Frontend engineer path · Shipping with confidence · Topic 16 of 17

Good unit tests

Not started

A good unit test checks one behaviour of a small piece of code, quickly and the same way every time. Write it as arrange (set up the inputs), act (call the code once) and assert (check the result). Give it a name that says what the code should do, so that when it fails, the name alone tells you what broke.

Why it matters: Unit tests are the fastest feedback you have. When each one checks a single behaviour, a red test tells you what broke without anyone having to debug it.

  1. 1One behaviour per test

    Each test checks one thing the code promises, and its name says what that is: "applies a percentage discount", not "test2".

  2. 2Arrange, act, assert

    Set up the inputs, do the one action, then check the result. Three short blocks, in that order.

  3. 3Test the behaviour, not the wiring

    Go through the public interface and check what comes out, so you can refactor the inside without rewriting the tests.

  4. 4Fast and deterministic

    No real clock, randomness, network or disk. The same code gives the same result every time, in milliseconds.

1

How it works

The explanation, with examples you can run.

Arrange, act, assert

You don't need a framework to see the pattern. Here is a ten-line test runner and four tests for a cart total, with prices in cents to avoid floating-point surprises. The last test fails because it found a real bug. Fix cartTotal and run it again.

⌘/Ctrl + Enter
OutputNot run yet

Press Run to see what your code prints, in order.

Runs in this browser, in an isolated frame with no access to this page. console.log, info, warn and error are shown; code runs as a classic script, so use an async function for await.

Real runners such as node:test, Vitest and Jest work the same way. They add parallel runs, watch mode, readable diffs and coverage on top.

Behaviour, not implementation

A test that checks how the code works (which private helper it called, in what order, with what internal state) breaks whenever someone restructures the code, even when nothing a caller can see has changed. People soon learn that a red test there means "update the test", and then a real failure gets the same treatment. Test through the public interface and check what comes out: a return value, a saved record, a message sent.

Keep them deterministic

Anything that changes between runs belongs outside the unit: the clock, random numbers, generated ids, environment variables, the network. Pass them in as parameters or small dependencies so the test can fix them. Then a failure always means the code changed, never that the time of day did. The next topic covers the stand-ins you pass in.

Small habits that pay off

  • Write the test for a bug before fixing it, and watch it fail first. A test that has never failed might not be able to.
  • Keep the arrange step short. If setup takes twenty lines, the code under test probably does too much.
  • Test edge cases on purpose: empty, one, many, the boundary itself, and just past it.
  • Avoid logic in tests (loops, ifs). A test should be simple enough that it obviously can't be wrong.
2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.One test checks the total, the tax, the currency and the item count. The total is wrong. What do you lose?
  2. 2.Which test survives a refactor that changes nothing a caller can see?
  3. 3.What is wrong with this test?
    test("orders placed today are not overdue", () => {  const order = { placedAt: new Date() }  expect(isOverdue(order, new Date())).toBe(false)})

0 of 3 right so far. Get all three to finish this topic.