|$ curl https://forge-ai.dev/api/markdown?path=docs/testing/tdd
$cat docs/testing-—-test-driven-development.md
updated Recently·14 min read·published

Testing — Test-Driven Development

TestingIntermediate
Introduction

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 Red-Green-Refactor Cycle

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.

red-green-refactor.test.ts
TypeScript
1// RED — Write a failing test first
2import { calculateDiscount } from "./pricing";
3
4test("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
13export 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
26const 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
32export 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

The refactor phase is not optional. Without refactoring, TDD produces code that passes tests but has poor internal quality. The test suite gives you confidence to restructure code aggressively — take advantage of it. Neglecting refactoring leads to "test-induced damage" where the code is warped to fit the tests.
Writing the Test First (Red)

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.

test-first.test.ts
TypeScript
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
9import { parseCSV } from "./csv-parser";
10
11describe("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

Always verify that your test fails before making it pass. A test that passes without the implementation code may be giving a false positive. This is especially common with tests that use .not.toThrow() — the test may not be calling the function at all.
Making It Pass (Green)

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.

making-it-pass.ts
TypeScript
1// Minimal implementation to pass the first test
2// csv-parser.ts
3export 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:
27export 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
51function 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 "simplest thing that works" might be a hard-coded return value for the first test. That is okay! The next test will force you to generalize. This incremental approach prevents over-engineering and ensures every line of code is justified by a test.
Refactoring Safely

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.

refactoring-safely.ts
TypeScript
1// Before refactor — works but has quality issues
2function 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
19interface OrderItem {
20 price: number;
21 quantity: number;
22}
23
24const TAX_RATE = 0.08;
25
26const DISCOUNT_MAP: Record<string, (total: number) => number> = {
27 SAVE10: (total) => total * 0.9,
28 SAVE20: (total) => total * 0.8,
29};
30
31function calculateSubtotal(items: OrderItem[]): number {
32 return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
33}
34
35function applyCoupon(total: number, coupon: string): number {
36 const discountFn = DISCOUNT_MAP[coupon];
37 return discountFn ? discountFn(total) : total;
38}
39
40function applyTax(total: number): number {
41 return total + total * TAX_RATE;
42}
43
44export 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

Use the refactor phase to extract pure functions. Pure functions (no side effects, same output for same input) are easier to test and compose. If you find testing a function difficult, it likely needs refactoring — not more complex test setup.
TDD for Pure Functions

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.

tdd-pure-functions.test.ts
TypeScript
1// TDD for a pure function: formatRelativeTime
2
3// 1. RED — Write tests first
4import { formatRelativeTime } from "./time";
5
6describe("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
53export 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

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.

tdd-react.test.tsx
TypeScript
1// TDD for a SearchableDropdown component
2// 1. RED — write behavior tests
3import { render, screen } from "@testing-library/react";
4import userEvent from "@testing-library/user-event";
5import SearchableDropdown from "./SearchableDropdown";
6
7const options = ["Apple", "Banana", "Cherry", "Date", "Elderberry"];
8
9describe("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

Avoid testing implementation details like component state, internal methods, or DOM structure in React TDD. Test what the user sees and does — rendered text, clickable elements, and accessible roles. This makes your tests resistant to refactoring.
TDD for API Integration

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.

tdd-api.test.ts
TypeScript
1// TDD for an API client
2import { http, HttpResponse } from "msw";
3import { setupServer } from "msw/node";
4import { createUser } from "./api-client";
5
6const server = setupServer();
7
8beforeAll(() => server.listen());
9afterEach(() => server.resetHandlers());
10afterAll(() => server.close());
11
12describe("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});
When TDD Works Best

TDD is not universally applicable. Understanding where it adds the most value helps you apply it effectively.

SituationTDD EffectivenessReason
Pure business logicExcellentNo dependencies, predictable outputs
API clientsGoodContract-driven, mockable network
React componentsModerateRequires Testing Library discipline
UI animationsPoorTime-dependent, hard to assert
Database migrationsPoorSide-effect heavy, stateful
Third-party integrationModerateMock-dependent, integration tests help
Refactoring legacy codeExcellentCharacterization tests lock behavior

info

If you are new to TDD, start with pure functions and utility modules. The tight feedback loop (write test, see fail, write code, see pass) is most satisfying with pure logic. Graduate to component TDD once you are comfortable with the cycle.
TDD vs BDD

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?"

AspectTDDBDD
FocusUnit-level correctnessBehavioral specification
LanguageCode (describe/it/expect)Natural language (Given/When/Then)
AudienceDevelopersDevelopers + stakeholders
ToolingVitest, JestCucumber, Jest, Vitest (with extensions)
ScopeSingle unitFeature or scenario
Writing orderTest firstScenario first
tdd-vs-bdd.test.ts
TypeScript
1// TDD style — developer-focused
2describe("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
11describe("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

You don't have to choose between TDD and BDD. Use TDD for unit-level code (pure functions, utilities) and BDD-style scenarios for feature-level tests. The combination gives you confidence at both the unit and integration levels.
Common Pitfalls
  • 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

TDD is a discipline that requires practice. Do not expect to be proficient after a week. It typically takes 2-3 months of consistent practice before TDD feels natural. The payoff — fewer bugs, better design, and faster debugging — is worth the initial friction.
Best Practices
  • 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

The best metric for TDD success is not "100% coverage" — it is "tests serve as documentation." If a new developer can understand what your code does by reading the tests, you are doing it right.
$Blueprint — Engineering Documentation·Section ID: TEST-TDD·Revision: 1.0