Section 5 · Testing Strategy
Test-Driven Development as a Tool
20 min3 min readCode playground
Lesson at a glance
Use the red-green-refactor cycle to build well-specified logic and to fix bugs, and recognise the situations where writing tests first slows you down.
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Test-driven development has a reputation problem. Some people treat it as a religion; others dismiss it after one painful kata. I treat it as a tool, and like any tool it’s excellent for some jobs and wrong for others. For the core money logic in Splitwise-lite, it’s the best tool I know.
The cycle
TDD is a short loop, usually a minute or two per turn:
- Red: write one small test for behaviour that doesn’t exist yet. Run it. Watch it fail, ideally with the failure you expected.
- Green: write the simplest code that makes it pass. Simple, not clever; hard-coding is allowed if it’s honest.
- Refactor: clean up the code (and the tests) while everything stays green.
Why insist on seeing red? Because a test you’ve never seen fail might not be able to fail. It might call the wrong function, assert on a variable that’s always truthy, or never actually check anything (a missing await, a .skip left in place). Red first is cheap proof that the test is wired up.
Building settleUp test-first
Splitwise-lite needs settleUp(balances): given who is owed and who owes (in cents), return a short list of transfers that brings everyone to zero. Here is how the first few turns go, with Vitest in watch mode so every save reruns the tests.
Turn 1, the simplest case. Nobody owes anything:
test('needs no transfers when everyone is settled', () => {
expect(settleUp({ Ana: 0, Ben: 0 })).toEqual([]);
});Red: settleUp is not defined. Green: export function settleUp() { return []; }. Yes, that’s the whole implementation for now. It’s honest about what the tests demand.
Turn 2, one debt. The next test forces real behaviour:
test('the debtor pays the creditor', () => {
expect(settleUp({ Ana: 500, Ben: -500 })).toEqual([
{ from: 'Ben', to: 'Ana', amountCents: 500 },
]);
});Green might pair the one negative balance with the one positive balance. Still small.
Turn 3, several people. Now a hard-coded pairing breaks, and the general algorithm earns its place: sort debtors and creditors by amount, repeatedly move the smaller of the two current amounts, advance whichever side reaches zero. For assertions, use properties where several answers are valid: applying the transfers brings every balance to zero, every amount is positive, and there are at most people - 1 transfers.
Turn 4, invalid input. Balances that don’t add up to zero mean a bug upstream; settleUp should refuse rather than invent money: expect(() => settleUp({ Ana: 500, Ben: -400 })).toThrow().
Then refactor: name the sort comparator, extract a helper, make it read well. The tests let you change the shape of the code without fear, which is the real payoff.
Fixing bugs test-first
The most valuable everyday use of TDD has nothing to do with new features. When a bug is reported:
- Write a test that reproduces it, and watch it fail. Now you know you understand the bug.
- Fix the code until the test passes.
- Keep the test. That bug can never come back silently.
This works even in codebases that were never written test-first, and it turns every bug report into a permanent improvement of the suite.
When not to
TDD assumes you know what “correct” means. When you don’t, writing tests first just writes down guesses:
- Exploratory UI work: trying layouts, interactions and copy. Build it, look at it, show it to someone; test what survives.
- Spikes: a throwaway experiment to learn whether an approach works. Delete it, then build the real thing (test-first if you like).
- Glue code with no logic: wiring a library’s callback to your state. Cover it with an integration or end-to-end test instead.
In the exercise you play the green half of the cycle: the tests for settleUp are written (they’re the checks), and you write the code that makes them pass. In the next two lessons, you’ll measure how good those tests are.
Key takeaways
- Red, green, refactor: write one failing test, make it pass with the simplest code, then clean up while the tests stay green.
- Seeing a test fail first proves it can fail; a test written after the code may pass for the wrong reason.
- Choose the next test so it forces one small step of new behaviour, starting from the simplest case.
- TDD shines for logic with clear rules and for bug fixes (reproduce with a failing test first); it fits poorly with exploratory UI work and throwaway spikes.