Section 2 · Unit Tests That Matter
Test Behaviour, Not Implementation
18 min3 min readCode playground
Lesson at a glance
Write tests that pin down a function’s contract (inputs, outputs and invariants) so they survive refactoring and fail only when behaviour really changes.
- 1Behaviour versus implementation
- 2What implementation-coupled tests look like
- 3Testing the contract instead
- 4How specific should assertions be?
- 5When a refactor does break a test
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
A test suite has two jobs: fail when behaviour breaks, and stay green when it doesn’t. Most teams focus on the first and forget the second. Then someone renames a private function, forty tests go red, nobody’s behaviour changed, and the lesson the team learns is “tests slow us down”.
Behaviour versus implementation
Here is Splitwise-lite’s splitEvenly, which divides a bill in cents between people. Leftover cents go to the first people in the list, so the result is fair (shares differ by at most one cent) and deterministic:
function splitEvenly(totalCents, people) {
if (!Number.isInteger(people) || people < 1) {
throw new RangeError('people must be a positive integer');
}
const base = Math.floor(totalCents / people);
const remainder = totalCents % people;
return Array.from({ length: people }, (_, i) => (i < remainder ? base + 1 : base));
}
console.log(splitEvenly(1000, 3)); // $10.00 three ways
console.log(splitEvenly(1001, 4));
console.log(splitEvenly(2, 3));Its behaviour is everything a caller can observe: the array it returns, the error it throws for zero people. Its implementation is how: Math.floor, the % operator, Array.from, the variable names. You could rewrite the body with a loop, or by handing out cents one at a time, and no caller would notice.
A test that checks behaviour survives all of those rewrites. A test that checks implementation breaks on every one of them, even though nothing a user cares about changed. That is a false alarm, and false alarms are expensive: each one costs a debugging session, and enough of them teach people to rerun or delete red tests without reading them.
What implementation-coupled tests look like
These are real patterns I have deleted from codebases:
// Spying on Math.floor: breaks if you switch to integer division another way
const floor = vi.spyOn(Math, 'floor');
splitEvenly(1000, 3);
expect(floor).toHaveBeenCalledWith(1000 / 3);
// Testing an exported private helper: breaks when you inline or rename it
expect(_remainderFor(1000, 3)).toBe(1);
// Snapshotting the source-shaped output of a debug helper nobody calls
expect(describeSplit(1000, 3)).toMatchSnapshot();Each one passes today and tells you nothing a behaviour test wouldn’t. Each one will fail the day someone improves the code.
Testing the contract instead
Start from what a caller relies on. For splitEvenly that is:
- One share per person.
- The shares add up to the total. Not a cent lost, not a cent invented.
- Shares differ by at most one cent.
- The extra cents go to the first people (documented, so callers may depend on it).
- Zero people is an error.
Points 2 and 3 are invariants: they hold for every valid input, so you can check them over several inputs at once:
import { describe, test, expect } from 'vitest';
import { splitEvenly } from './split.js';
const sum = (xs) => xs.reduce((a, b) => a + b, 0);
describe('splitEvenly', () => {
test('never loses or invents a cent', () => {
for (const [total, people] of [[1000, 3], [1001, 4], [2, 3], [0, 5]]) {
expect(sum(splitEvenly(total, people))).toBe(total);
}
});
test('gives the leftover cents to the first people', () => {
expect(splitEvenly(1001, 4)).toEqual([251, 250, 250, 250]);
});
test('refuses to split between zero people', () => {
expect(() => splitEvenly(1000, 0)).toThrow(RangeError);
});
});The sum invariant is the most valuable line in the file. A version that drops the remainder fails it. So does an off-by-one that hands out one cent too many. It doesn’t care how the shares are computed, only that money is conserved.
How specific should assertions be?
Behaviour tests can still be exact. toEqual([251, 250, 250, 250]) pins the documented order of extra cents, and that is behaviour because callers rely on it. The question is never “exact or loose?” but “would a user or caller notice if this changed?” If yes, assert it precisely. If no, don’t assert it at all.
When a refactor does break a test
Sometimes a change to the code should change a test, and it helps to know which situation you are in. If the public contract changed (the function now returns objects instead of numbers, or the extra cents now go to the payer), the test was right to fail, and you update it on purpose, ideally before changing the code. If the contract is the same and a test fails anyway, the test was coupled to the implementation: rewrite it against the contract and delete the old one. A healthy suite makes that question easy to answer, because every test name states a promise the code makes.
In the exercise, write behaviour tests for splitEvenly that catch four realistic bugs. The next lesson is about finding the inputs that expose bugs like these.
Key takeaways
- Behaviour is what callers can observe (return values, thrown errors, visible side effects); implementation is how the code gets there.
- A test that breaks when you refactor without changing behaviour is a false alarm, and false alarms teach a team to ignore red builds.
- Invariants such as “the shares add up to the total” catch whole families of bugs with one assertion.
- Don’t export private helpers only to test them; test them through the public function that uses them.