Unit Testing
You change how an order total is calculated. The function now has to be right for a dozen shapes of order: one line, twenty lines, a line the customer emptied, an order with nothing in it at all. Checking that by hand means building each of those orders in the app and reading the total off the screen, which takes fifteen minutes and your full attention. You will do it today. You will not do it on Thursday, when the change is one character long and obviously safe.
A unit test is that check, written down as a program: run one piece of code with known inputs, compare what came back against what should have come back, and say so. Think of a pump on a bench. You do not need the building’s plumbing connected to find out whether it moves water — you feed it a known input and watch what comes out, one component, in isolation, in seconds.
Arrange, act, assert
Nearly every test worth reading has the same three beats. Arrange the inputs. Act: call the thing. Assert that the result is what you claimed. Keeping those beats visible, in that order, is most of what makes a test readable at 2am by somebody who did not write it.
Three properties separate a test that helps from a test that is merely present.
It pins one behavior. A test that asserts six things fails at the first one and hides the other five, and its name cannot describe what broke. One behavior per test means the name of the failing test is often the whole diagnosis.
It is deterministic. Same input, same answer, every run, on every machine, in any order. That rules out the system clock, the network, randomness, and state left behind by the test that ran before it. A test that fails once a week teaches the team to press run again, and after a month of that nobody reads the red at all. The odds compound across a suite: twenty tests that each fail one run in a hundred, for reasons that have nothing to do with the code, turn the whole suite red on nearly one run in five, because 0.99 to the twentieth power is 0.82.
It fails informatively. “Expected 3000, received 1800” tells you what the code believes — 1800 is what those two lines come to when quantity is ignored — while “assertion failed” only sends you to the debugger. Half the value of a test arrives on the day it fails, so the failure message is not a detail.
The word “unit” is looser than it sounds. It is not “one class” or “one file” — it is a piece of behavior you can exercise on its own and reason about without holding the rest of the system in your head. Pure functions make the easiest units, which is why code that separates calculation from I/O tends to be code that is pleasant to test.
The scene below is one such unit — orderTotal, which turns order lines into a
number — with three of its behaviors pinned one assertion at a time. The third
one is red.
Predict first
The scene runs three behaviors of orderTotal against a reduce with no seed: two lines, then an emptied line, then an order with no lines. The empty order fails. The reduce gets its seed, and all three behaviors run again. One badge counts passing assertions and one counts failing ones, adding one each time an assertion lands. What do the two badges read when the lap ends?
Show the answer
The answer is C. The reasoning for each option is now shown under it.
one assertion being run · badges tally passes and failures
Three behaviors, one red assertion, one one-argument fix — use Next step to go one at a time.
Red, green, and the empty order
The scene runs the three behaviors that orderTotal needs to have, and the
third one is the interesting one. Two lines add up. An emptied line multiplies
out to nothing. An order with no lines at all throws a TypeError instead of
returning zero, because reduce with no initial value has nothing to return
from an empty array.
That failure is doing three jobs at once. It reports a real bug — an empty cart is a state a live system reaches constantly. It names the fix precisely, since the message says exactly what is missing. And it is a bug that no amount of reading the function would reliably have caught, because the code looks correct and is correct for every input anybody had thought about.
The badges tally assertions as they land, so they only climb: after the fix the fail badge still reads 1, because it is a record of what happened during the run rather than a count of what is broken now. That is also how a test suite reads in real life — the red you fixed at 10am is still in this morning’s history.
The whole story fits in one file:
import { expect, test } from 'vitest';
interface LineItem {
unitPriceCents: number;
quantity: number;
}
function orderTotal(items: readonly LineItem[]): number {
return items
.map((item) => item.unitPriceCents * item.quantity)
.reduce((sum, cents) => sum + cents, 0);
}
const twoLines: LineItem[] = [
{ unitPriceCents: 1200, quantity: 2 },
{ unitPriceCents: 600, quantity: 1 },
];
test('sums unit price x quantity across lines', () => {
expect(orderTotal(twoLines)).toBe(3000);
});
test('counts a line the customer emptied as zero', () => {
expect(orderTotal([{ unitPriceCents: 1200, quantity: 0 }])).toBe(0);
});
test('totals an order with no lines as zero', () => {
expect(orderTotal([])).toBe(0);
});
That is the green version — the 0 at the end of the reduce is the fix from
the scene. Delete that one argument and the first two tests stay green while the
third fails with TypeError: Reduce of empty array with no initial value, which
is the red step in the scene, one edit away from being reproduced.
Read the three test names in order and you have a description of what
orderTotal does, written in a language that cannot go stale: a comment can
describe behavior the function stopped having, and nothing complains, while a
test that describes behavior the function stopped having turns red on the next
run. That is also the safety net that makes refactoring possible. Rewriting the
body of a function is only relaxing when something independent is prepared to
tell you the answers did not change.
Try it
Pin the negative quantity
A bug in the returns flow let a line with quantity: -1 reach orderTotal,
and it quietly returned -1200. Decide that a negative quantity is an error:
write a test that expects orderTotal to throw a RangeError for it, run it
against the code above before changing anything, and then make it pass.
Hint
Give expect a function that calls orderTotal, not the result of calling it:
expect(() => orderTotal(…)).toThrow(RangeError). Called directly,
orderTotal would throw before expect ever ran, and the test would fail on
the very error it was written to catch. For the fix, check every line before
doing any arithmetic.
Solution
function orderTotal(items: readonly LineItem[]): number {
for (const item of items) {
if (item.quantity < 0) {
throw new RangeError(`quantity must be 0 or more, got ${item.quantity}`);
}
}
return items
.map((item) => item.unitPriceCents * item.quantity)
.reduce((sum, cents) => sum + cents, 0);
}
test('rejects a line with a negative quantity', () => {
expect(() =>
orderTotal([{ unitPriceCents: 1200, quantity: -1 }]),
).toThrow(RangeError);
});Run against the old orderTotal, the new test is the only red one of the four,
and it fails with AssertionError: expected function to throw an error, but it didn't.
That red run is worth seeing: a test you have only ever watched pass might not
be able to fail at all. After the change, all four are green.
The error carries the bad value in its message, so whoever meets it in a log learns what was wrong without opening a debugger:
try {
orderTotal([{ unitPriceCents: 1200, quantity: -1 }]);
} catch (error) {
console.log(String(error)); // RangeError: quantity must be 0 or more, got -1
}What green does not prove
A suite of passing unit tests says every unit works alone. It says nothing at
all about whether they work together, and that gap is where a large share of
production incidents live: a config value that points at the wrong host, a
service that returns snake_case where the caller expects camelCase, two
correct modules holding incompatible assumptions about who validates the input.
Every unit can be green while the checkout page is broken, which is why unit
tests are the bottom layer rather than the whole story — integration tests
exercise the seams, and they are the natural next thing to read.
There is a way to make unit tests actively expensive, too. Tests written against a function’s internals — asserting that a private helper was called twice, or that a value passed through a particular intermediate shape — fail every time somebody improves the implementation, without any behavior having changed. The team learns that red means “the tests need updating” rather than “the code is wrong”, and that is the end of the suite’s usefulness. Assert on what the unit promises its callers, and leave how it gets there alone.
Coverage numbers deserve the same suspicion. A hundred percent line coverage
means every line ran during the suite, not that every line ran with the input
that breaks it. orderTotal had full line coverage from the first test in the
scene, and the empty order still threw.
Check yourself
Pick an answer to lock it in. You then get the reasoning behind every option, including the ones you didn't pick. Any you miss come back on the review page a day later.
A test for isExpired(token) passes all day on every laptop, and fails in CI only on runs that start a few minutes after midnight UTC. What is the most likely cause?
Show the answer
The answer is B. The reasoning for each option is now shown under it.
orderTotal has 100% line coverage. A bug report shows it returning NaN for an order from an old client that left unitPriceCents off one line. How did the suite miss it?
Show the answer
The answer is D. The reasoning for each option is now shown under it.
You rewrite the body of orderTotal from a for loop into map and reduce. Every total it returns is identical, and four tests turn red. What were those tests most likely asserting?
Show the answer
The answer is A. The reasoning for each option is now shown under it.
A rewrite of orderTotal fails the first test with expected 3000, received 600. The input is twoLines: 1200 × 2 on one line, 600 × 1 on the other. Which bug produces exactly 600?
Show the answer
The answer is C. The reasoning for each option is now shown under it.
Key takeaways
A unit test pins one behavior of one unit with known inputs, and its name should be enough to tell you what broke when it turns red.
Determinism is what makes a suite trustworthy: a test that fails intermittently trains the team to ignore failures.
Tests are executable documentation and the safety net that makes refactoring possible — but only when they assert on behavior rather than on internals.
Green units do not mean a working system; the seams between units need their own tests, and coverage measures lines executed, not behavior verified.