TDD Rubric — Three Checks
Answer each one. If any answer is wrong, fix it before moving on.
1. Stub or Mock?
- Stub = incoming dep (data flowing in). Never assert on it.
- Mock = outgoing dep / exit point. Assert on it. Max one per test.
Wrong choice here means you're either asserting on the wrong thing or missing the real exit point entirely.
~2–5% of tests need mocks. If you're reaching for a mock, ask first: can I test this via return value or state change instead?
Anti-patterns to catch now:
- Asserting on a stub — it's not an exit point
- Verifying internal calls between things you own — only assert on calls to deps you don't control
- Over-specifying — exact object schemas, exact argument order unless order is a stated requirement; these break on refactoring even when behavior is correct
- Hardcoded dep (e.g.
new Date()inline,moment()inline) — inject it or the test is flaky by design
Jest:
jest.mock("./module-name") // hoisted before imports
mockFn.mockReturnValue(value) // stub indirect input
expect(mock).toHaveBeenCalledWith() // assert exit point
afterEach(jest.resetAllMocks) // prevent state leaking between tests
2. Is this async? Split it.
Don't write async tests against logic you can test synchronously.
Split into:
- Async shell — one integration test for the fetch/IO
- Sync logic core — many unit tests for the processing; extract to a plain function, test it directly
If you can't avoid async:
- Timers →
jest.useFakeTimers()+jest.advanceTimersToNextTimer(), notglobal.setTimeoutmonkey-patching - Callbacks → extract success/error logic to named public fns, call via
done() - Await → move logic into plain fns that
return/throw; the test stays synchronous
3. Should this test change?
Before touching an existing test, identify why it's failing:
| Cause | Action |
|---|---|
| Production bug | Don't touch the test — it's doing its job |
| Buggy test | Fix it — inject an obvious bug, confirm red, fix prod, confirm green |
| Requirement gone | Delete it |
| API/semantic change in prod | Update it |
| Flaky (moving parts: time, network, randomness) | Quarantine → stub the dep → convert to unit test → kill if cost > value |
Logic in tests (if, for, try-catch, string concatenation) creates lying tests — a bug in the algorithm makes the expected value wrong in exactly the same way. Always hardcode expected values.