Test Types Deep Dive · 1 of 8

Unit Testing — Small, Fast, and Lots of Them

A unit test exercises one piece of code in isolation. It calls a function, asserts the result, and tells you in milliseconds whether the behavior is right. They form the wide base of the test pyramid: thousands run in seconds, fail with pinpoint accuracy, and let you refactor without fear. Done well, they're the cheapest insurance in software.

Pyramid BaseFastIsolatedMocksTDD
← Back to Testing
Quick Facts

What Counts as a Unit Test

Basic Concepts

  • One thing at a time: a single function, a single class, a single behavior. If it touches a database, the network, or the clock, it's drifted into integration territory.
  • Fast: milliseconds per test. The whole suite should run in seconds, locally, every time you save.
  • Deterministic: same inputs, same result, every time. No flakes, no time-of-day dependence.
  • Independent: tests don't share state. Order doesn't matter. Parallel execution just works.
  • Readable as documentation: a good test name and body describe the behavior better than a comment ever will.
Anatomy

The Shape of a Good Unit Test

Arrange / Act / Assert

Three sections, in order. Set up inputs and dependencies; call the unit under test once; assert on the result. Mixing them — branching logic, multiple acts, scattered assertions — turns the test into a riddle when it fails.

test('discount of 10% on $100 returns $90', () => {
  // Arrange
  const cart = new Cart([{ price: 100 }]);

  // Act
  const total = cart.totalWithDiscount(0.10);

  // Assert
  expect(total).toBe(90);
});
Test Behavior, Not Implementation

Assert on what the code does, not how. Tests that verify "the method called X then called Y" break on every refactor; tests that verify "given input A, return B" survive any internal rewrite that preserves the contract.

One Logical Assertion Per Test

You can have multiple expect calls, but they should describe one behavior. A failure should make it obvious what broke. Tests that assert ten unrelated things fail uninformatively — and the cleanup after a failure mid-test is awful.

Name Tests as Behaviors

shouldRejectExpiredTokens() > testToken(). Patterns: "given X, when Y, then Z" / "X returns Y when Z" / Ruby's describe ... it. The name is what shows up in CI failure logs; make it tell you what's broken.

Doubles

Mocks, Stubs, and Fakes

The Vocabulary
  • Stub: returns canned answers. "When you ask for the user, return this user."
  • Mock: records calls and lets you assert on them. "Was email.send called once with these args?"
  • Fake: a working alternative implementation. An in-memory repository instead of Postgres.
  • Spy: wraps a real object; lets you observe calls without changing behavior.
  • Dummy: a placeholder argument that's never used.
Prefer Fakes to Mocks

Mock-heavy tests verify implementation details — they couple to call sequences and method names. Fakes (an in-memory repo, an in-memory clock, a fake email sender that stores sent messages) let tests assert on outcomes. They're more work to write once and pay back across hundreds of tests.

Don't Mock What You Don't Own

Mocking a third-party library directly couples your test to that library's exact shape. Mock at your own abstraction — the port / interface you wrote — and let the adapter to the third-party library be tested separately, perhaps with a real instance.

TDD

Test-Driven Development

Red, Green, Refactor

Write a failing test (red). Write the smallest code that passes (green). Improve the design without changing behavior (refactor). Loop in minutes, not days. Forces you to think about the API before the implementation, and gives you a regression test for every behavior by construction.

When TDD Works

Logic-heavy code with clear inputs and outputs — pricing rules, parsers, business calculations, algorithms. The cycle is short and the tests stay relevant.

When It Doesn't

Spike work where you don't yet know the design. UI exploration. Code that's mostly orchestration of slow external services. TDD isn't a cult — use it where it earns its keep, skip it where it doesn't.

Common Mistakes

What Goes Wrong

  • Tests that re-implement the function. If the test mirrors the production code, it'll mirror the bug too.
  • Asserting on private state. Tests that read internals lock the design in cement. Test the public contract.
  • Shared mutable fixtures. One test's setup leaking into another's run is the #1 cause of "flaky on CI, fine locally."
  • Time, randomness, network. Inject a clock, seed your RNG, mock the network. Real time and real entropy belong in integration tests, not units.
  • Coverage worship. 100% coverage with weak assertions tells you nothing. Coverage is a floor, not a ceiling.
  • Slow units. If a "unit" test takes 200ms, it's hitting something it shouldn't. Find the dependency and inject a fake.
In Practice

What Healthy Looks Like

A mature unit-test suite runs in seconds, asserts on behavior, requires no setup beyond importing, and gives you confidence to delete or rewrite any module knowing the suite will catch regressions. New code has tests as a non-optional part of the PR. Tests that get hard to write are signals about the design itself — usually a missing abstraction or excessive coupling.

Pair unit tests with the rest of the pyramid: integration for component interactions, E2E for full-system flows. Unit tests can't catch wiring mistakes; the higher tiers can't run a thousand iterations of edge cases. Each tier earns its keep.

Continue

Other Test Types