Testing & Quality
Testing & Quality

What a test is for

Lesson · ~20 min
After this unit you can
  • Explain what a test is for — confidence to change code — and judge a test by whether it fails when behaviour breaks, and only then
  • Rewrite an assertion on an implementation detail (private state, which setter ran) as an assertion on observable behaviour
  • Prioritise what to test — happy path, high-damage branches, boundaries, past bugs — and name what not to spend tests on
  • Compare the test pyramid and the testing trophy as answers to the same cost-versus-confidence trade-off
  • Spot a test that can't fail — one that checks its own mock, recomputes its expected value, or only adds coverage

Read

Simple
1 / 2

A test is a tripwire stretched across behaviour

Flag

A test suite exists so you can change code without fear. You refactor, upgrade a library or fix a bug, run the tests, and either they stay green (you didn't break anything you promised) or one goes red and tells you exactly which promise you broke.

That gives every test one job: fail when the behaviour breaks, and only then.

// shipping.ts — orders of $50.00 or more ship free (amounts in cents)
export function shippingCost(subtotalCents: number): number {
  return subtotalCents >= 5000 ? 0 : 499;
}
// shipping.test.ts
import { expect, it } from "vitest";
import { shippingCost } from "./shipping";

it("ships free from exactly $50", () => {
  expect(shippingCost(5000)).toBe(0);
});

it("charges $4.99 just below $50", () => {
  expect(shippingCost(4999)).toBe(499);
});

Now try to break it. Change >= to > and the first test goes red — the tripwire caught a real change in what customers pay. Rename the parameter, rewrite the function as an if, move it to another file: nothing goes red, because nothing a caller can observe changed.

A test can let you down in two opposite ways:

  • It misses a real break. It stays green while customers get charged. (A test that only checked shippingCost(10000) would pass under both >= and >.)
  • It breaks on a harmless change. It goes red on a refactor that changed nothing a caller sees. Do that often enough and the team learns to "just update the test" — and stops believing red.

Both failures cost the same thing: trust. Most of this track is about writing tests that avoid them.

Medium
1 / 2

Choosing what to test in a real feature

Flag

The feature: a discount code at checkout. applyDiscount(cart, code, now) looks the code up, checks it hasn't expired and that the cart meets its minimum, and returns the new total. You can think of thirty tests. You have time for eight. Spend them in this order.

1. The happy path. A valid code on a qualifying cart lowers the total by the right amount. If this breaks, every customer with a code notices — it's the test you'd miss most.

2. Detail where a mistake does damage. Money, permissions and data loss earn more tests than copy and colour. Here that's the arithmetic:

  • a percentage code rounds to whole cents the way finance expects
  • a fixed-amount code never takes the total below zero
  • two codes don't stack unless the rules say so

3. The boundaries. Bugs cluster where < meets <=. If the minimum is $30, test $30.00 and $29.99 — not $10 and $100, which pass whichever comparison you wrote. Same for expiry: a code that expires at midnight, checked one millisecond either side.

4. Every bug that already escaped. When a bug reaches production, write the test that reproduces it first, watch it fail, then fix the code. It's cheap insurance against the one bug you know is plausible.

5. The paths in between — unknown codes, empty carts — if time remains.

What not to spend tests on

  • Other people's code. That React re-renders on setState, that JSON.parse throws on bad input, that your validation library rejects "not-an-email". Test your use of them (that your schema marks the field as required), not their guarantees.
  • Things you don't control. Playwright's best-practice guide says it plainly: "Only test what you control" — not the payment provider's hosted page or a link to someone else's site. Replace them with a double at the boundary.
  • Private helpers on their own. If roundToCents is only reachable through applyDiscount, the rounding tests above already cover it — and a later inline-and-delete refactor won't break anything.
  • What the type checker already proves. In strict TypeScript, a test that applyDiscount "rejects a number passed as the code" re-checks the compiler. (Data arriving from the network is different: it's untyped at runtime, so validate it at the boundary and test that.)
Complex

Tests that can't fail, and the habit that catches them

Flag

A green test is only evidence if it could have been red. These four pass no matter what the code does — each one is common in real suites.

It checks the double you configured.

fetchUser.mockResolvedValue({ id: 1, name: "Ada" });
expect(await fetchUser(1)).toEqual({ id: 1, name: "Ada" }); // tests the mock, not your code

The code under test never ran. The assertion has to be about your function's output when given that fake user — await displayName(1) returning "ADA", say.

It recomputes the expected value with the same logic.

const items = [{ priceCents: 450, qty: 2 }];
expect(total(items)).toBe(items.reduce((s, i) => s + i.priceCents * i.qty, 0));

If total has the formula wrong, the test has the same formula wrong. Write the expected value as a literal you worked out by hand: toBe(900).

It runs code without checking it.

it("covers priceLabel", () => {
  priceLabel(0);
  priceLabel(1999);
});

Run under Vitest 4 with coverage on, this test reports 100% statements, branches and functions for priceLabel — and it asserts nothing. Coverage records which lines ran, not whether anyone looked at the result. web.dev's coverage guide: "100% code coverage doesn't guarantee the absence of bugs or flaws in your code." Use coverage to find code no test touches; don't use it to prove a test is good.

It's a snapshot nobody reads. The test fails, someone presses u to update the snapshot, the new (wrong) output becomes the expected output. The vitest-in-practice unit covers when snapshots earn their place.

The habit: watch it fail

Before you trust a new test, make it go red once:

  • For a bug fix: write the test first and run it against the unfixed code. It must fail — for the reason the bug report describes. Then fix, and watch it pass.
  • For a new test of working code: break the code on purpose (flip >= to >, return early, delete the line that saves) and run it. If it stays green, it isn't testing that line.

Quiz

What is a test suite actually for?

Flag

You replace a cart's private array with a Map. Every caller-visible result is unchanged, but one test goes red: it asserted on cart["lines"]. What does that red tell you?

Flag

A payment form shows "Card declined" when the API rejects the card. Which assertion tests behaviour rather than implementation?

Flag

Checkout has a discount code feature and you have time for a handful of extra tests beyond the happy path. Where does boundary-by-boundary detail pay most?

Flag

Which of these is not worth a test of your own?

Flag

What do the test pyramid and the testing trophy actually disagree about?

Flag

A test does fetchUser.mockResolvedValue(ada) and then asserts expect(await fetchUser(1)).toEqual(ada). What has it verified?

Flag

Coverage for pricing.ts is 100%. What does that tell you?

Flag

Fill in the blank

The rule is "orders of $50.00 or more ship free" (amounts in cents). Pick the two inputs that would catch a > written where >= was meant, from both sides.

Flag
it("ships free at exactly the threshold", () => {
  expect(shippingCost()).toBe(0);
});
it("charges just below it", () => {
  expect(shippingCost()).toBe(499);
});

Rewrite the implementation-detail test to go through the cart's public API: add(item), count(sku), total().

Flag
cart.add({ sku: "tea", priceCents: 450 });
cart.add({ sku: "tea", priceCents: 450 });
// was: expect(cart["lines"][0].qty).toBe(2);
expect(cart.("tea")).toBe(2);
expect(cart.()).toBe(900);

Bug #212: a line with quantity 0 crashes checkout. Before you touch the fix, what must this new test do against today's code?

Flag
it("ignores a line with quantity 0 (bug #212)", () => {
  // Run this BEFORE the fix. It must , or it isn't testing the bug.
  expect(() => checkoutTotal([{ sku: "tea", priceCents: 450, qty: 0 }])).not.toThrow();
});

Further reading