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