Engineering5 min read

Unit Testing Principles

Write effective unit tests - the testing pyramid, FIRST principles, test doubles (mocks/stubs/spies), and test design patterns that make tests maintainable.

Unit TestingTDDJestVitestTest Design

skill: Unit Testing Principles category: Engineering tags: [Unit Testing, TDD, Jest, Vitest, Test Design] readingTime: 5

Tests are code that proves other code works. The goal is not 100% coverage - it's confidence. Confident tests are fast, isolated, and test behavior not implementation. Tests that require constant updating when internals change are not testing behavior; they're testing implementation details and provide false security.

Workflow

  1. Apply the testing pyramid and FIRST principlesThe pyramid guides where to invest testing effort. FIRST principles define what makes a good test - violation of any one creates fragile, slow, or unreliable tests.
  2. Write tests with the AAA patternArrange-Act-Assert (AAA) gives tests a consistent, readable structure. Every test has exactly one action and one assertion group - the single-responsibility principle for tests.
  3. Use test doubles appropriatelyTest doubles (mocks, stubs, spies, fakes) replace real dependencies to keep tests fast and isolated. Choosing the right double for the right purpose prevents over-mocking.
  4. Practice Test-Driven DevelopmentTDD (red → green → refactor) produces better-designed code - if a unit is hard to test, it's usually too large or has too many dependencies.
  5. Avoid common test anti-patternsAnti-patterns make tests brittle, slow, or unreliable - they erode trust in the test suite until people stop running (or fixing) tests.

Output Format

Unit testing: AAA structure with beforeEach isolation, Fake repository over Mock, TDD red→green→refactor, and anti-pattern avoidance (no implementation details, no shared state, no assertion-free tests).

describe("Service") → beforeEach(() => { repo = new Fake(); svc = new Service(repo) })
it("does X") { Arrange → Act → Assert }
Fake: in-memory Map<string,Entity> implementing IRepository
TDD: failing test → min code to pass → refactor
Avoid: _internals, shared state, assertion-free, snapshot overuse

Examples

Input

Output

Edge Cases & Gotchas

  • Testing async error handling - expect(async fn).rejects.toThrow() is the correct pattern. Wrapping in try/catch and asserting in catch allows tests to silently pass if the error is never thrown.
  • Time-dependent tests - Tests using new Date() or Date.now() fail nondeterministically. Use vi.useFakeTimers() or inject a clock dependency. Never hardcode "today" in assertions.
  • Circular dependencies breaking tests - Circular module imports (A imports B, B imports A) cause vi.mock() to return undefined unexpectedly. Restructure to break the cycle, or use vi.mock with a factory function.
  • Coverage vanity metrics - 90% coverage with mostly implementation-detail tests provides false confidence. A 70% coverage test suite testing observable behavior provides more value. Measure mutation testing score, not coverage.

When to Break These Rules

  • Write integration tests instead of unit tests for code that has no meaningful behavior without its dependencies (thin adapters, mappers, simple CRUD). Unit testing these adds no value.
  • Skip unit tests for prototype/throwaway code - the investment pays off only when code will be maintained. Validate with end-to-end tests first, add unit tests when the design stabilizes.

Reference Files

  • https://vitest.dev/guide/
  • https://jestjs.io/docs/getting-started

More skills in this library

Browse all technical skills - Mobile, AI/ML, Web, Backend, Design, and Product.