tdd
Test-Driven Development
TDD is a design practice as much as a testing practice. Tests written before implementation force the interface to be designed from the caller's perspective. Painful test setup signals a design problem — too many dependencies, responsibilities, or the wrong abstraction boundary. Let the tests tell you.
Schools
Two schools differ on what constitutes a "unit" and where to draw the mock boundary.
London school (default here): A unit is a single class or function. Double all collaborators at module boundaries. Preferred for new code — drives interface design and keeps feedback fast.
Chicago school: A unit is a cluster of related objects. Wire real collaborators; only double external infrastructure (databases, HTTP, clocks). Preferred when retrofitting tests onto existing code with stable internal structure.
Both are valid. The choice determines where doubles live, not whether you test.
Red-Green-Refactor
Red — write a failing test
Write a test that specifies one behavior that does not yet exist. Run it. Confirm it fails for the right reason: the behavior is absent, not a compilation error or misconfigured test harness. A test that fails for the wrong reason gives false confidence when it later passes.
test "order total includes 20% tax when region is EU":
order = Order(region: Region.EU, pricing: StubPricingService(basePrice: 100.00))
assert order.calculate() == 120.00
// Fails: Order.calculate() does not exist yet — correct red
Green — write the minimum passing implementation
Write the simplest generalisation that satisfies all current tests. "Minimum" means the simplest correct implementation — not a hardcoded return value, unless you are deliberately triangulating (see below). Do not add behavior not yet specified by a test.
Triangulation is a valid incremental technique: return a hardcode on the first test, write a second test that contradicts it, then generalise. Use it consciously, not reflexively.
// First test: order with one item at 100.00 in EU → 120.00
calculate() -> decimal:
return 120.00 // hardcode to pass first test only
// Second test: order with one item at 200.00 in EU → 240.00
// Hardcode no longer works — now generalise:
calculate() -> decimal:
return this.pricing.getPrice(this.item) * (1 + this.region.taxRate())
Checkpoint
Before refactoring: commit or checkpoint the moment all tests are green. If a refactor breaks tests, revert to the checkpoint rather than debugging a mixed change. This preserves the diagnostic value of the loop.
Refactor — improve structure, not behavior
In the refactor phase: no new behavior enters. Rename, extract, consolidate, simplify. If you discover a missing case during refactor, stop — write a test for it (red), pass it (green), then resume the refactor.
Watch for design signals during refactor:
- Arrange section requires more than five or six lines → component has too many responsibilities
- Hard to find a meaningful assertion → interface does not expose the right behavior
- Must mock many collaborators → dependency structure needs redesign
When the arrange section is painful, read references/dependency-injection.md. When choosing the right double is unclear, read references/test-doubles.md.
Test Structure
Use Arrange / Act / Assert. Keep the sections visually distinct.
test "payment is declined when card has insufficient funds":
// Arrange
gateway = StubPaymentGateway(response: DeclinedResult(code: "INSUFFICIENT_FUNDS"))
order = Order(gateway: gateway)
// Act / Assert
assertThrows(PaymentDeclined, code: "INSUFFICIENT_FUNDS"):
order.confirm()
One behavior per test. Multiple assertions are acceptable when they describe facets of the same outcome — not when they cover separate behaviors. If the first assertion failing would hide whether a second behavior is correct, split the test.
For naming, fixture setup, assertion quality, Object Mother, Test Data Builder, and test pyramid guidance: read references/test-patterns.md.
Dependency Injection
The subject under test must not instantiate its own collaborators that cross a boundary. If it does, doubles cannot be substituted.
Constructor injection — default for object-oriented code. Declare dependencies as constructor parameters.
// Untestable — creates its own dependency
class OrderService:
confirm(orderId: string) -> void:
SmtpClient.send(...) // hard-coded
// Testable — dependency is substitutable
class OrderService:
emailSender: EmailSender
constructor(emailSender: EmailSender):
this.emailSender = emailSender
confirm(orderId: string) -> void:
this.emailSender.send(...)
Argument injection — default for functional or stateless code. Pass the dependency as a function parameter.
function confirmOrder(
orderId: string,
sendEmail: (to: string, body: string) -> void
) -> void:
sendEmail(lookupEmail(orderId), buildBody(orderId))
Inject one level deep. The test wires the subject's direct dependencies only. Those dependencies' own dependencies are their internal concern.
For factory injection, DI containers, and when to prefer each pattern: read references/dependency-injection.md.
Mock Boundary Rule
Mock at the edge of what you own. A boundary is any dependency your code calls but does not define:
- External services: HTTP APIs, payment gateways, identity providers
- Infrastructure: databases, file systems, message queues
- Non-determinism: system clock, random number generation
Do not mock:
- Classes and functions within the same module as the code under test
- Value objects and domain entities
- Pure functions with no I/O
The test: if I refactor the internal structure without changing observable behavior, would this double break? If yes, it is coupled to an implementation detail, not a boundary.
For the full test doubles taxonomy (stub, spy, fake, mock, dummy) and a decision guide: read references/test-doubles.md.
Workflow Checklist
Copy and track progress through each cycle:
TDD cycle:
- [ ] Write one failing test — confirm it fails for the right reason (Red)
- [ ] Write minimum generalisation to pass all tests (Green)
- [ ] Checkpoint: commit or stage before touching structure
- [ ] Refactor structure only — no new behavior
- [ ] If a missing case is found during refactor: stop, write a test, then resume
- [ ] Repeat for the next behavior
Reference Files
Read these on demand — do not load all at once.
| File | Read when |
|---|---|
| references/test-doubles.md | Choosing between stub, spy, fake, mock, or dummy; deciding where to draw the mock boundary |
| references/dependency-injection.md | Arrange section is complex; too many collaborators; choosing constructor vs argument vs factory injection |
| references/test-patterns.md | Naming a test; structuring fixture data; writing assertions that diagnose failures; understanding the test pyramid |
| assets/test-template.md | Generating a test suite from scratch; need a concrete scaffold showing London vs Chicago school wiring, builder setup, and Arrange/Act/Assert structure |
More from felixnorden/skills
building-with-effect
Build TypeScript programs with the Effect library - type-safe error handling, dependency injection, concurrency, resource management, and composable abstractions. Use when working with Effect, Schema, or any @effect/* ecosystem package.
9planning-workflow
Guides structured software planning through reusable templates. Covers design concept authoring, implementation plan authoring with vertical slices, and agent configuration. Use when starting a new feature, designing a solution before implementation, producing a phased plan for a build agent, or when the user mentions planning, design phase, implementation plan, QRSPI, or vertical slices.
3solidity-development
Comprehensive Solidity smart contract development best practices covering security patterns, gas optimization, and professional documentation standards. Use when writing, reviewing, or auditing Solidity smart contracts, or when the user mentions EVM, Ethereum, blockchain development, smart contracts, Solidity, gas optimization, or security audits.
1