CodeOath
← All posts
Testing36 min total · 12 parts

Testing Fundamentals: Unit, Integration, and E2E Tests Done Right

Part 3 of 12 · ~3 min

What Makes a Good Unit Test

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.

  • Fast. A unit test should run in milliseconds. If your unit suite takes ten minutes, something in it isn't actually a unit test — it's probably hitting a real database or a real network call somewhere.
  • Isolated (independent). A test shouldn't depend on another test having run first, on the order tests execute in, or on any shared mutable state. Run it alone, run it in a random order, run it a hundred times — same result every time.
  • Repeatable. The same test, run in the same codebase, gives the same result every single time — not "usually passes," not "flaky on CI but fine locally." Anything that depends on the current time, network latency, random numbers, or execution order without controlling for it breaks repeatability.
  • Self-checking. The test itself decides pass or fail via an assertion — no human has to read console output and judge whether it "looks right." A test that just logs a value for a person to eyeball isn't a test, it's a print statement with extra steps.
  • Timely. Written close to the time the code was written — ideally before or right alongside it, while the intent is still fresh in your head, not weeks later as an afterthought sprint.

Test behavior, not implementation

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.

The AAA structure

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.