Loading content...
Writing tests before code is counterintuitive. Your instinct says to solve the problem first, then verify. TDD asks you to define success first, then achieve it. This inversion requires deliberate practice and a specific set of techniques.
This page addresses the practical reality of test-first development: How do you write a test when nothing exists? How do you decide what to test next? How do you maintain the discipline when deadlines loom and pressure mounts?
These aren't theoretical concerns. Every TDD practitioner has faced the blank file, the unclear requirement, the temptation to "just write the code." Mastering test-first development means mastering these moments.
By the end of this page, you will have a practical toolkit for test-first development. You'll know how to start from nothing, choose the next test to write, handle uncertainty, and maintain discipline. These are the skills that separate TDD theorists from TDD practitioners.
The hardest test to write is the first one. There's no code, no structure, sometimes no clarity on what you're even building. This is exactly where TDD shines—it forces you to gain clarity before writing a single line of production code.
The Bootstrap Problem:
To test something, you need to know what it looks like. But you're supposed to write the test before the thing exists. This seems like a paradox, but it's actually an opportunity.
Starting with a test forces you to answer fundamental questions:
The First Test Formula:
Start with the absolute simplest assertion about your system. Often, this is a "degenerate" or "zero" case that seems almost trivial:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
// ═══════════════════════════════════════════════════════════// STARTING FROM ZERO: Building a Stack// ═══════════════════════════════════════════════════════════ // You need to build a stack. Where do you start?// With the simplest possible test. // Test 1: A new stack exists and is emptydescribe('Stack', () => { it('should be empty when newly created', () => { // What do I learn from writing this test? // - The class is called Stack // - It has an isEmpty() method // - New stacks are empty const stack = new Stack(); expect(stack.isEmpty()).toBe(true); });}); // Make it pass (GREEN):class Stack { isEmpty(): boolean { return true; // Hardcoded! And correct for now. }} // Test 2: After pushing, no longer emptyit('should not be empty after push', () => { // What do I learn from writing this test? // - Stack has a push() method // - push() takes an item (we'll use any for now) const stack = new Stack(); stack.push('item'); expect(stack.isEmpty()).toBe(false);}); // Make it pass:class Stack { private count = 0; push(item: unknown): void { this.count++; } isEmpty(): boolean { return this.count === 0; }} // Test 3: Pop returns what was pushedit('should return pushed item on pop', () => { // What do I learn from writing this test? // - Stack has a pop() method // - pop() returns the same type as pushed const stack = new Stack<string>(); // Generic now! stack.push('hello'); expect(stack.pop()).toBe('hello');}); // Notice: We discovered the need for generics!// The test revealed the design. // ═══════════════════════════════════════════════════════════// STARTING FROM ZERO: Building a URL Shortener// ═══════════════════════════════════════════════════════════ // More complex example: URL shortener servicedescribe('UrlShortener', () => { // Start with the simplest meaningful test it('should return a shortened URL for any input', () => { // What do I learn from writing this test? // - There's a UrlShortener class // - It has a shorten() method // - shorten() takes a URL string // - shorten() returns a URL string const shortener = new UrlShortener(); const original = 'https://example.com/very/long/path'; const shortened = shortener.shorten(original); // What's the minimal assertion? expect(shortened).toBeDefined(); expect(shortened.length).toBeLessThan(original.length); }); // Next: Verify the core contract it('should return original URL when expanded', () => { const shortener = new UrlShortener(); const original = 'https://example.com/page'; const shortened = shortener.shorten(original); const expanded = shortener.expand(shortened); expect(expanded).toBe(original); });}); // The test revealed:// - The class name and responsibility// - Two core methods: shorten() and expand()// - The fundamental contract: expand(shorten(x)) === x// We haven't thought about storage, algorithms, or IDs yet.// Those will emerge as we add tests.If you don't know where to start, test the empty case, the zero case, or the no-op case. These tests are almost always valid, easy to make pass, and establish the basic structure. From there, each subsequent test adds one small behavior.
After the first test, how do you decide what to test next? This decision shapes how your design evolves. Choose wisely, and your code grows in useful directions. Choose poorly, and you fight against your own structure.
The Test Selection Heuristics:
Experienced TDD practitioners use several heuristics to choose the next test:
| Strategy | When to Use | Example |
|---|---|---|
| Start with degenerate cases | Beginning of development | Empty list, zero amount, null input |
| Add one complexity | After basics work | One item → two items → many items |
| Happy path first | Core functionality unclear | Successful login before failed login |
| Error paths next | After happy path works | Invalid credentials, expired tokens |
| Boundary conditions | Behavior varies at edges | Zero, negative, max values, empty strings |
| Combination cases | Multiple features interact | Discount + tax, multiple filters |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
// ═══════════════════════════════════════════════════════════// PROGRESSIVE TEST SEQUENCE: FizzBuzz// Demonstrating thoughtful test ordering// ═══════════════════════════════════════════════════════════ describe('FizzBuzz', () => { // Step 1: Degenerate case - what's the simplest input? it('should return "1" for input 1', () => { expect(fizzBuzz(1)).toBe('1'); }); // Step 2: Another simple case - is the pattern clear? it('should return "2" for input 2', () => { expect(fizzBuzz(2)).toBe('2'); }); // Step 3: First special case - divisible by 3 it('should return "Fizz" for numbers divisible by 3', () => { expect(fizzBuzz(3)).toBe('Fizz'); expect(fizzBuzz(6)).toBe('Fizz'); }); // Step 4: Second special case - divisible by 5 it('should return "Buzz" for numbers divisible by 5', () => { expect(fizzBuzz(5)).toBe('Buzz'); expect(fizzBuzz(10)).toBe('Buzz'); }); // Step 5: Combination case - divisible by both it('should return "FizzBuzz" for numbers divisible by 3 and 5', () => { expect(fizzBuzz(15)).toBe('FizzBuzz'); expect(fizzBuzz(30)).toBe('FizzBuzz'); }); // Step 6: Edge case - zero it('should handle zero', () => { expect(fizzBuzz(0)).toBe('FizzBuzz'); // 0 divisible by everything }); // Step 7: Error case - negative numbers it('should throw for negative numbers', () => { expect(() => fizzBuzz(-1)).toThrow('Input must be non-negative'); });}); // Notice the progression:// 1. Simple → Complex// 2. Happy path → Edge cases → Error cases// 3. Each test adds exactly one new concept // ═══════════════════════════════════════════════════════════// PROGRESSIVE TEST SEQUENCE: Password Validator// Real-world example with business rules// ═══════════════════════════════════════════════════════════ describe('PasswordValidator', () => { const validator = new PasswordValidator(); // Start with the question: What's a valid password? // Define minimum viable validity it('should accept password meeting all requirements', () => { const result = validator.validate('SecurePass123!'); expect(result.isValid).toBe(true); }); // Now systematically test each requirement violation it('should reject password shorter than 8 characters', () => { const result = validator.validate('Short1!'); expect(result.isValid).toBe(false); expect(result.errors).toContain('Password must be at least 8 characters'); }); it('should reject password without uppercase letter', () => { const result = validator.validate('lowercase123!'); expect(result.isValid).toBe(false); expect(result.errors).toContain('Password must contain uppercase letter'); }); it('should reject password without lowercase letter', () => { const result = validator.validate('UPPERCASE123!'); expect(result.isValid).toBe(false); expect(result.errors).toContain('Password must contain lowercase letter'); }); it('should reject password without number', () => { const result = validator.validate('NoNumbers!Only'); expect(result.isValid).toBe(false); expect(result.errors).toContain('Password must contain a number'); }); it('should reject password without special character', () => { const result = validator.validate('NoSpecial123'); expect(result.isValid).toBe(false); expect(result.errors).toContain('Password must contain special character'); }); // Boundary cases it('should accept password with exactly 8 characters', () => { const result = validator.validate('Short1!A'); expect(result.isValid).toBe(true); }); // Multiple violations it('should report all violations when multiple rules broken', () => { const result = validator.validate('weak'); expect(result.isValid).toBe(false); expect(result.errors.length).toBeGreaterThan(1); });}); // Each test explores one aspect of the validator.// By the end, we have comprehensive coverage// AND the design emerged naturally.Before coding, brainstorm a list of tests you might write. Don't commit to order yet—just capture ideas. As you work, cross off completed tests and add new ones that emerge. This list keeps you focused and prevents forgetting edge cases.
A test's power lies in its assertion. Weak assertions allow bugs to pass. Strong assertions catch regressions precisely. Learning to write effective assertions is a core TDD skill.
Characteristics of Good Assertions:
Common Assertion Pitfalls:
expect(result).toBeDefined() — Tells us almost nothingexpect(result).toBeTruthy() — Could be any non-falsy valueexpect(() => fn()).not.toThrow() — Doesn't verify what fn() didexpect(spy).toHaveBeenCalled() — Doesn't verify correct argumentsexpect(arr.length).toBeGreaterThan(0) — What values are in it?expect(result).toBe(42) — Specifies exact expected valueexpect(user.isActive()).toBe(true) — Tests specific stateexpect(fn()).toEqual({ status: 'ok', id: 1 }) — Verifies structureexpect(spy).toHaveBeenCalledWith(email, password) — Exact argumentsexpect(arr).toEqual([1, 2, 3]) — Verifies exact contents123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
// ═══════════════════════════════════════════════════════════// ASSERTION QUALITY SPECTRUM// ═══════════════════════════════════════════════════════════ describe('Order total calculation', () => { const order = new Order([ new LineItem('Widget', 10.00, 2), new LineItem('Gadget', 25.00, 1), ]); // WEAK: Just checking existence it('should calculate total (weak)', () => { const total = order.getTotal(); expect(total).toBeDefined(); // What is the total? }); // BETTER: Checking it's a reasonable number it('should calculate total (better)', () => { const total = order.getTotal(); expect(total).toBeGreaterThan(0); // Still doesn't verify correctness }); // STRONG: Checking exact expected value it('should calculate total as sum of line items', () => { const total = order.getTotal(); expect(total).toBe(45.00); // 10*2 + 25*1 = 45 }); // STRONGEST: The calculation IS the assertion it('should equal sum of (price × quantity) for all items', () => { const total = order.getTotal(); const expected = order.items.reduce( (sum, item) => sum + item.price * item.quantity, 0 ); expect(total).toBe(expected); // Also verify specific value to catch both sides being wrong expect(total).toBe(45.00); });}); // ═══════════════════════════════════════════════════════════// ASSERTING ON BEHAVIOR, NOT IMPLEMENTATION// ═══════════════════════════════════════════════════════════ describe('UserCache', () => { // BAD: Testing implementation details it('should use Map internally (implementation test)', () => { const cache = new UserCache(); cache.store(user); // This tests HOW we cache, not WHAT we cache expect((cache as any).cache instanceof Map).toBe(true); expect((cache as any).cache.has(user.id)).toBe(true); }); // GOOD: Testing observable behavior it('should return stored user by id', () => { const cache = new UserCache(); const user = new User('123', 'Alice'); cache.store(user); const retrieved = cache.get('123'); // Tests behavior: store then get returns same user expect(retrieved).toEqual(user); }); // GOOD: Testing cache semantics without implementation it('should return undefined for non-existent id', () => { const cache = new UserCache(); expect(cache.get('unknown')).toBeUndefined(); }); it('should update existing entry on re-store', () => { const cache = new UserCache(); const original = new User('123', 'Alice'); const updated = new User('123', 'Alice Updated'); cache.store(original); cache.store(updated); expect(cache.get('123')?.name).toBe('Alice Updated'); });}); // ═══════════════════════════════════════════════════════════// ASSERTING ON STATE CHANGES// ═══════════════════════════════════════════════════════════ describe('Account', () => { // WEAK: Testing method was called it('should call persist on deposit (weak)', () => { const mockRepo = { save: jest.fn() }; const account = new Account(mockRepo); account.deposit(100); // Only verifies save was called, not that balance changed expect(mockRepo.save).toHaveBeenCalled(); }); // STRONG: Testing state change AND persistence it('should increase balance and persist on deposit', () => { const mockRepo = { save: jest.fn() }; const account = new Account(mockRepo, { balance: 50 }); account.deposit(100); // Verify state change expect(account.balance).toBe(150); // Verify persistence with correct state expect(mockRepo.save).toHaveBeenCalledWith( expect.objectContaining({ balance: 150 }) ); });});Sometimes you genuinely don't know enough to write a test. The algorithm is unclear, the API is unfamiliar, the requirement is ambiguous. TDD dogma might say "write the test anyway," but that's often counterproductive.
The Spike Technique:
A "spike" is throwaway code written to explore and learn. You deliberately violate TDD to gain understanding, then throw away the spike code and rebuild with TDD.
Spikes are appropriate when:
Rules for Healthy Spiking:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
// ═══════════════════════════════════════════════════════════// SPIKE: Exploring an unfamiliar API// Goal: Learn how to use a PDF generation library// ═══════════════════════════════════════════════════════════ // spike-pdf.ts (THROWAWAY CODE - do not commit)import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; async function explorePdfGeneration() { // Just experimenting... const doc = await PDFDocument.create(); const page = doc.addPage([600, 400]); const font = await doc.embedFont(StandardFonts.Helvetica); page.drawText('Hello PDF!', { x: 50, y: 350, size: 30, font, color: rgb(0, 0, 0), }); const bytes = await doc.save(); await Deno.writeFile('test-output.pdf', bytes); console.log('PDF created!'); // Learned: // - PDFDocument.create() is async // - Pages need explicit dimensions // - Fonts must be embedded // - Coordinates are bottom-left origin // - save() returns Uint8Array} explorePdfGeneration(); // After spike: DELETE this file completely // ═══════════════════════════════════════════════════════════// REBUILD WITH TDD: Now we know enough to test-drive// ═══════════════════════════════════════════════════════════ // invoice-pdf-generator.test.tsdescribe('InvoicePdfGenerator', () => { // Now we can write meaningful tests because we understand the domain it('should generate PDF with company name in header', async () => { const generator = new InvoicePdfGenerator(); const invoice = new Invoice({ company: 'Acme Corp', items: [{ description: 'Widget', amount: 100 }], }); const pdf = await generator.generate(invoice); // We know from spike that we can extract text content expect(pdf.getTextContent()).toContain('Acme Corp'); }); it('should include all line items with amounts', async () => { const generator = new InvoicePdfGenerator(); const invoice = new Invoice({ company: 'Test Co', items: [ { description: 'Widget', amount: 100 }, { description: 'Gadget', amount: 250 }, ], }); const pdf = await generator.generate(invoice); const content = pdf.getTextContent(); expect(content).toContain('Widget'); expect(content).toContain('$100.00'); expect(content).toContain('Gadget'); expect(content).toContain('$250.00'); }); it('should calculate and display total', async () => { const generator = new InvoicePdfGenerator(); const invoice = new Invoice({ company: 'Test Co', items: [ { description: 'Widget', amount: 100 }, { description: 'Gadget', amount: 250 }, ], }); const pdf = await generator.generate(invoice); expect(pdf.getTextContent()).toContain('Total: $350.00'); });}); // The spike taught us:// - How the PDF library works// - What we can and cannot assert about PDFs// - The structure of our generator class// Now TDD drives the design with full understanding.The spike code 'works.' Throwing it away feels wasteful. But spike code lacks tests, lacks design, lacks thought. If you keep it, you're building on a foundation of sand. The discipline is: spike, learn, delete, rebuild with TDD.
TDD principles remain constant, but their application varies by scenario. Let's explore test-first approaches for common development situations.
Building a new feature from scratch:
// Feature: User can search products by name
it('should return empty list when no products match', () => {
const catalog = new ProductCatalog();
expect(catalog.search('xyz')).toEqual([]);
});
it('should return matching products', () => {
const catalog = new ProductCatalog();
catalog.add(new Product('Widget'));
catalog.add(new Product('Gadget'));
const results = catalog.search('Widget');
expect(results).toHaveLength(1);
expect(results[0].name).toBe('Widget');
});
it('should match partial names', () => {
const catalog = new ProductCatalog();
catalog.add(new Product('Blue Widget'));
expect(catalog.search('Widget')).toHaveLength(1);
expect(catalog.search('Blue')).toHaveLength(1);
});
it('should be case-insensitive', () => {
const catalog = new ProductCatalog();
catalog.add(new Product('Widget'));
expect(catalog.search('widget')).toHaveLength(1);
expect(catalog.search('WIDGET')).toHaveLength(1);
});
TDD requires discipline, and discipline is hardest when under pressure. Deadlines loom, production is down, or the feature "just needs to work." These moments test your commitment to TDD.
The Pressures That Break TDD:
Strategies for Maintaining Discipline:
Each pressure has counterarguments that reinforce TDD's value:
| Pressure | The Temptation | The Reality | The Discipline |
|---|---|---|---|
| Time crunch | "Skip tests to ship faster" | Bugs from untested code take longer to fix than tests take to write | TDD is faster when you factor in debugging time |
| Complex feature | "Figure out the code first, test later" | Without tests, you'll spend hours debugging instead of minutes testing | Small tests for small parts. Complexity is accumulated simplicity |
| Production incident | "Just fix it, we'll test later" | Without a test, the same bug will recur | First: reproduce with test. Then: fix. Then: never see it again |
| Design uncertainty | "I'll write tests when the design is stable" | TDD helps you find the right design faster | Use spikes for learning, TDD for building |
When tempted to skip TDD, commit to 5 minutes: write one small test. Often, that single test reveals the design and makes the next step obvious. The activation energy of starting is the hardest part. Once you're in the cycle, momentum carries you.
Writing tests first is a learnable skill. Like any skill, it improves with practice and conscious effort. Let's consolidate the practical techniques:
What's Next:
With the theory and techniques of TDD understood, the next page puts it all together with TDD in practice. We'll walk through a complete feature development using TDD, showing how cycles accumulate into working software.
You now have practical techniques for writing tests before code. Starting from nothing, choosing the next test, writing strong assertions, handling uncertainty, and maintaining discipline—these skills transform TDD from theory to daily practice.