Skip to content

Unit Testing

A unit test verifies the smallest piece of behaviour in isolation — a function, a method, a class. It runs in memory, with all collaborators replaced by test doubles (stubs, mocks, spies), so a failure points at exactly one unit.

┌─────────────┐
│ Unit under │ ← only this is real
│ test │
└──────┬──────┘
stub ───┤├─── mock ← collaborators are fake

The defining properties: fast (milliseconds), deterministic (no clock, network or disk), isolated (no shared state between tests), and focused (one reason to fail).

Use it for

  • Pure logic: calculations, parsers, formatters, state reducers, validators
  • Boundary conditions and edge cases (0, empty, max, unicode)
  • Branches and error paths you can reach cheaply

Avoid or reduce it when

  • The unit is thin glue over a framework — you would be testing the framework
  • Everything is mocked away, so the test still passes when the wiring is broken
  • Behaviour is really about several units cooperating → use an integration test
Framework Ecosystem Notes
Vitest JS/TS Vite-native, Jest-compatible API, built-in coverage
Jest JS/TS Most widely used; large plugin ecosystem
Mocha + Chai/Sinon JS/TS Flexible, unopinionated runner
pytest Python Fixtures, parametrise, huge plugin ecosystem
unittest Python Standard library, no dependencies
Go testing (go test) Go Built in; table-driven tests are idiomatic
Testify Go Assertions and mocks on top of go test
JUnit 5 Java/JVM The JVM standard; parameterized tests
TestNG Java/JVM Suites, groups, parallel execution
xUnit .NET Modern, [Fact]/[Theory]
NUnit .NET Mature, data-driven [TestCase]
Rust #[test] Rust Built in via cargo test
PHPUnit PHP The PHP standard

Mocking/doubling: Sinon, Jest mocks, unittest.mock, mockito (Java), Moq/NSubstitute (.NET), testify/mock (Go), mockall (Rust).

Terminal window
npm install -D vitest
npx vitest run # or `npx vitest` in watch mode
// vite.config.ts (optional)
import { defineConfig } from 'vite';
export default defineConfig({ test: { environment: 'node' } });
import { describe, it, expect, vi } from 'vitest';
import { calculateDiscount } from './discount';
describe('calculateDiscount', () => {
it('applies 10% for orders over 100', () => {
expect(calculateDiscount(200)).toBe(20);
});
it('applies no discount at the boundary', () => {
expect(calculateDiscount(100)).toBe(0);
});
it('does not call the logger on the happy path', () => {
const log = vi.fn();
calculateDiscount(200, log);
expect(log).not.toHaveBeenCalled();
});
});
  • Test behaviour, not implementation. Assert on outputs and observable state, not on how many times a private helper ran.
  • One main reason to fail per test. Name it after the behaviour (applies 10% for orders over 100), not the method.
  • Avoid over-mocking. If a test needs five mocks to run, the unit is too coupled.
  • No shared mutable fixtures. Tests must pass in any order and in parallel.
  • Cover boundaries. Off-by-one bugs live at 0, 1, max, empty, null.
  • Do not chase 100% coverage. Coverage shows what ran, not what was verified — use mutation testing for that.

Self-assessment quiz

Pick one answer per question, then press Check answers.

  1. What is the key characteristic that makes a test a *unit* test?
  2. Which of these should a good unit test avoid?
  3. Your unit test needs five mocks to run. What does this most likely indicate?
  4. Which pairing is a unit-test framework for Python?
  5. Why is 100% line coverage NOT the same as being well tested?