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 mutatedThree 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
| Level | Covers | Speed | Typically breaks when | How many |
|---|---|---|---|---|
| Unit | one function or module, dependencies faked | a millisecond | you change that function | hundreds |
| Integration | several real pieces together, real DB or store | tens to hundreds of ms | the seam between two pieces changes | dozens |
| End to end | the whole app through the UI | seconds | anything at all, including the network | a 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
- Red: write the failing test first It forces you to name the behaviour and decide the signature before you have any code to defend.
- Green: the dullest code that passes Resist generalising. Getting to green fast tells you the test itself works.
- Red again: the next case One new expectation at a time. Each one is a decision you are recording.
- 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.mapworks. 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:test | Vitest | Jest | |
|---|---|---|---|
| Install | nothing, it ships with Node | one dev dependency | one dev dependency |
| ESM | native | native | workable, historically painful |
| Watch mode | --watch, basic | fast and incremental | yes |
| Assertions | node:assert/strict | expect (Jest compatible) | expect |
| Mocks | mock in node:test | vi.fn, vi.mock, fake timers | jest.fn, jest.mock |
| Browser DOM | no | yes, jsdom or real browser mode | yes, jsdom |
| Good fit for | libraries, scripts, no build step | anything with a bundler | existing 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/catcharound 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
===ortoBeand 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 3orworks, 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:testneeds no install, Vitest is the comfortable choice with a bundler. Both are the runner you just wrote, with conveniences.