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.

πŸ€” Sound familiar?
  • 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:

SourceFix
Date.now(), new Date()Fake timers (Vitest, Sinon, MockClock in C#)
Math.random() / GuidInject a seeded generator
Iteration over Object.keys / dict in old runtimesSort before assert
Async ordering racesAwait all promises explicitly; never setTimeout as wait
External networkMock 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.findById returns 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 cached

Naming 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.