Test-Driven Development (TDD)
Philosophy
TDD is a development methodology where you write tests BEFORE implementation. This ensures:
-
Clear requirements - Tests define expected behavior upfront
-
Better design - Forces you to think about interfaces first
-
Confidence - Every feature has test coverage from day one
-
Refactoring safety - Tests catch regressions immediately
When to Use TDD
Situation Use TDD? Reason
New utility function YES Pure functions are perfect for TDD
New Effect service YES Define interface via tests first
Complex business logic YES Tests clarify requirements
Bug fix YES Write failing test that reproduces bug first
UI component styling No Visual changes don't benefit from TDD
Exploratory prototyping No Requirements unclear, iterate first
TRPC endpoint (simple CRUD) Optional Ask user preference
Red-Green-Refactor Cycle
The TDD workflow follows three phases:
- RED - Write Failing Test First
import { describe, expect, it } from "vitest"; import { calculateDiscount } from "../calculate-discount";
describe("calculateDiscount", () => { it("applies 10% discount for orders over 100", () => { // This test will FAIL - function doesn't exist yet expect(calculateDiscount(150)).toBe(135); }); });
Run test to see it fail:
bun run vitest run packages/common/src/tests/calculate-discount.test.ts
- GREEN - Minimal Implementation
Write the minimum code to make the test pass:
// packages/common/src/calculate-discount.ts export function calculateDiscount(amount: number): number { if (amount > 100) { return amount * 0.9; } return amount; }
Run test to see it pass:
bun run vitest run packages/common/src/tests/calculate-discount.test.ts
- REFACTOR - Improve Without Breaking
Improve code quality while keeping tests green:
const DISCOUNT_THRESHOLD = 100; const DISCOUNT_RATE = 0.1;
export function calculateDiscount(amount: number): number { if (amount <= DISCOUNT_THRESHOLD) { return amount; } return amount * (1 - DISCOUNT_RATE); }
Run tests again to verify refactoring didn't break anything.
See references/red-green-refactor.md for detailed workflow examples.
Test Hierarchy (Prefer Simpler)
-
Unit tests (preferred) - Pure functions, Effect services with mock layers
-
TRPC Integration (ask first) - Full TRPC stack with PGlite
-
E2E (ask + justify) - Browser automation, slowest
Situation Test Type Action
Pure function, parser, util Unit Write immediately
Effect service with dependencies Unit with mock layers Write immediately
TRPC procedure (DB logic) TRPC Integration Ask user first
User-facing flow, UI behavior E2E Ask + warn about maintenance
Effect TDD Patterns
Test-First Service Design
-
Define interface via test - What should the service do?
-
Create mock layer - Isolate dependencies
-
Implement service - Make tests pass
-
Refactor - Improve with confidence
import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect";
describe("PricingService", () => { // 1. Define what the service should do via tests it.effect("calculates base price without discount", () => Effect.gen(function* () { const service = yield* PricingService; const result = yield* service.calculatePrice({ itemId: "item-1", quantity: 2, }); expect(result.total).toBe(200); }).pipe(Effect.provide(testLayer)), );
it.effect("applies bulk discount for quantity > 10", () => Effect.gen(function* () { const service = yield* PricingService; const result = yield* service.calculatePrice({ itemId: "item-1", quantity: 15, }); expect(result.total).toBe(1350); // 15% discount }).pipe(Effect.provide(testLayer)), ); });
Mock Layer Factory Pattern
// Create parameterized mock layers for different test scenarios const createMockInventoryLayer = (inventory: Map<string, number>) => Layer.succeed(InventoryService, { getStock: (itemId) => Effect.succeed(inventory.get(itemId) ?? 0), reserveStock: (itemId, qty) => Effect.succeed(void 0), });
// Use in tests const testLayer = PricingService.layer.pipe( Layer.provide(createMockInventoryLayer(new Map([["item-1", 100]]))), );
See references/effect-tdd-patterns.md for comprehensive Effect testing patterns.
Test File Locations
Code Location Test Location
packages/X/src/file.ts
packages/X/src/tests/file.test.ts
apps/web-app/src/infrastructure/trpc/routers/X.ts
apps/web-app/src/tests/X.test.ts
apps/web-app/src/routes/**
apps/web-app/e2e/feature.e2e.ts
Commands
Unit & Integration Tests
Run all tests (unit + TRPC integration)
bun run test
Watch mode - re-run on file changes
bun run test:watch
Run with coverage report
bun run test:coverage
Run specific test file (FROM PROJECT ROOT, full path required)
bun run vitest run packages/common/src/tests/pagination.test.ts bun run vitest run apps/web-app/src/tests/formatters.test.ts
Run tests matching pattern
bun run vitest run -t "calculateDiscount"
E2E Tests
Install Playwright browsers (first time only)
bun run test:e2e:install
Run all E2E tests
bun run test:e2e
Run E2E with interactive UI
bun run test:e2e:ui
WRONG Syntax (DO NOT USE)
These DO NOT work:
bun run test packages/common/src/tests/file.test.ts # script doesn't accept path cd packages/common && bun run vitest run src/tests/file.test.ts # wrong cwd
TDD Anti-Patterns
- Writing Implementation First
// ❌ BAD - Implementation before test
export function formatPrice(amount: number): string {
return $${amount.toFixed(2)};
}
// Then writing test after - defeats TDD purpose
- Skipping the RED Phase
// ❌ BAD - Test passes immediately (you didn't verify it can fail) it("returns true", () => { expect(true).toBe(true); // This always passes! });
- Too Many Tests at Once
// ❌ BAD - Writing all tests before any implementation describe("UserService", () => { it("creates user", () => { /* ... / }); it("updates user", () => { / ... / }); it("deletes user", () => { / ... / }); it("lists users", () => { / ... / }); it("validates email", () => { / ... */ }); // 10 more tests... }); // Now you have 15 failing tests - overwhelming!
Correct approach: One test at a time. RED → GREEN → REFACTOR → next test.
- Skipping Refactor Phase
// ❌ BAD - Test passes, move on without cleanup export function calc(a: number, b: number, c: string): number { if (c === "add") return a + b; if (c === "sub") return a - b; if (c === "mul") return a * b; return 0; }
// ✅ GOOD - Refactor to cleaner design type Operation = "add" | "subtract" | "multiply";
const operations: Record<Operation, (a: number, b: number) => number> = { add: (a, b) => a + b, subtract: (a, b) => a - b, multiply: (a, b) => a * b, };
export function calculate(a: number, b: number, op: Operation): number { return operations[op](a, b); }
Resources
references/
-
red-green-refactor.md
-
Detailed TDD cycle workflow with examples
-
effect-tdd-patterns.md
-
Effect service testing, mock layers, error cases
-
test-first-examples.md
-
Step-by-step TDD examples for this codebase
Related Skills
-
testing-patterns
-
Test syntax, TRPC integration tests, E2E patterns
-
effect-ts
-
Effect service design, layers, error handling