12 · Testing & Delivery

Unit Testing

7 min read · 5 questions · 1 exercise

A unit test runs one piece of code with known inputs and checks the answer, fast enough that you can afford to run all of them on every change.

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?

  1. That is the state of the suite after the fix: three behaviors, all green. The badges are a tally of what happened during the run, not a status board, so the red from the third run is still counted.

  2. Six is the number of runs, but one of them failed before the fix existed. Fixing the code changes what the next run does; it does not turn a red run that already happened green.

  3. Two passes and one failure before the fix, then three passes after it: 2 + 3 = 5 green and 1 red, over 6 runs. The two behaviors that were already green run again because a fix has to be shown not to break them.

Show the answer

The answer is C. The reasoning for each option is now shown under it.

Three behaviors, one red

one assertion being run · badges tally passes and failures

runner
orderTotal
pass
fail

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?

  1. Slowness makes a test time out, not change its answer, and it would not line up this neatly with one time of day. The failures following the clock is the clue.

  2. A test that depends on the current time gets a different input on every run, and some of those inputs cross a boundary like midnight. Pass the time in, isExpired(token, now), and the test can fix the moment and get the same answer forever.

  3. Leftover state makes a test fail depending on which tests ran before it, not on the time of day. Order-dependent failures follow the run order; these follow the clock.

  4. Several assertions in one test make a failure harder to read, but they do not make it intermittent. Something is changing between runs, and the time of day is what changes.

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?

  1. Every line did run: the function is short, and the first test alone executes all of it. The coverage number was accurate; it just measures something other than the inputs you tried.

  2. toBe compares with Object.is, which treats NaN as equal to itself, so expect(result).toBe(NaN) works. Nothing compared against NaN because no test produced it.

  3. The tests on this page assert on the totals orderTotal returns, which is behavior. The gap is in which orders they try, not in what they look at.

  4. Coverage counts the lines that ran, not the inputs they ran with. undefined * 2 is NaN, and NaN plus anything is NaN, so one missing price spoils the whole total, and only a test with that input would have shown 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?

  1. Only tests tied to the implementation, such as one that spied on a helper or counted loop iterations, can fail while every output stays the same. Tests on what orderTotal returns would have stayed green, and that is what makes a refactor safe.

  2. Every total is identical, so a test that checks totals sees exactly what it saw before. Tests on returned values stay green through a rewrite, which is the property that makes them worth having.

  3. A crowded test hides which behavior broke, but it still only fails when a behavior changes. Here none did, so the red came from something other than behavior.

  4. If the old totals were wrong and the new ones are identical, the tests would have been red before the rewrite too. Red that appears only after a refactor that changed no output points at the tests, not the totals.

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?

  1. Then it would add the bare prices, 1200 + 600, and receive 1800. That is the failure the page uses as its example of an informative message.

  2. Stopping after the first line gives 1200 × 2 = 2400. The received number points somewhere else.

  3. With total = where total += belongs, each line replaces the one before, and the last line is 600 × 1 = 600. Each wrong guess predicts a different number, which is why the received value in a failure message is half the diagnosis.

  4. That multiplies both prices by 2: 2400 + 1200 = 3600. Close in spirit, but the number does not match, and the number is the evidence.

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.