Testing Fundamentals

Mental model: A test is three lines: set up a situation, run one thing, compare the result to the answer you wrote down first.

Level: intermediate · about 18 minutes

You already test. Every time you save, reload and click through the form to see whether the total is right, you are running a test by hand. The only differences with an automated test are that the computer does the clicking, and the expected answer is written down instead of held in your head.

function cartTotal(items) {
  return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}

function check(actual, expected, label) {
  if (actual !== expected) throw new Error(label + ': expected ' + expected + ', received ' + actual);
  console.log('ok   ' + label);
}

check(cartTotal([]), 0, 'an empty cart totals 0');
check(cartTotal([{ price: 2, qty: 3 }]), 6, 'one line multiplies price by qty');
check(cartTotal([{ price: 2, qty: 3 }, { price: 1.5, qty: 2 }]), 9, 'two lines add up');

A test suite with no framework. This is the whole idea.

An assertion is a comparison that throws when it fails. A test is a named function containing assertions. A test runner is a loop that calls those functions, catches the throws, and counts. That is the entire model, and everything else a framework gives you is convenience on top of it.

Arrange, act, assert

function applyCoupon(cart, coupon) {
  if (coupon.minTotal && cart.total < coupon.minTotal) return cart;
  return { ...cart, total: cart.total - coupon.amount };
}

// arrange: the situation, and nothing else
const cart = { total: 100 };
const coupon = { amount: 10, minTotal: 50 };

// act: exactly one call
const result = applyCoupon(cart, coupon);

// assert: compare against the answer you decided in advance
console.log(result.total === 90);   // -> true
console.log(cart.total === 100);    // -> true, the input was not mutated

Three visual sections. If a test does not fit this shape, it is testing more than one thing.

Build the runner

function createRunner() {
  const cases = [];

  const test = (name, fn) => { cases.push({ name, fn }); };

  const run = () => {
    const results = [];
    for (const testCase of cases) {
      try {
        testCase.fn();
        results.push({ name: testCase.name, ok: true });
      } catch (err) {
        results.push({ name: testCase.name, ok: false, message: err.message });
      }
    }
    const failed = results.filter((r) => !r.ok).length;
    return { total: results.length, passed: results.length - failed, failed, results };
  };

  return { test, run };
}

const { test, run } = createRunner();

test('adds', () => { if (1 + 1 !== 2) throw new Error('maths'); });
test('fails on purpose', () => { throw new Error('expected 3, received 4'); });

const summary = run();
console.log(summary.passed + ' passed, ' + summary.failed + ' failed');
for (const r of summary.results) console.log((r.ok ? 'ok   ' : 'FAIL ') + r.name + (r.ok ? '' : ' -> ' + r.message));

About thirty lines. Registration, isolation, reporting.

The try/catch inside the loop is the only interesting line. It is what makes tests isolated: one failing test does not stop the rest, so a single run tells you everything that is broken. Collecting the cases first and running them after is what lets a framework support filtering, ordering and reporting.

const assert = (cond, msg) => { if (!cond) throw new Error(msg ?? 'expected truthy'); };

const assertEqual = (actual, expected, msg) => {
  if (!deepEqual(actual, expected)) {
    throw new Error((msg ?? 'not equal') + ': expected ' + JSON.stringify(expected) + ', received ' + JSON.stringify(actual));
  }
};

const assertThrows = (fn, ErrorType) => {
  try { fn(); } catch (err) {
    if (ErrorType && !(err instanceof ErrorType)) throw new Error('wrong error type: ' + err.name);
    return;
  }
  throw new Error('expected the function to throw');
};

function deepEqual(a, b) { return JSON.stringify(a) === JSON.stringify(b); } // good enough for a demo, not for real

assert(true);
assertEqual([1, { a: 2 }], [1, { a: 2 }]);
assertThrows(() => { throw new RangeError('x'); }, RangeError);
console.log('all three helpers behaved');

The assertion helpers this course gives you, written out. Nothing magic.

console.log({ a: 1 } === { a: 1 });
console.log([1, 2] === [1, 2]);

Objects and arrays compare by identity, so two structurally identical values are never ===. This single fact is why every test framework ships a deep equality assertion (assertEqual here, toEqual in Vitest, assert.deepStrictEqual in Node) alongside the identity one.

Unit, integration, end to end

LevelCoversSpeedTypically breaks whenHow many
Unitone function or module, dependencies fakeda millisecondyou change that functionhundreds
Integrationseveral real pieces together, real DB or storetens to hundreds of msthe seam between two pieces changesdozens
End to endthe whole app through the UIsecondsanything at all, including the networka handful

Unit tests localise failures: when one goes red you know the file. Integration tests catch the bugs that live in the gaps, which is where most real bugs live, because each unit worked exactly as its author intended. You want both, weighted toward the fast end, and only enough end to end tests to cover the two or three journeys that must never break.

TDD in miniature

  1. Red: write the failing test first It forces you to name the behaviour and decide the signature before you have any code to defend.
  2. Green: the dullest code that passes Resist generalising. Getting to green fast tells you the test itself works.
  3. Red again: the next case One new expectation at a time. Each one is a decision you are recording.
  4. Refactor: with a net Now change the implementation freely. The tests tell you within a second whether you broke a case you had already solved.

Naming

Names that tell you nothing

test('cartTotal', ...)
test('test 2', ...)
test('works', ...)
test('bug fix', ...)
// A failure here means
// opening the file to find
// out what broke.

Names that are the bug report

test('an empty cart totals 0', ...)
test('multiplies price by qty', ...)
test('ignores out of stock lines', ...)
test('rejects a negative qty', ...)
// The failing line in CI
// already tells you the
// behaviour that regressed.

Write the name as a sentence about behaviour, in the present tense, without the word "test". Read your failure output as if it were a bug report, because on the day it fails, that is exactly what it is.

What not to test

  • Private helpers. Test them through the public function that uses them, or they will pin your refactors in place.
  • Third party libraries. Array.prototype.map works. Test your use of it, not it.
  • Which methods you called. assert(spy.callCount === 1) on an internal call is a test of the implementation, and it goes red on every rewrite.
  • Exact log text and error messages, unless the wording genuinely is the contract.
  • Getters that only return a field. There is no behaviour to break.
  • Everything, blindly, for a coverage percentage. Coverage tells you what ran, not what was checked.
  • Generated snapshots that nobody reads. An approved snapshot nobody inspected is a test that asserts "unchanged".

A tour of the real runners

import test from 'node:test';
import assert from 'node:assert/strict';
import { cartTotal } from './cart.js';

test('an empty cart totals 0', () => {
  assert.equal(cartTotal([]), 0);
});

test('cart maths', async (t) => {
  await t.test('multiplies price by qty', () => {
    assert.equal(cartTotal([{ price: 2, qty: 3 }]), 6);
  });
  await t.test('deep compares the receipt', () => {
    assert.deepStrictEqual({ lines: 1, total: 6 }, { lines: 1, total: 6 });
  });
});

node:test, built into Node 18 and up. Zero dependencies. Run with node --test.

import { describe, it, expect, vi } from 'vitest';
import { cartTotal, notify } from './cart.js';

describe('cartTotal', () => {
  it('totals an empty cart as 0', () => {
    expect(cartTotal([])).toBe(0);
  });

  it('returns the same shape it was given', () => {
    expect({ total: 6, currency: 'GBP' }).toEqual({ total: 6, currency: 'GBP' });
  });

  it('notifies once per checkout', () => {
    const send = vi.fn();
    notify({ total: 6 }, send);
    expect(send).toHaveBeenCalledTimes(1);
  });
});

Vitest. Install it, then npx vitest. Watch mode is instant and it understands ESM natively.

node:testVitestJest
Installnothing, it ships with Nodeone dev dependencyone dev dependency
ESMnativenativeworkable, historically painful
Watch mode--watch, basicfast and incrementalyes
Assertionsnode:assert/strictexpect (Jest compatible)expect
Mocksmock in node:testvi.fn, vi.mock, fake timersjest.fn, jest.mock
Browser DOMnoyes, jsdom or real browser modeyes, jsdom
Good fit forlibraries, scripts, no build stepanything with a bundlerexisting large codebases
a good test
one behaviour, one act, a name that reads like a bug report
fast
no clock, no network, no randomness. Inject them
isolated
order does not matter, and one failure does not hide the others
deterministic
same result every run, on every machine
structure
deep equality for objects, identity only for primitives and references
after a bug
write the failing test first, then fix it. That is the test that pays
coverage
a hint about untested paths, never a target to hit

Try it yourself

Your own runner, with failures

function createRunner() {
  const cases = [];
  return {
    test: (name, fn) => cases.push({ name, fn }),
    run() {
      const results = cases.map((c) => {
        try {
          c.fn();
          return { name: c.name, ok: true };
        } catch (err) {
          return { name: c.name, ok: false, message: err.message };
        }
      });
      const failed = results.filter((r) => !r.ok).length;
      return { total: results.length, passed: results.length - failed, failed, results };
    },
  };
}

const slugify = (text) => text.toLowerCase().split(' ').join('-');
const { test, run } = createRunner();

test('lowercases', () => { if (slugify('Ada') !== 'ada') throw new Error('got ' + slugify('Ada')); });
test('hyphenates spaces', () => { if (slugify('a b') !== 'a-b') throw new Error('got ' + slugify('a b')); });
test('drops punctuation', () => { if (slugify('a, b!') !== 'a-b') throw new Error('got ' + slugify('a, b!')); });

const summary = run();
for (const r of summary.results) console.log((r.ok ? 'ok   ' : 'FAIL ') + r.name + (r.ok ? '' : ' -> ' + r.message));
console.log(summary.passed + '/' + summary.total + ' passing');

Add async support by awaiting testCase.fn() inside the loop. Then add a only flag that skips every other test.

Make time testable

// untestable: the answer changes as you read this
function greetingNow() {
  const hour = new Date().getHours();
  return hour < 12 ? 'good morning' : 'good afternoon';
}

// testable: the clock is an argument with a sensible default
function greeting(now = new Date()) {
  return now.getHours() < 12 ? 'good morning' : 'good afternoon';
}

console.log(greeting(new Date('2024-01-01T09:00:00')));  // -> good morning
console.log(greeting(new Date('2024-01-01T15:00:00')));  // -> good afternoon
console.log(greetingNow());                              // -> depends when you ran it

The second version can be tested. Write two assertions for it, one either side of midnight, without waiting.

Exercises

Write the test runner

Write createRunner() returning { test, run }. test(name, fn) registers a case. run() calls every registered function in registration order, catching throws, and returns { total, passed, failed, results }. A passing case is exactly { name, ok: true }. A failing case is exactly { name, ok: false, message } where message is the thrown error message. One failure must not stop the others.

Deep equality, the assertion behind them all

Write deepEqual(a, b) returning true when two values are structurally equal. Primitives compare with ===, except that NaN equals NaN. Arrays are equal when they have the same length and equal items in order. Plain objects are equal when they have the same own keys and equal values. An array is never equal to an object. Recurse for nested values.

Check yourself

What does this print?
false — Objects compare by reference, so two separately created objects are never === however identical they look. This is exactly why test frameworks ship deep equality (toEqual, assert.deepStrictEqual) next to identity comparison.
What is the one line that makes a test runner useful?
The try/catch around each test, so a failure isolates instead of stopping the run — Without it, the first throw ends the whole run and you learn about one failure per run. With it, a single run tells you everything that is broken. Everything else the tool does is convenience layered on that.
A function reads new Date() internally to decide a greeting. What is the smallest change that makes it testable?
Accept the current time as a parameter with a default, like greeting(now = new Date()) — Dependency injection through a defaulted parameter costs one line, keeps every existing caller working, and lets a test pass any instant it likes. Patching globals works but leaks between tests and hides the dependency.
Which of these is the weakest test to write?
A test asserting an internal helper was called exactly once — That test asserts how the function is built, not what it does. Any refactor that keeps the behaviour identical turns it red, which teaches the team that red means "it changed" rather than "it broke". Assert on outcomes.

Common mistakes

  • Comparing objects with === or toBe and being baffled that two identical looking values differ.
  • Several acts in one test, so a failure tells you the group is broken but not which case.
  • Test names like test 3 or works, which turn a CI failure into an archaeology exercise.
  • Depending on the real clock, real randomness or the network, then reruns until green.
  • Testing private helpers directly, which freezes the implementation in place.
  • Chasing a coverage number: coverage measures what ran, not what was checked.
  • Writing the test after the fix, so you never see it fail and never learn whether it would have caught the bug.

Takeaways

  • An assertion throws on failure, a test is a named function of assertions, a runner is a loop with a try/catch.
  • Arrange, act, assert. One act per test, and a name that reads like a bug report.
  • Objects need deep equality, so know which of your framework's two comparisons you are using.
  • Unit tests localise failures, integration tests catch the bugs in the gaps. Weight toward the fast end.
  • Test-first means you design the interface as a user of it, and you see the test fail before it passes.
  • Inject the clock, randomness and the network, or your suite will be flaky and nobody will trust it.
  • Assert on behaviour, never on which internal calls happened.
  • node:test needs no install, Vitest is the comfortable choice with a bundler. Both are the runner you just wrote, with conveniences.