Testing — Test-Driven Development
Test-Driven Development (TDD) is a software development methodology where you write tests before writing the production code. The cycle is simple: write a failing test (red), make it pass (green), then clean up the code (refactor). While counterintuitive at first, this approach leads to better-designed code, higher test coverage, and fewer bugs.
TDD was popularized by Kent Beck in the late 1990s as part of Extreme Programming (XP). Despite being over two decades old, it remains one of the most debated and misunderstood practices in software engineering. When done correctly, TDD acts as a design tool that shapes your code architecture — not just a testing technique.
The TDD cycle consists of three phases, repeated for each unit of functionality. Each cycle should take 30 seconds to 5 minutes. If a cycle takes longer, the unit of work is too large.
| 1 | // RED — Write a failing test first |
| 2 | import { calculateDiscount } from "./pricing"; |
| 3 | |
| 4 | test("applies 10% discount for orders over $100", () => { |
| 5 | const result = calculateDiscount(150, { type: "volume" }); |
| 6 | expect(result).toBe(135); // $150 - 10% = $135 |
| 7 | }); |
| 8 | |
| 9 | // At this point, calculateDiscount doesn't exist yet. |
| 10 | // Run the test: FAIL (red) |
| 11 | |
| 12 | // GREEN — Write minimal code to make the test pass |
| 13 | export function calculateDiscount( |
| 14 | amount: number, |
| 15 | discount: { type: string } |
| 16 | ): number { |
| 17 | if (discount.type === "volume" && amount > 100) { |
| 18 | return amount * 0.9; |
| 19 | } |
| 20 | return amount; |
| 21 | } |
| 22 | |
| 23 | // Run the test: PASS (green) |
| 24 | |
| 25 | // REFACTOR — Clean up without changing behavior |
| 26 | const DISCOUNT_THRESHOLDS = { |
| 27 | volume: { minAmount: 100, rate: 0.9 }, |
| 28 | seasonal: { minAmount: 0, rate: 0.85 }, |
| 29 | clearance: { minAmount: 0, rate: 0.6 }, |
| 30 | } as const; |
| 31 | |
| 32 | export function calculateDiscount( |
| 33 | amount: number, |
| 34 | discount: { type: keyof typeof DISCOUNT_THRESHOLDS } |
| 35 | ): number { |
| 36 | const config = DISCOUNT_THRESHOLDS[discount.type]; |
| 37 | if (amount >= config.minAmount) { |
| 38 | return amount * config.rate; |
| 39 | } |
| 40 | return amount; |
| 41 | } |
| 42 | |
| 43 | // Run the test: still PASS (green) |
| 44 | // The refactor changed the implementation but preserved behavior |
info
Writing the test first forces you to think about the interface before the implementation. You define what the code should do before deciding how it should do it. This shifts focus from implementation details to behavior specification.
| 1 | // Step 1: Think about the API you want |
| 2 | // Before writing any code, ask: |
| 3 | // - What inputs does this function take? |
| 4 | // - What output does it produce? |
| 5 | // - What edge cases exist? |
| 6 | // - What errors can occur? |
| 7 | |
| 8 | // Step 2: Write the test as if the implementation exists |
| 9 | import { parseCSV } from "./csv-parser"; |
| 10 | |
| 11 | describe("parseCSV", () => { |
| 12 | it("parses a simple CSV string into an array of objects", () => { |
| 13 | const csv = "name,age\nAlice,30\nBob,25"; |
| 14 | const result = parseCSV(csv); |
| 15 | |
| 16 | expect(result).toEqual([ |
| 17 | { name: "Alice", age: "30" }, |
| 18 | { name: "Bob", age: "25" }, |
| 19 | ]); |
| 20 | }); |
| 21 | |
| 22 | it("handles quoted fields with commas", () => { |
| 23 | const csv = 'name,description\nAlice,"loves cats, dogs, and birds"'; |
| 24 | const result = parseCSV(csv); |
| 25 | |
| 26 | expect(result).toEqual([ |
| 27 | { name: "Alice", description: "loves cats, dogs, and birds" }, |
| 28 | ]); |
| 29 | }); |
| 30 | |
| 31 | it("returns empty array for empty string", () => { |
| 32 | expect(parseCSV("")).toEqual([]); |
| 33 | }); |
| 34 | |
| 35 | it("throws on malformed input", () => { |
| 36 | expect(() => parseCSV("a,b,c\n1,2")).toThrow("Invalid row length"); |
| 37 | }); |
| 38 | |
| 39 | it("trims whitespace from headers and values", () => { |
| 40 | const csv = " name , age \n Alice , 30 "; |
| 41 | const result = parseCSV(csv); |
| 42 | |
| 43 | expect(result).toEqual([ |
| 44 | { name: "Alice", age: "30" }, |
| 45 | ]); |
| 46 | }); |
| 47 | }); |
| 48 | |
| 49 | // Step 3: Run the test — expect every test to fail (red) |
| 50 | // This confirms the test can fail (avoids false positives) |
| 51 | // npx vitest run → all tests FAIL |
warning
In the green phase, write the simplest possible code that makes the test pass. Resist the urge to write production-ready code — that comes in the refactor phase. The green phase is about finding the minimal path to passing tests.
| 1 | // Minimal implementation to pass the first test |
| 2 | // csv-parser.ts |
| 3 | export function parseCSV(csv: string): Record<string, string>[] { |
| 4 | if (csv === "") return []; |
| 5 | |
| 6 | const lines = csv.split("\n"); |
| 7 | const headers = lines[0].split(","); |
| 8 | const result: Record<string, string>[] = []; |
| 9 | |
| 10 | for (let i = 1; i < lines.length; i++) { |
| 11 | const values = lines[i].split(","); |
| 12 | const row: Record<string, string> = {}; |
| 13 | headers.forEach((header, index) => { |
| 14 | row[header.trim()] = values[index]?.trim() ?? ""; |
| 15 | }); |
| 16 | result.push(row); |
| 17 | } |
| 18 | |
| 19 | return result; |
| 20 | } |
| 21 | |
| 22 | // This passes the first test and the empty-string test. |
| 23 | // The quoted-fields test and malformed-input test still fail. |
| 24 | // Next cycle: handle quoted fields. |
| 25 | |
| 26 | // After several cycles, handling edge cases: |
| 27 | export function parseCSV(csv: string): Record<string, string>[] { |
| 28 | if (csv === "") return []; |
| 29 | |
| 30 | const lines = csv.split("\n"); |
| 31 | if (lines.length < 2) throw new Error("No data rows"); |
| 32 | |
| 33 | const headers = parseLine(lines[0]); |
| 34 | const result: Record<string, string>[] = []; |
| 35 | |
| 36 | for (let i = 1; i < lines.length; i++) { |
| 37 | const values = parseLine(lines[i]); |
| 38 | if (values.length !== headers.length) { |
| 39 | throw new Error(`Invalid row length at line ${i + 1}`); |
| 40 | } |
| 41 | const row: Record<string, string> = {}; |
| 42 | headers.forEach((header, index) => { |
| 43 | row[header.trim()] = values[index]?.trim() ?? ""; |
| 44 | }); |
| 45 | result.push(row); |
| 46 | } |
| 47 | |
| 48 | return result; |
| 49 | } |
| 50 | |
| 51 | function parseLine(line: string): string[] { |
| 52 | const values: string[] = []; |
| 53 | let current = ""; |
| 54 | let inQuotes = false; |
| 55 | |
| 56 | for (const char of line) { |
| 57 | if (char === '"') { |
| 58 | inQuotes = !inQuotes; |
| 59 | } else if (char === "," && !inQuotes) { |
| 60 | values.push(current); |
| 61 | current = ""; |
| 62 | } else { |
| 63 | current += char; |
| 64 | } |
| 65 | } |
| 66 | values.push(current); |
| 67 | |
| 68 | return values; |
| 69 | } |
| 70 | |
| 71 | // Now all tests pass. Next: refactor. |
info
The refactor phase is where you improve code quality without changing behavior. The test suite acts as a safety net — you can restructure code aggressively because the tests will catch any behavioral changes.
| 1 | // Before refactor — works but has quality issues |
| 2 | function processOrder(items: any[], coupon?: string) { |
| 3 | let total = 0; |
| 4 | for (let i = 0; i < items.length; i++) { |
| 5 | total += items[i].price * items[i].quantity; |
| 6 | } |
| 7 | if (coupon) { |
| 8 | if (coupon === "SAVE10") { |
| 9 | total = total * 0.9; |
| 10 | } else if (coupon === "SAVE20") { |
| 11 | total = total * 0.8; |
| 12 | } |
| 13 | } |
| 14 | total = total + total * 0.08; |
| 15 | return Math.round(total * 100) / 100; |
| 16 | } |
| 17 | |
| 18 | // After refactor — same behavior, better structure |
| 19 | interface OrderItem { |
| 20 | price: number; |
| 21 | quantity: number; |
| 22 | } |
| 23 | |
| 24 | const TAX_RATE = 0.08; |
| 25 | |
| 26 | const DISCOUNT_MAP: Record<string, (total: number) => number> = { |
| 27 | SAVE10: (total) => total * 0.9, |
| 28 | SAVE20: (total) => total * 0.8, |
| 29 | }; |
| 30 | |
| 31 | function calculateSubtotal(items: OrderItem[]): number { |
| 32 | return items.reduce((sum, item) => sum + item.price * item.quantity, 0); |
| 33 | } |
| 34 | |
| 35 | function applyCoupon(total: number, coupon: string): number { |
| 36 | const discountFn = DISCOUNT_MAP[coupon]; |
| 37 | return discountFn ? discountFn(total) : total; |
| 38 | } |
| 39 | |
| 40 | function applyTax(total: number): number { |
| 41 | return total + total * TAX_RATE; |
| 42 | } |
| 43 | |
| 44 | export function processOrder(items: OrderItem[], coupon?: string): number { |
| 45 | const subtotal = calculateSubtotal(items); |
| 46 | const afterDiscount = coupon ? applyCoupon(subtotal, coupon) : subtotal; |
| 47 | const withTax = applyTax(afterDiscount); |
| 48 | return Math.round(withTax * 100) / 100; |
| 49 | } |
| 50 | |
| 51 | // Run tests after refactoring — all GREEN |
| 52 | // Tests prove the refactor preserved behavior |
info
Pure functions are the sweet spot for TDD. They have no side effects, no dependencies, and produce the same output for the same input. This makes them trivially testable and perfect for the TDD cycle.
| 1 | // TDD for a pure function: formatRelativeTime |
| 2 | |
| 3 | // 1. RED — Write tests first |
| 4 | import { formatRelativeTime } from "./time"; |
| 5 | |
| 6 | describe("formatRelativeTime", () => { |
| 7 | it('returns "just now" for a few seconds ago', () => { |
| 8 | const now = Date.now(); |
| 9 | expect(formatRelativeTime(now - 5000, now)).toBe("just now"); |
| 10 | }); |
| 11 | |
| 12 | it('returns "5 minutes ago" for 5 minutes', () => { |
| 13 | const now = Date.now(); |
| 14 | const fiveMinAgo = now - 5 * 60 * 1000; |
| 15 | expect(formatRelativeTime(fiveMinAgo, now)).toBe("5 minutes ago"); |
| 16 | }); |
| 17 | |
| 18 | it('returns "1 hour ago" for exactly one hour', () => { |
| 19 | const now = Date.now(); |
| 20 | const oneHourAgo = now - 60 * 60 * 1000; |
| 21 | expect(formatRelativeTime(oneHourAgo, now)).toBe("1 hour ago"); |
| 22 | }); |
| 23 | |
| 24 | it('returns "yesterday" for 24-48 hours', () => { |
| 25 | const now = Date.now(); |
| 26 | const yesterday = now - 30 * 60 * 60 * 1000; |
| 27 | expect(formatRelativeTime(yesterday, now)).toBe("yesterday"); |
| 28 | }); |
| 29 | |
| 30 | it('returns "3 days ago" for 3 days', () => { |
| 31 | const now = Date.now(); |
| 32 | const threeDaysAgo = now - 3 * 24 * 60 * 60 * 1000; |
| 33 | expect(formatRelativeTime(threeDaysAgo, now)).toBe("3 days ago"); |
| 34 | }); |
| 35 | |
| 36 | it("returns the date string for older dates", () => { |
| 37 | const now = Date.now(); |
| 38 | const oldDate = new Date("2023-01-15").getTime(); |
| 39 | expect(formatRelativeTime(oldDate, now)).toBe("2023-01-15"); |
| 40 | }); |
| 41 | |
| 42 | it("handles future dates gracefully", () => { |
| 43 | const now = Date.now(); |
| 44 | const future = now + 60 * 60 * 1000; |
| 45 | expect(formatRelativeTime(future, now)).toBe("in 1 hour"); |
| 46 | }); |
| 47 | }); |
| 48 | |
| 49 | // 2. Implement one case at a time (GREEN) |
| 50 | // 3. Refactor after each cycle |
| 51 | // Final implementation after all cycles: |
| 52 | |
| 53 | export function formatRelativeTime( |
| 54 | timestamp: number, |
| 55 | now: number = Date.now() |
| 56 | ): string { |
| 57 | const diff = now - timestamp; |
| 58 | const absDiff = Math.abs(diff); |
| 59 | const seconds = Math.floor(absDiff / 1000); |
| 60 | const minutes = Math.floor(seconds / 60); |
| 61 | const hours = Math.floor(minutes / 60); |
| 62 | const days = Math.floor(hours / 24); |
| 63 | |
| 64 | const prefix = diff >= 0 ? "" : "in "; |
| 65 | const suffix = diff >= 0 ? " ago" : ""; |
| 66 | |
| 67 | if (seconds < 60) return prefix + "just now"; |
| 68 | if (minutes === 1) return prefix + "1 minute" + suffix; |
| 69 | if (minutes < 60) return prefix + minutes + " minutes" + suffix; |
| 70 | if (hours === 1) return prefix + "1 hour" + suffix; |
| 71 | if (hours < 24) return prefix + hours + " hours" + suffix; |
| 72 | if (days === 1) return prefix + "yesterday"; |
| 73 | if (days < 30) return prefix + days + " days" + suffix; |
| 74 | |
| 75 | return new Date(timestamp).toISOString().split("T")[0]; |
| 76 | } |
TDD for React components follows the same cycle but focuses on user-visible behavior rather than implementation details. Use Testing Library queries that match how users interact with the component.
| 1 | // TDD for a SearchableDropdown component |
| 2 | // 1. RED — write behavior tests |
| 3 | import { render, screen } from "@testing-library/react"; |
| 4 | import userEvent from "@testing-library/user-event"; |
| 5 | import SearchableDropdown from "./SearchableDropdown"; |
| 6 | |
| 7 | const options = ["Apple", "Banana", "Cherry", "Date", "Elderberry"]; |
| 8 | |
| 9 | describe("SearchableDropdown", () => { |
| 10 | it("shows all options when input is focused", async () => { |
| 11 | render(<SearchableDropdown options={options} />); |
| 12 | const input = screen.getByRole("combobox"); |
| 13 | await userEvent.click(input); |
| 14 | |
| 15 | options.forEach((opt) => { |
| 16 | expect(screen.getByText(opt)).toBeInTheDocument(); |
| 17 | }); |
| 18 | }); |
| 19 | |
| 20 | it("filters options as user types", async () => { |
| 21 | render(<SearchableDropdown options={options} />); |
| 22 | const input = screen.getByRole("combobox"); |
| 23 | await userEvent.type(input, "Ap"); |
| 24 | |
| 25 | expect(screen.getByText("Apple")).toBeInTheDocument(); |
| 26 | expect(screen.queryByText("Banana")).not.toBeInTheDocument(); |
| 27 | }); |
| 28 | |
| 29 | it("selects an option on click", async () => { |
| 30 | const onChange = vi.fn(); |
| 31 | render(<SearchableDropdown options={options} onChange={onChange} />); |
| 32 | |
| 33 | const input = screen.getByRole("combobox"); |
| 34 | await userEvent.click(input); |
| 35 | await userEvent.click(screen.getByText("Cherry")); |
| 36 | |
| 37 | expect(onChange).toHaveBeenCalledWith("Cherry"); |
| 38 | expect(input).toHaveValue("Cherry"); |
| 39 | }); |
| 40 | |
| 41 | it("shows no results message when no match found", async () => { |
| 42 | render(<SearchableDropdown options={options} />); |
| 43 | const input = screen.getByRole("combobox"); |
| 44 | await userEvent.type(input, "Xylophone"); |
| 45 | |
| 46 | expect(screen.getByText("No results found")).toBeInTheDocument(); |
| 47 | }); |
| 48 | |
| 49 | it("closes dropdown when clicking outside", async () => { |
| 50 | render( |
| 51 | <div> |
| 52 | <SearchableDropdown options={options} /> |
| 53 | <button>Outside</button> |
| 54 | </div> |
| 55 | ); |
| 56 | |
| 57 | const input = screen.getByRole("combobox"); |
| 58 | await userEvent.click(input); |
| 59 | expect(screen.getByText("Apple")).toBeInTheDocument(); |
| 60 | |
| 61 | await userEvent.click(screen.getByText("Outside")); |
| 62 | expect(screen.queryByText("Apple")).not.toBeInTheDocument(); |
| 63 | }); |
| 64 | }); |
| 65 | |
| 66 | // 2. Implement incrementally (GREEN) |
| 67 | // 3. Refactor |
warning
TDD works for API integration when combined with MSW (Mock Service Worker). The tests define the contract between your application and the API, making the expected behavior explicit before the implementation.
| 1 | // TDD for an API client |
| 2 | import { http, HttpResponse } from "msw"; |
| 3 | import { setupServer } from "msw/node"; |
| 4 | import { createUser } from "./api-client"; |
| 5 | |
| 6 | const server = setupServer(); |
| 7 | |
| 8 | beforeAll(() => server.listen()); |
| 9 | afterEach(() => server.resetHandlers()); |
| 10 | afterAll(() => server.close()); |
| 11 | |
| 12 | describe("createUser", () => { |
| 13 | it("sends POST request with user data", async () => { |
| 14 | let capturedRequest: any; |
| 15 | server.use( |
| 16 | http.post("/api/users", async ({ request }) => { |
| 17 | capturedRequest = request; |
| 18 | return HttpResponse.json({ id: 1 }, { status: 201 }); |
| 19 | }) |
| 20 | ); |
| 21 | |
| 22 | const result = await createUser({ name: "Alice", email: "alice@test.com" }); |
| 23 | |
| 24 | expect(capturedRequest.method).toBe("POST"); |
| 25 | expect(await capturedRequest.json()).toEqual({ |
| 26 | name: "Alice", |
| 27 | email: "alice@test.com", |
| 28 | }); |
| 29 | expect(result).toEqual({ id: 1 }); |
| 30 | }); |
| 31 | |
| 32 | it("throws on network error", async () => { |
| 33 | server.use( |
| 34 | http.post("/api/users", () => { |
| 35 | return HttpResponse.error(); |
| 36 | }) |
| 37 | ); |
| 38 | |
| 39 | await expect( |
| 40 | createUser({ name: "Alice", email: "alice@test.com" }) |
| 41 | ).rejects.toThrow("Network request failed"); |
| 42 | }); |
| 43 | |
| 44 | it("throws on validation error", async () => { |
| 45 | server.use( |
| 46 | http.post("/api/users", () => { |
| 47 | return HttpResponse.json( |
| 48 | { error: "Email already exists" }, |
| 49 | { status: 422 } |
| 50 | ); |
| 51 | }) |
| 52 | ); |
| 53 | |
| 54 | await expect( |
| 55 | createUser({ name: "Alice", email: "existing@test.com" }) |
| 56 | ).rejects.toThrow("Email already exists"); |
| 57 | }); |
| 58 | |
| 59 | it("handles rate limiting", async () => { |
| 60 | let attempts = 0; |
| 61 | server.use( |
| 62 | http.post("/api/users", () => { |
| 63 | attempts++; |
| 64 | if (attempts === 1) { |
| 65 | return HttpResponse.json(null, { status: 429, headers: { "Retry-After": "1" } }); |
| 66 | } |
| 67 | return HttpResponse.json({ id: 2 }, { status: 201 }); |
| 68 | }) |
| 69 | ); |
| 70 | |
| 71 | const result = await createUser({ name: "Bob", email: "bob@test.com" }); |
| 72 | expect(result).toEqual({ id: 2 }); |
| 73 | expect(attempts).toBe(2); // Retried once |
| 74 | }); |
| 75 | }); |
TDD is not universally applicable. Understanding where it adds the most value helps you apply it effectively.
| Situation | TDD Effectiveness | Reason |
|---|---|---|
| Pure business logic | Excellent | No dependencies, predictable outputs |
| API clients | Good | Contract-driven, mockable network |
| React components | Moderate | Requires Testing Library discipline |
| UI animations | Poor | Time-dependent, hard to assert |
| Database migrations | Poor | Side-effect heavy, stateful |
| Third-party integration | Moderate | Mock-dependent, integration tests help |
| Refactoring legacy code | Excellent | Characterization tests lock behavior |
info
Behavior-Driven Development (BDD) extends TDD by focusing on the behavior of the system from the user's perspective. While TDD asks "does the code work?", BDD asks "does the system behave correctly?"
| Aspect | TDD | BDD |
|---|---|---|
| Focus | Unit-level correctness | Behavioral specification |
| Language | Code (describe/it/expect) | Natural language (Given/When/Then) |
| Audience | Developers | Developers + stakeholders |
| Tooling | Vitest, Jest | Cucumber, Jest, Vitest (with extensions) |
| Scope | Single unit | Feature or scenario |
| Writing order | Test first | Scenario first |
| 1 | // TDD style — developer-focused |
| 2 | describe("ShoppingCart", () => { |
| 3 | it("calculates total with tax", () => { |
| 4 | const cart = new ShoppingCart(); |
| 5 | cart.addItem({ price: 10, quantity: 2 }); |
| 6 | expect(cart.getTotal(0.08)).toBe(21.6); |
| 7 | }); |
| 8 | }); |
| 9 | |
| 10 | // BDD style — behavior-focused |
| 11 | describe("Shopping cart checkout", () => { |
| 12 | it("should show correct total when customer has items in cart", () => { |
| 13 | // Given the customer has items in their cart |
| 14 | render(<ShoppingCart />); |
| 15 | await userEvent.click(screen.getByText("Add Item")); |
| 16 | await userEvent.click(screen.getByText("Add Item")); |
| 17 | |
| 18 | // When they proceed to checkout |
| 19 | await userEvent.click(screen.getByText("Checkout")); |
| 20 | |
| 21 | // Then they should see the total with tax |
| 22 | expect(screen.getByTestId("total")).toHaveTextContent("$21.60"); |
| 23 | }); |
| 24 | }); |
| 25 | |
| 26 | // BDD with Gherkin syntax (via cucumber) |
| 27 | // Feature: Shopping Cart |
| 28 | // Scenario: Customer adds items and checks out |
| 29 | // Given the customer has 2 items in their cart |
| 30 | // When they proceed to checkout |
| 31 | // Then they should see a total of $21.60 |
info
- Skipping the refactor phase — Writing only the red and green phases leads to technical debt. The refactor phase is where TDD earns its keep.
- Writing too large a test — If a red-green cycle takes more than 5 minutes, the test is too big. Break it into smaller units.
- Writing the test for the implementation — Tests should specify behavior, not verify implementation. Avoid testing private methods or internal state.
- Ignoring test failures — A failing test is information. Investigate immediately. Never disable a failing test without understanding the root cause.
- Over-mocking — Excessive mocking creates fragile tests that break on every refactor. Mock at the right level — prefer dependency injection.
- Not running tests continuously — TDD requires running tests every 30-60 seconds. If your test suite takes minutes to run, it is too slow for effective TDD.
warning
- Write the test as if the implementation exists — Focus on the ideal API, not what you think is implementable. The implementation will adapt to the interface.
- One assertion per test — Each test should verify one behavior. Multiple assertions in a single test make it harder to identify what failed.
- Use descriptive test names — A test name should describe the behavior, not the implementation: test("returns discounted price for premium members").
- Keep tests fast — Unit tests should execute in milliseconds. If a test is slow, it likely touches too many dependencies.
- Refactor tests too — Tests need maintenance just like production code. Duplicate setup, unclear assertions, and hard-coded values are all test smells.
- Pair program for TDD — TDD is easier with a partner. One person writes the test (red), the other makes it pass (green), and both refactor together.
info