Steven's Knowledge

Unit Testing

Structure, assertions, and the discipline of testing one thing at a time without leaking into the layers above

Unit Testing

A unit test exercises one unit of behavior in isolation, with no I/O and no network. Done well, it's the cheapest signal in the suite: milliseconds to run, deterministic, written next to the code, easy to read.

Done badly, it's the most expensive: a test that pins the implementation in place, fails for unrelated reasons, and makes refactoring a chore. The difference is mostly discipline at the boundaries.

For the higher-level question of which tests belong at the unit level vs. above, see Testing Strategy. This page is about how to write a unit test that earns its place.

What "Unit" Means

The honest answer: a unit is whatever the team consistently treats as one. Common definitions:

DefinitionTest scope
One functionDirect calls, often pure
One classThe class plus simple collaborators it owns
One module / fileCohesive logic exposed through a small public API
One feature sliceSeveral files that together implement one behavior

The classic "one class = one test class" rule is fine for small classes and a trap for large ones. A 500-line class with 30 methods doesn't need 30 test files — it needs to be split, or tested through a smaller seam.

The useful rule isn't "test the class," it's "test through the smallest stable interface that exposes the behavior." Implementation details below that line should be free to change without breaking tests.

Structure: Arrange-Act-Assert

Three sections, separated by blank lines:

test('rejects negative withdrawals', () => {
  // Arrange
  const account = new Account({ balance: 100 });

  // Act
  const result = account.withdraw(-50);

  // Assert
  expect(result.ok).toBe(false);
  expect(result.error).toBe('NEGATIVE_AMOUNT');
  expect(account.balance).toBe(100);
});

Why the structure matters:

  • A reader can scan the act line and know what's under test. If you can't find the act in five seconds, the test is doing too much.
  • Setup leaking into assertions is a smell. "Arrange in the assertions" usually means the test is asserting on intermediate state, not the actual outcome.
  • Multiple acts is a smell. Two calls in one test usually means two tests.

A common variant: Given-When-Then. Same shape, different vocabulary; pick one and use it consistently.

Naming

A test name is read more often than it's written. The shape:

<what is being tested>_<under what condition>_<what is expected>

withdraw_whenAmountExceedsBalance_returnsInsufficientFundsError
parsePhoneNumber_withInternationalPrefix_returnsFormattedNumber
calculateDiscount_forFirstTimeBuyer_appliesTwentyPercent

The name should let a reader know what failed without opening the file. test('works', ...) and test('user', ...) fail this bar.

The opposite extreme — names that fully describe the implementation — is also a trap: withdraw_callsValidatorThenUpdatesBalanceThenLogsTransaction couples the test name to internals.

What to Assert

A unit test asserts a single observable property of the unit's behavior. Three patterns:

State assertion

The unit changed some state; the test reads it back:

account.deposit(50);
expect(account.balance).toBe(150);

Return assertion

The unit returned a value:

expect(formatPrice(1234)).toBe('$12.34');

Interaction assertion (use sparingly)

The unit called a collaborator a certain way:

service.publish(event);
expect(broker.send).toHaveBeenCalledWith({ type: 'order.placed', id: 1 });

Prefer state and return assertions over interaction assertions. Interaction assertions couple the test to how the unit does its job, not what it accomplishes. They break the moment the implementation changes — even if the behavior is identical.

When interaction assertions are the right tool: the only observable behavior is the call (e.g., "the audit logger was called with the right event"). Otherwise, find a way to observe the outcome.

What Not to Test

Pages of tests on things that don't need them is its own anti-pattern. Skip:

  • Trivial getters and setters that only assign or return.
  • Framework code. Don't test that React.useState works.
  • Constructor parameter assignment. new User('a').name === 'a' is testing the language.
  • Generated code. The generator has tests.
  • Private helpers via reflection. Test the public seam that uses them.
  • Tautologies. expect(x).toBe(x) is not a test.

The litmus: if I broke this code in a realistic way, would any test fail? If the answer for a proposed test is "no, because the assertion would still hold," don't write it.

Test Doubles: The Short Version

Detailed terminology lives in Testing Strategy. For unit tests specifically:

  • Prefer real collaborators when they're cheap. A pure function passed to another pure function doesn't need a stub.
  • Use fakes when state matters but the real thing is expensive. In-memory repository, in-memory clock.
  • Use stubs for "this collaborator returns X" cases.
  • Use mocks rarely, and only when you genuinely need to assert that a call happened.

If a test creates more mocks than it has lines of arrange code, the unit has too many dependencies — split it.

Speed

A unit test that takes longer than ~10ms is suspect. Sources of slowness:

  • It's touching the filesystem (use temp dirs / in-memory FS, or it's not a unit test).
  • It's doing real network I/O (same — not a unit test).
  • It's spinning up a heavy framework (lazy-load the framework outside the test).
  • It's running an expensive computation that could be table-driven (precompute fixtures).
  • It's sleeping (replace with deterministic time control).

A suite of 1,000 unit tests should run in 5–15 seconds. If yours doesn't, something is masquerading as a unit test.

Common Failure Modes

Tests that mirror the implementation

test('processOrder', () => {
  const order = makeOrder();
  service.processOrder(order);
  expect(validator.validate).toHaveBeenCalled();
  expect(repository.save).toHaveBeenCalled();
  expect(notifier.send).toHaveBeenCalled();
});

This test will pass as long as processOrder calls those three methods in any order, with any arguments, doing anything to the order. A bug that mangles the order would not fail it. Refactor — pull notifier out into a separate handler — and the test breaks for no behavioral reason.

Better: assert an outcome. Was the order persisted? Did the saved order have the right total? Did a confirmation get sent?

One test per branch, even when behavior is the same

test('admin user can read', () => expect(canRead(adminUser)).toBe(true));
test('admin user can write', () => expect(canWrite(adminUser)).toBe(true));
test('admin user can delete', () => expect(canDelete(adminUser)).toBe(true));
// ...11 more

This is data, not behavior. Use a table-driven test:

test.each([
  [adminUser, 'read', true],
  [adminUser, 'write', true],
  [adminUser, 'delete', true],
  [memberUser, 'read', true],
  [memberUser, 'write', false],
])('%s can %s = %s', (user, action, expected) => {
  expect(can(user, action)).toBe(expected);
});

One row, one case, one signal.

Tests that exercise everything to assert one thing

test('returns customer name', () => {
  // 30 lines of setup: cart, items, promotions, taxes, shipping, payment
  const order = service.placeOrder(complexState);
  expect(order.customer.name).toBe('Alice');
});

The customer name does not need a shopping cart. Test what you're asserting.

Snapshot-as-test

expect(renderResult).toMatchSnapshot();

Useful for output too structural to assert otherwise. Dangerous because:

  • Snapshots are updated reflexively when they fail.
  • Large snapshots aren't read in review.
  • A wrong implementation that happens to produce the existing snapshot will be locked in.

If a snapshot fails, someone has to read the diff and decide. If that doesn't happen, the snapshot is providing no signal.

Time-coupled tests

test('expires after 7 days', () => {
  const token = createToken();
  // wait 7 days?
  expect(token.isExpired()).toBe(true);
});

Anything that depends on real time is non-deterministic and slow. Inject a clock:

const clock = new TestClock('2024-01-01');
const token = createToken(clock);
clock.advance({ days: 7, minutes: 1 });
expect(token.isExpired(clock)).toBe(true);

Pre-commit Checklist

Before a unit test goes in:

  • Can I name the single behavior under test in one short sentence?
  • Is the act section one line, easy to spot?
  • Would this test fail if I broke the behavior in a plausible way?
  • Will this test still pass after a refactor that doesn't change behavior?
  • Does it run in under 10ms?
  • Are any mocks asserting that a specific call happened? Is that the actual observable behavior, or am I testing implementation?
  • If I deleted this test, would I lose protection against any real bug?

The last question is the most useful. A test that doesn't answer "yes" to it isn't pulling weight.

On this page