A Unit Test Is About One Behavior, Not One Function
Unit testing isn't about achieving 100% coverage or wrapping every function. It's about pinning down behavior β the small, deterministic, fast feedback loop that tells you a refactor didn't change what the code does. When unit tests slow down or start breaking on every change, it's almost always because they were testing implementation, not behavior.
- Renaming a private method breaks twenty tests
- Every test starts with five lines of mock setup
- Tests pass on your machine, fail on CI, pass on a re-run
- You delete tests because βthey were flakyβ
The principles below β AAA, isolation, determinism, the right kind of test double β are what separate a test suite that helps from one that gets disabled and forgotten.
The AAA Pattern
Arrange, Act, Assert. Three sections, separated by a blank line. The test reads top-to-bottom like a story; failures point at one specific moment.
import { describe, it, expect } from 'vitest';
import { applyDiscount } from './pricing';
describe('applyDiscount', () => {
it('rounds the discounted price down to the nearest cent', () => {
// Arrange
const price = 19.999;
const percentOff = 10;
// Act
const result = applyDiscount(price, percentOff);
// Assert
expect(result).toBe(17.99);
});
});Isolation: Tests Don't Share State
Tests must be runnable in any order, in parallel, and any number of times, with the same result. That means no shared mutable state, no leaked timers, no real network, no real filesystem (or a fresh temp dir per test).
import { beforeEach, afterEach, vi } from 'vitest';
beforeEach(() => {
vi.useFakeTimers(); // freeze Date.now()
vi.setSystemTime(new Date('2026-05-17T10:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});Determinism
A test that fails 1 in 50 times is worse than no test β it teaches the team to ignore failures. Sources of nondeterminism and how to remove them:
| Source | Fix |
|---|---|
Date.now(), new Date() | Fake timers (Vitest, Sinon, MockClock in C#) |
Math.random() / Guid | Inject a seeded generator |
Iteration over Object.keys / dict in old runtimes | Sort before assert |
| Async ordering races | Await all promises explicitly; never setTimeout as wait |
| External network | Mock or stub; use MSW for HTTP |
The Test Double Vocabulary (Meszaros)
Saying βmockβ for everything makes conversations imprecise. The five distinct kinds:
- Dummy β passed but never used (a placeholder).
- Stub β returns canned values (β
userRepo.findByIdreturns thisβ). - Spy β records calls so the test can assert on them.
- Mock β has expectations set up in advance; fails the test if not met.
- Fake β a working but simplified implementation (in-memory repo, sqlite-in-memory).
Default to fakes when possible. Stubs for value-returning collaborators. Reach for mocks only when you genuinely need to assert thata call happened (e.g. βnotification was sentβ), not just that the result was right.
// Stub vs mock β same setup, different intent
import { vi } from 'vitest';
const repo = {
findById: vi.fn().mockResolvedValue({ id: '1', name: 'Ada' }), // stub
};
const notifier = { send: vi.fn() }; // spy / mock
// Behavior assertion β what we care about
const svc = new UserGreeter(repo, notifier);
await svc.greet('1');
expect(notifier.send).toHaveBeenCalledWith('1', 'Hello, Ada');Test What the Code Does, Not How
Symptom: refactoring without changing observable behavior breaks tests. Cause: the tests assert on private internals (which methods were called in which order). Fix: assert on outputs and visible side effects only. Treat the unit under test as a black box.
// β Fragile β couples test to implementation choice
expect(internalCacheSpy).toHaveBeenCalledWith('users:1');
// β
Stable β asserts the contract
expect(await service.getUser('1')).toEqual({ id: '1', name: 'Ada' });
expect(repo.findById).toHaveBeenCalledTimes(1); // second call should be cachedNaming That Survives the Six-Month Test
A failing test name should tell you what broke without opening the file. The pattern that holds up: method_condition_expectedResultβ or in BDD style, βwhen X, then Yβ.
// β Useless when red
it('works', () => { ... });
it('test discount 1', () => { ... });
// β
Self-describing
it('applyDiscount: returns original price when percentOff is 0', () => { ... });
it('applyDiscount: throws when percentOff > 100', () => { ... });Parameterized / Table Tests
// Vitest
import { it, expect } from 'vitest';
it.each([
['empty', '', '' ],
['lower', 'hello', 'HELLO' ],
['mixed', 'Hello', 'HELLO' ],
['unicode','straΓe', 'STRASSE'],
])('upperCase: %s β "%s"', (_label, input, expected) => {
expect(upperCase(input)).toBe(expected);
});
// xUnit (.NET)
[Theory]
[InlineData("", "")]
[InlineData("hello", "HELLO")]
[InlineData("Hello", "HELLO")]
public void UpperCase_returns_expected(string input, string expected)
=> Assert.Equal(expected, input.ToUpperInvariant());Pure Functions Are a Cheat Code
Code that takes inputs and returns outputs with no side effects needs no mocks, no setup, no teardown. Push side effects (IO, time, randomness, network) to the edges. The core of your domain logic should be pure. The result: 80% of your tests are five lines long, and the other 20% are integration tests around the edges.
flowchart LR
IN[Inputs] --> H[Imperative Shell<br/>(IO, framework)]
H -->|plain data| C[Functional Core<br/>(pure)]
C -->|plain data| H
H --> OUT[Outputs / effects]
style C fill:#dfd
Coverage as a Smell, Not a Target
Coverage tells you what wasn't executed. It doesn't tell you what wasn't asserted. 90% coverage with weak assertions is a worse signal than 70% with mutation testing showing high kill rate. Use coverage to spot untested branches; use mutation testing (Stryker for JS/.NET) when you want to verify test quality.
Pitfalls
Testing the mocks
If you mock the database and then assert on the mock's arguments, you've tested the test. The unit's contract is what the caller observes, not what it tells its dependencies.
Sleep-based waits
await sleep(100) in a test is a flake waiting to happen. Use fake timers and vi.runAllTimers(), or wait explicitly for the condition you care about.
One assertion per test taken too literally
Multiple assertions are fine if they describe one behavior. Multiple behaviors per test is the smell β split those into separateit blocks so a failure tells you which behavior broke.

