A test suite full of unit tests isn't automatically a good test suite. A good unit test has five properties, usually remembered by the acronym FIRST (Fast, Isolated, Repeatable, Self-checking, Timely) — worth walking through each on its own.
The single most important design decision in a unit test: what exactly is it allowed to know about? A test that only calls a function's public API and checks its observable output survives a refactor of that function's internals untouched. A test that reaches into private variables, checks how many times an internal helper got called, or asserts on the literal sequence of internal steps breaks the moment someone reorganizes the code — even when the actual behavior, from the outside, hasn't changed at all.
// Implementation-coupled — breaks if you rename internalHelper or change the algorithm,
// even though the function's actual output is completely unaffected
test("calculateTotal uses internalHelper twice (bad)", () => {
const spy = jest.spyOn(module, "internalHelper");
calculateTotal(cart);
expect(spy).toHaveBeenCalledTimes(2);
});
// Behavior-focused — only cares about the one thing that actually matters:
// given this input, is the output correct? Survives any internal rewrite.
test("calculateTotal sums item prices including tax (good)", () => {
const cart = [{ price: 10 }, { price: 20 }];
expect(calculateTotal(cart, { taxRate: 0.1 })).toBe(33);
});
A good rule of thumb: if you can imagine rewriting a function's internals — a different loop, a different helper, a different algorithm — while it still does the exact same job from the outside, and your test would break anyway, that test is coupled to implementation, not behavior. That's the single biggest reason well-covered codebases still feel painful to refactor: not too few tests, but tests watching the wrong thing.
Nearly every well-written unit test follows the same three-part shape, usually called Arrange-Act-Assert:
test("applyDiscount reduces price by the given percentage", () => {
// Arrange — set up the inputs and any state the test needs
const price = 200;
const discountPercent = 25;
// Act — call the one thing actually being tested
const result = applyDiscount(price, discountPercent);
// Assert — check the outcome
expect(result).toBe(150);
});
Keeping these three sections visually separate — even just with a blank line or a comment — makes a test readable at a glance: what goes in, what happens, what should come out. A test that interleaves setup, calls, and assertions throughout is much harder to scan, and it's a sign the test might be trying to check too many things at once. One test, one behavior, one clear reason to fail — if a test's name doesn't describe a single behavior in plain English, it's usually doing too much.