EduFlow
Testing JavaScript Apps
0%

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.

  1. 1The cycle
  2. 2Building settleUp test-first
  3. 3Fixing bugs test-first
  4. 4When not to

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:

  1. Red: write one small test for behaviour that doesn’t exist yet. Run it. Watch it fail, ideally with the failure you expected.
  2. Green: write the simplest code that makes it pass. Simple, not clever; hard-coding is allowed if it’s honest.
  3. Refactor: clean up the code (and the tests) while everything stays green.
A cycle of three boxes: red, write a failing test; green, make it pass; refactor, clean up with tests green; then back to red. Red a failing test Green simplest passing code Refactor clean up, stay green
Red, green, refactor

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:

src/settle-up.test.js
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:

JavaScript
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:

  1. Write a test that reproduces it, and watch it fail. Now you know you understand the bug.
  2. Fix the code until the test passes.
  3. 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.

Further reading

Check your understanding

3 questions

Question 1 of 3

You write a test for a new case and it passes immediately, before you change any code. What should you do?

Question 2 of 3

A user reports that settling up a group with one person owing $0.01 crashes. What’s the TDD way to fix it?

Question 3 of 3

Which task is the weakest fit for strict test-first development?

Practice

Code playground

Make the settleUp tests pass

This is the green step of TDD: the tests are already written (they’re the checks below), and your job is to make them pass.

settleUp(balances) takes balances in cents ({ Ana: 1500, Ben: -1000, Cai: -500 }: positive means the person is owed money, negative means they owe) and returns transfers such as [{ from: 'Ben', to: 'Ana', amountCents: 1000 }, { from: 'Cai', to: 'Ana', amountCents: 500 }].

The tests require that: settled groups need no transfers; one debtor pays one creditor; applying the transfers brings everyone to zero with positive whole-cent amounts; there are at most people − 1 transfers (counting people with a non-zero balance); settled people never appear; balances that don’t add up to zero throw; and the input object is not modified.

Work through the checks in order, running after each change. The starter passes the first one.

// Make the tests (the checks) pass, one at a time.
// balances: { name: cents }. Positive = is owed money, negative = owes.
// Return transfers: [{ from, to, amountCents }].
function settleUp(balances) {
  return [];
}

console.log(settleUp({ Ana: 500, Ben: -500 }));
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • Settled groups (and an empty one) need no transfers
  • With two people, the debtor pays the creditor the full amount
  • Applying the transfers brings everyone to zero, with positive whole-cent amounts
  • Uses at most people − 1 transfers
  • People with a zero balance never appear in a transfer
  • Throws when the balances don’t add up to zero
  • Does not modify the balances object it was given