EduFlow
Testing JavaScript Apps
0%

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.

  1. 1Behaviour versus implementation
  2. 2What implementation-coupled tests look like
  3. 3Testing the contract instead
  4. 4How specific should assertions be?
  5. 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:

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

Tests send inputs to splitEvenly and check outputs; the internal helpers inside the dashed box are not touched by tests. Tests inputs, outputs splitEvenly(total, people) internals: floor, %, Array.from free to change
Tests talk to the public contract, not the internals

What implementation-coupled tests look like

These are real patterns I have deleted from codebases:

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

  1. One share per person.
  2. The shares add up to the total. Not a cent lost, not a cent invented.
  3. Shares differ by at most one cent.
  4. The extra cents go to the first people (documented, so callers may depend on it).
  5. 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:

src/split.test.js
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.

Further reading

Check your understanding

3 questions

Question 1 of 3

You rewrite splitEvenly from Array.from to a plain for loop. Its outputs are identical for every input. Two tests go red. What does that tell you about those tests?

Question 2 of 3

Which single assertion would catch the most different bugs in splitEvenly(totalCents, people)?

Question 3 of 3

A teammate exports _remainderFor(total, people) from split.js so it can be unit tested directly. What’s the better approach?

Practice

Code playground

Behaviour tests for splitEvenly

The subject’s comment lists splitEvenly’s contract. Write tests for that contract (not for how the function is written) until your suite passes on the real code and catches four hidden bugs:

  • one drops the leftover cents,
  • one hands out one leftover cent too many,
  • one gives the leftover cents to the last people instead of the first,
  • one returns an empty array for zero people instead of throwing.

A sum helper is already in the starter.

// Splits a bill in integer cents between people.
// Contract:
//   - one share per person
//   - the shares add up exactly to totalCents
//   - shares differ by at most one cent
//   - leftover cents go to the FIRST people in the list
//   - fewer than one person throws a RangeError
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));
}
Loading the editor…

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

Results

(no output)

Checks

  • All your tests pass against the real splitEvenly
  • Catches the version that drops leftover cents
  • Catches the version that hands out one cent too many
  • Catches the version that gives leftover cents to the last people
  • Catches the version that accepts zero people