Tags: web-dev concept

Unit Testing

Date: 2026-08-17


Testing a piece of code in isolation. The word “unit” is doing all the work and nobody agrees what it means — pick the boundary too small and your tests break on every refactor, which teaches people not to refactor.


A unit test exercises a single piece of code independently of its collaborators, verifying behaviour for given inputs.

The unit boundary is the whole decision

TOO SMALL — every function
  → tests coupled to implementation
  → renaming a private helper breaks
    tests
  → refactoring means rewriting tests
  → people stop refactoring

ABOUT RIGHT — a module's public
behaviour
  → tests describe what it does
  → internals can change freely
  → tests only fail when behaviour does

TOO LARGE — the whole system
  → not a unit test
  → slow, and failures don't localise

The test to apply: if I restructure the internals without changing behaviour, should this test fail? If yes, the boundary is too small.

This is the single most consequential decision in testing, and it’s usually made by accident — the boundary ends up being whatever’s convenient to import.

Test behaviour, not implementation

// BRITTLE — asserts HOW
expect(calculator._applyVat)
  .toHaveBeenCalledWith(50, 0.2)
 
// DURABLE — asserts WHAT
expect(calculateTotal(50)).toBe(60)

The second survives a rewrite of the internals. The first is a test of the current implementation, and its only function is to break when someone improves it.

A useful heuristic: if the test names a private method, it’s testing implementation.

Structure

test('applies VAT to the net price', () => {
  // Arrange
  const order = { net: 50, vatRate: 0.2 }
 
  // Act
  const total = calculateTotal(order)
 
  // Assert
  expect(total).toBe(60)
})

One behaviour per test. A test asserting six things fails on the first and tells you nothing about the other five.

Name tests as sentences about behaviour:

✓ applies VAT to the net price
✓ returns zero for an empty basket
✓ throws when the VAT rate is negative

✗ test1
✗ calculateTotal works
✗ should work correctly

The test name should tell you what broke without opening the file — because that’s the only thing you see in a CI failure.

What deserves unit tests

Pure logic with rules and edge cases — where they’re genuinely the right tool:

GOOD CANDIDATES
  pricing and VAT calculation
  discount stacking rules
  validation
  date and timezone handling
  parsing and formatting
  state machine transitions
    — State Machines

POOR CANDIDATES
  glue code with no logic
  a component that only renders props
  anything whose value is in its
    integration
    — Integration Testing

See: State Machines · Integration Testing

A function with branching rules and money involved is the ideal unit test. A component that passes props through is better covered by rendering it.

Edge cases are the point

describe('calculateTotal', () => {
  test('handles an empty basket')
  test('handles a single item')
  test('rounds to two decimal places')
  test('handles a 100% discount')
  test('rejects a negative quantity')
  test('handles the maximum quantity')
})

The happy path is rarely where bugs are. Zero, one, many, negative, maximum, null, empty string, and the boundary either side of every threshold.

Rounding is the one that costs money. Any monetary calculation should have a test asserting the pennies — Contribution Margin.

Mocking, carefully

// reasonable — an external boundary
vi.mock('./api/fetchPrices')
 
// suspicious — mocking your own code
vi.mock('./calculateVat')

Mocking your own modules usually means the unit boundary is wrong. If a function needs three of your own modules mocked to test, it isn’t isolated — it’s entangled, and the test is documenting the entanglement — Test Doubles, Coupling and Cohesion.

What they can’t do

  • Prove the pieces work together. Every unit passing with mocks is compatible with a completely broken application
  • Catch contract drift. Your mock returns what the API returned in March
  • Test anything about rendering, layout or the browser

Which is why a suite that’s only unit tests gives false confidence, and why the weighting usually belongs at the integration level for web applications — Testing Strategy.