Unit Testing
Chapter Fifty-One
Syllabus topic Module 2, "Integration & System Testing: Unit testing".
Pages 350 to 356 of 499
In one line
A unit test calls one function with values it chooses and checks the answer, with no server, no database and no waiting, which is why the parts of a system worth getting exactly right, its rules, its limits and its cryptography, are the parts written as plain functions.
In the wording to use when asked: unit testing exercises the smallest independently testable parts of a system in isolation from their collaborators, using arranged inputs and asserted outputs; it is fast, deterministic and written by the developer alongside the code, and its feasibility is a direct consequence of the design, since pure functions and injected dependencies can be tested where code that reaches for a database or a clock cannot.
What a unit is here
A unit is the smallest thing worth testing on its own: one function, or one small module. The worked project has four files of them, and what is in each says something about the design:
| File | What it tests | Why it can be tested at all |
|---|---|---|
rules.test.js | the slots, the cut-offs, the totals, the moves, the clock | the rules are pure functions (Chapter 43) |
validate.test.js | every input rule | validation depends on nothing but its argument (Chapter 47) |
passwords.test.js | hashing and verifying | the password module takes a password and gives a string |
attempts.test.js | the limit on failed sign-ins | the limiter is given its clock (Chapter 46) |
Every one of those is a decision the system must get right, and none of them needs a database. That is not luck: it is what the layer rule bought (Chapter 28). Code that reaches for the database, the request or the clock can only be tested at the next level up.
Arrange, act, assert
Every test has the same three parts, and naming them is the easiest way to keep tests readable:
it('allows four failures and blocks at the fifth', () => {
const clock = fakeClock();
const limiter = attemptLimiter({ max: 5, windowMs: 15 * MINUTE,
clock });
for (let i = 0; i < 4; i += 1) limiter.fail('k');
assert.equal(limiter.blockedFor('k'), 0);
limiter.fail('k');
assert.equal(limiter.blockedFor('k'), 15 * 60);Arrange: a limiter with a clock of the test's own. Act: four failures, then a fifth. Assert: nothing blocked after four, blocked for 900 seconds after five. That is NFR-6, in eight lines, and it runs in less than a millisecond.
The name of a test is part of the test. Read the report of a good suite and it is a specification: "allows four failures and blocks at the fifth", "is closed at the cut-off itself", "salts: one password never gives the same hash twice".
Testing what depends on time
Two of these modules depend on time, and neither waits for it.
Unit Testing
The rules are given the moment, so a test simply passes the moment it wants:
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const rules = require('../../src/rules');
const IST = 'Asia/Kolkata';
const at = (hhmm) => new Date(`2026-09-29T${hhmm}:00+05:30`);
describe('localTime', () => {
it('reads the canteen clock, not the server clock', () => {
// 04:50 in London (UTC) is 10:20 in Mumbai.
const now = new Date('2026-09-29T04:50:00Z');
assert.deepEqual(rules.localTime(now, IST),
{ date: '2026-09-29', minutes: 620 });
});
it('gives the Mumbai date just after midnight there', () => {
// 18:40 on the 28th in UTC is already the 29th in Mumbai.
const now = new Date('2026-09-28T18:40:00Z');
assert.equal(rules.localTime(now, IST).date, '2026-09-29');
});
});
describe('stampAt', () => {
it('writes a moment as the canteen clock shows it', () => {
const now = new Date('2026-09-29T04:50:07Z');
assert.equal(rules.stampAt(now, IST), '2026-09-29 10:20:07');
});
});
describe('slotsAt', () => {
it('has every slot open at 10:30', () => {
const open = rules.slotsAt(at('10:30'), IST).map((s) => s.open);
assert.deepEqual(open, [true, true, true, true]);
});
it('closes each slot 15 minutes before it', () => {
assert.deepEqual(rules.slotsAt(at('12:20'), IST), [
{ time: '12:30', cutoff: '12:15', open: false },
{ time: '12:40', cutoff: '12:25', open: true },
{ time: '12:50', cutoff: '12:35', open: true },
{ time: '13:00', cutoff: '12:45', open: true },
]);
});
});
describe('isSlotOpen', () => {
it('is open one minute before the cut-off', () => {
assert.equal(rules.isSlotOpen('12:40', at('12:24'), IST), true);
});
it('is closed at the cut-off itself', () => {
assert.equal(rules.isSlotOpen('12:40', at('12:25'), IST), false);
});
it('is never open for a slot that does not exist', () => {
assert.equal(rules.isSlotOpen('12:35', at('09:00'), IST), false);
});
});
describe('orderTotal', () => {
it('adds quantity times price, in paise', () => {
const lines = [
{ quantity: 2, pricePaise: 7000 },
{ quantity: 1, pricePaise: 2000 },
];
assert.equal(rules.orderTotal(lines), 16000);
});
});
describe('canMove', () => {
it('lets the counter move an order forward', () => {
assert.equal(rules.canMove('placed', 'preparing', 'staff'), true);
assert.equal(rules.canMove('preparing', 'ready', 'owner'), true);
assert.equal(rules.canMove('ready', 'collected', 'staff'), true);
assert.equal(rules.canMove('ready', 'no_show', 'staff'), true);
});
it('lets only the student cancel, and only while placed', () => {
assert.equal(rules.canMove('placed', 'cancelled', 'student'), true);
assert.equal(rules.canMove('placed', 'cancelled', 'staff'), false);
assert.equal(rules.canMove('preparing', 'cancelled', 'student'),
false);
});
it('refuses a skipped step and any move backwards', () => {
assert.equal(rules.canMove('placed', 'ready', 'staff'), false);
assert.equal(rules.canMove('ready', 'preparing', 'staff'), false);
assert.equal(rules.canMove('collected', 'ready', 'owner'), false);
});
it('never lets a student move an order forward', () => {
assert.equal(rules.canMove('placed', 'preparing', 'student'),
false);
});
});
describe('returnsStock', () => {
it('returns stock on a cancellation only', () => {
assert.equal(rules.returnsStock('cancelled'), true);
assert.equal(rules.returnsStock('no_show'), false);
assert.equal(rules.returnsStock('collected'), false);
});
});at('12:24')builds a moment in the canteen's own time zone, so every test reads as the time the canteen sees.- The time zone is tested, not assumed: 04:50 in UTC is 10:20 in Mumbai, and 18:40 on the 28th in UTC is already the 29th there. Those two tests are what stop the whole system slipping a day for a server in another country (ADR-4).
- The boundary is tested from both sides: open at 12:24, closed at 12:25, which is the cut-off itself. Chapter 52 explains why the two values either side of a boundary are the ones that find faults.
Unit Testing
The limiter cannot be given a moment, because it remembers; so it is given a clock it can be moved, four lines at the top of its test file:
// A clock that only moves when the test moves it.
function fakeClock() {
let now = 1_000_000;
const clock = () => now;
clock.advance = (ms) => {
now += ms;
};
return clock;
}Fifteen minutes pass in the test by calling clock.advance(15 * MINUTE). A test that instead waited would take fifteen minutes, and nobody would run it.
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { attemptLimiter } = require('../../src/attempts');
// A clock that only moves when the test moves it.
function fakeClock() {
let now = 1_000_000;
const clock = () => now;
clock.advance = (ms) => {
now += ms;
};
return clock;
}
const MINUTE = 60 * 1000;
describe('attemptLimiter', () => {
it('allows four failures and blocks at the fifth', () => {
const clock = fakeClock();
const limiter = attemptLimiter({ max: 5, windowMs: 15 * MINUTE,
clock });
for (let i = 0; i < 4; i += 1) limiter.fail('k');
assert.equal(limiter.blockedFor('k'), 0);
limiter.fail('k');
assert.equal(limiter.blockedFor('k'), 15 * 60);
});
it('lets the key try again once the window has passed', () => {
const clock = fakeClock();
const limiter = attemptLimiter({ max: 5, windowMs: 15 * MINUTE,
clock });
for (let i = 0; i < 5; i += 1) limiter.fail('k');
clock.advance(15 * MINUTE);
assert.equal(limiter.blockedFor('k'), 0);
});
it('forgets the failures after a successful sign-in', () => {
const clock = fakeClock();
const limiter = attemptLimiter({ max: 5, windowMs: 15 * MINUTE,
clock });
for (let i = 0; i < 5; i += 1) limiter.fail('k');
limiter.reset('k');
assert.equal(limiter.blockedFor('k'), 0);
});
it('counts each key on its own', () => {
const clock = fakeClock();
const limiter = attemptLimiter({ max: 5, windowMs: 15 * MINUTE,
clock });
for (let i = 0; i < 5; i += 1) limiter.fail('a');
assert.equal(limiter.blockedFor('b'), 0);
});
});Testing the validation
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const validate = require('../../src/validate');
// Runs fn and returns the field details of the error it
// throws, so a test can say exactly which fields failed.
function fieldsOf(fn) {
try {
fn();
} catch (err) {
return err.details;
}
assert.fail('expected the input to be refused');
}
describe('validate.account', () => {
it('trims the name and lower-cases the email', () => {
const out = validate.account({
name: ' Priya Menon ', email: 'Priya@College.Example',
password: 'canteen-demo',
});
assert.equal(out.name, 'Priya Menon');
assert.equal(out.email, 'priya@college.example');
});
it('accepts a name written in Devanagari', () => {
const out = validate.account({
name: 'प्रिया मेनन', email: 'p@college.example',
password: 'canteen-demo',
});
assert.equal(out.name, 'प्रिया मेनन');
});
it('reports every bad field at once', () => {
assert.deepEqual(Object.keys(fieldsOf(() => validate.account({
name: 'P', email: 'not-an-email', password: 'short',
}))), ['name', 'email', 'password']);
});
it('refuses a body that is not an object at all', () => {
const fields = fieldsOf(() => validate.account('hello'));
assert.ok(fields.name && fields.email && fields.password);
});
it('refuses a password on the list of common ones', () => {
const fields = fieldsOf(() => validate.account({
name: 'Meera Joshi', email: 'm@college.example',
password: 'password1',
}));
assert.equal(fields.password, 'This password is too common. '
+ 'Choose one that is hard to guess.');
});
it('refuses a common password whatever its capitals', () => {
const fields = fieldsOf(() => validate.account({
name: 'Meera Joshi', email: 'm@college.example',
password: 'PassWord1',
}));
assert.ok(fields.password);
});
});
describe('validate.menuItem', () => {
it('needs every field for a new item', () => {
const fields = fieldsOf(() => validate.menuItem({}));
assert.deepEqual(Object.keys(fields),
['name', 'category', 'pricePaise', 'isVeg']);
});
it('takes any one field for a change', () => {
assert.deepEqual(
validate.menuItem({ pricePaise: 7500 }, { partial: true }),
{ pricePaise: 7500 });
});
it('refuses a change that changes nothing', () => {
const fields = fieldsOf(() =>
validate.menuItem({}, { partial: true }));
assert.ok(fields.body);
});
it('refuses a price in rupees with a decimal point', () => {
const fields = fieldsOf(() => validate.menuItem(
{ pricePaise: 75.5 }, { partial: true }));
assert.ok(fields.pricePaise);
});
});
describe('validate.idParam', () => {
it('reads a plain whole number', () => {
assert.equal(validate.idParam('17', 'Order'), 17);
});
for (const bad of ['0', '017', '-1', '1.5', 'abc', '4294967296']) {
it(`treats "${bad}" as not found`, () => {
assert.throws(() => validate.idParam(bad, 'Order'),
{ status: 404, message: 'Order not found.' });
});
}
});
describe('validate.dateQuery', () => {
it('uses the fallback when no date is given', () => {
assert.equal(validate.dateQuery(undefined, '2026-09-29'),
'2026-09-29');
});
for (const bad of ['2026-02-30', '2026-13-01', '29-09-2026']) {
it(`refuses ${bad}`, () => {
assert.throws(() => validate.dateQuery(bad), { status: 400 });
});
}
});Unit Testing
Three habits in it are worth copying:
- A helper for the awkward part.
fieldsOf(fn)runs something that should be refused and returns the error's field messages, so each test is one line of intent. It also fails when nothing is thrown, which is the case everyone forgets: a test that expects a refusal must fail if the input is accepted. - The good case and the bad case together. The name is trimmed and the email lower-cased; a name in Devanagari is accepted; three bad fields are reported at once.
- The messages are asserted, not just the failure. A student reads the message, so the message is part of the behaviour.
Unit Testing
Testing the cryptography
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { hashPassword, verifyPassword } = require('../../src/passwords');
describe('passwords', () => {
it('stores the settings, the salt and the key', async () => {
const stored = await hashPassword('lunch-at-12-40');
const parts = stored.split('$');
assert.equal(parts.length, 6);
assert.deepEqual(parts.slice(0, 4),
['scrypt', '16384', '8', '5']);
});
it('accepts the right password and refuses a wrong one',
async () => {
const stored = await hashPassword('lunch-at-12-40');
assert.equal(await verifyPassword('lunch-at-12-40', stored),
true);
assert.equal(await verifyPassword('lunch-at-12-50', stored),
false);
});
it('salts: one password never gives the same hash twice',
async () => {
const a = await hashPassword('same-password');
const b = await hashPassword('same-password');
assert.notEqual(a, b);
});
it('refuses a stored value that is not an scrypt hash',
async () => {
assert.equal(await verifyPassword('x', 'plain-text'), false);
});
});What a test can and cannot say here is worth being clear about. It can say that the stored form carries the settings, that the right password verifies and a wrong one does not, that two hashes of one password differ, which is the salt working, and that a value that is not an scrypt hash is refused rather than crashing. It cannot say that scrypt is a good algorithm: that comes from OWASP's cheat sheet, and the decision is recorded in ADR-1 (Chapter 36). A test proves that the code does what was decided; the decision itself is defended in the documents.
Running them
$ cd ~/canteen-preorder
$ npm run test:unit 2>&1 | tail -4
pass refuses 2026-13-01
pass refuses 29-09-2026
43 tests: 43 passed, 0 failedForty-three tests, and they need no database and no server, which is why a developer runs them after every few lines. npm test runs these and the other seventy as well (Chapter 50).
What a failure looks like
A test is only useful if its failure is readable. One is broken on purpose here, by changing the cut-off from fifteen minutes to ten in a copy of the rules:
$ cd ~/canteen-preorder
$ sed -i 's/^const CUTOFF_MINUTES = 15;/const CUTOFF_MINUTES = 10;/' \
> src/rules.js
$ node --test --test-reporter=./test/reporter.js \
> test/unit/rules.test.js 2>&1 | grep -E 'FAIL|tests:'
FAIL closes each slot 15 minutes before it
FAIL is closed at the cut-off itself
14 tests: 12 passed, 2 failed
$ node --test --test-reporter=./test/reporter.js \
> test/unit/rules.test.js 2>&1 | grep -A2 'closed at the cut-off'
FAIL is closed at the cut-off itself
Expected values to be strictly equal:
$ sed -i 's/^const CUTOFF_MINUTES = 10;/const CUTOFF_MINUTES = 15;/' \
> src/rules.js
$ node --test --test-reporter=./test/reporter.js \
> test/unit/rules.test.js 2>&1 | tail -1
14 tests: 14 passed, 0 failedUnit Testing
Two tests fail, and their names say what changed: the cut-offs of all four slots, and the boundary at the cut-off itself. The test that checks a slot is open a minute before its cut-off still passes, because a slot that closes ten minutes before is still open at 12:24: the tests that fail are exactly the ones whose behaviour moved, which is what makes a failure worth reading.
The last two commands put the file back and show the tests passing again. A student trying this must remember the second half; a test suite left broken is worse than none, because it trains everybody to ignore it.
How many tests, and which
Not one test per function: one test per decision. The rules have 43 unit tests between four files because the rules make that many decisions, each of which somebody could get wrong:
- every branch of a rule: open, closed, and the boundary between;
- every limit, from both sides: 5 and 6 of an item, 10 and 11 in an order;
- every kind of wrong input: the wrong type, missing, too long, not in the list;
- every case the system exists to handle: a date that crosses midnight in another time zone, a password that is on the common list, a key that is blocked while another is not.
And one test for anything a bug has ever done, added the day it is fixed, so that it cannot come back (Chapter 56).
Do this for your project
- Write your rules as pure functions, and your small modules so that they are given what they need.
- Write unit tests with the code, not after.
- Name each test as a sentence about behaviour, so the report reads as a specification.
- Test both sides of every boundary and every branch.
- Give anything that depends on time a clock the test can set, and never make a test wait.
- Assert the message a user would see, not only that something failed.
- Break a test on purpose once, to see that its failure tells you what is wrong.
Mistakes that cost marks
Tests that need a database to test a rule, which is a sign the rule is in the wrong layer.
assert.ok(result) and nothing more, which passes for a dozen wrong answers.
A test that expects a refusal and passes when the input is accepted, because nothing checks that anything was thrown.
Tests named test1, test2, whose report tells nobody anything.
Unit Testing
A test that sleeps, so the suite takes minutes.
Testing only the happy path, which is the one nobody gets wrong.
Quick revision
- A unit is one function or one small module, tested in isolation, with no database, no server and no waiting.
- Arrange, act, assert; the test's name is part of it.
- Time: pass the moment to a pure function; give a movable clock to anything that remembers.
- Test both sides of a boundary, every branch, every kind of wrong input, and every message.
- A test that expects a refusal must fail when nothing is refused.
- Tests say the code does what was decided; the decision is defended in the documents.
Questions you must be able to answer
1. What is a unit test, and what makes one possible? A test that calls one function or one small module on its own and checks the result, with no database, server or waiting. It is possible when the code is written so that it depends only on what it is given: pure functions, and modules handed their clock and their store.
2. How can a rule about a fifteen-minute cut-off be tested in a millisecond? By passing the moment in. The rule is a function of the time it is given, so a test can ask what is open at 12:24 and at 12:25 without waiting for either, and can test another time zone as easily.
3. How is the limit on failed sign-ins tested without waiting fifteen minutes? The limiter is given a clock. The test uses a clock of its own that only moves when the test moves it, so fifteen minutes pass with one call.
4. What can a unit test say about the password hashing, and what can it not? It can say that the stored form carries the algorithm and its settings, that the right password verifies and a wrong one does not, that the same password hashes differently each time, and that a stored value in the wrong form is refused. It cannot say that the algorithm is the right choice: that is a decision recorded and defended in the architecture document.
5. Why must a test that expects a refusal fail when nothing is refused? Because otherwise it passes when the system silently accepts something it should have rejected, which is exactly the fault it was written to catch. The helper that runs the call fails outright if no error was thrown.
6. Why break a test on purpose? To see that its failure says what is wrong. A suite whose failures are unreadable is a suite that will be ignored, and changing one rule should produce a small number of failures, each naming what it expected.
The rest of this subject
These notes are cut from the University's printed syllabus. Open the syllabus itself for the same subject.