Unit testing

Write truly effective unit tests for business logic — complete file structure, 5 common anti-patterns with before/after comparisons, async/await handling, parameterized tests (test.each), and snapshot maintenance strategy.

“90% line coverage” does not mean tests are effective — cases without expect, assertions that always pass, still contribute to coverage numbers. The goal is to make every test turn red precisely when behavior breaks.

In the SKILL, beyond naming the framework (Jest / Vitest / Mocha), also specify: where test files live (__tests__/ vs co-located *.spec.ts), which factory file paths to reuse, and which code does not need unit tests (Express route registration, ORM migrations, config-only files) to avoid agents padding unneeded coverage.

  • Every public behavior: at least 1 happy path + 1 boundary or error path.
  • Name tests with it('should <expected behavior> when <condition>') so failure reports are readable immediately.
  • Reuse repo-internal factories/builders — do not hand-write { id: 1, name: 'test' } in every case.

Test pyramid and layers

Many fast, isolated unit tests at the base; a moderate layer of integration tests (real collaboration boundaries); a thin top of E2E or contract tests. In the SKILL, document each layer’s folders, naming prefixes, and which layers must run on PR—avoid “everything is E2E” slowing feedback.

              ┌─────────┐
             ╱   E2E   ╱│  Few, slow: key user journeys, smoke
            ╱────────╱ │
           ╱  integ  ╱  │  Middle: real DB/msg/HTTP or testcontainers
          ╱────────╱   │
         ╱  unit   ╱    │  Many, fast: pure logic, mock external I/O
        ╱────────╱     │
       ──────────      └── Lower: more tests ↑, faster each ↓, finer blame ↑
Tip: When generating tests, fill unit-layer gaps first; integration/E2E only for what units cannot prove (wiring and cross-process contracts).

Complete test file structure and 5 anti-patterns

Standard test file structure: group by feature with describe, centralize shared fixture construction in beforeEach, keep each it to a single behavioral scenario. Here is a complete, runnable example (Jest / Vitest style):

// src/billing/__tests__/computeTotal.test.ts
import { computeTotal } from '../computeTotal';
import { buildCart, buildItem } from '../../test/factories';

describe('computeTotal', () => {
  let baseCart: Cart;

  beforeEach(() => {
    baseCart = buildCart({ items: [buildItem({ price: 100, qty: 2 })] });
  });

  describe('with tax rate', () => {
    it('should apply tax on top of subtotal', () => {
      // Arrange
      const taxRate = 0.1;
      // Act
      const result = computeTotal(baseCart, taxRate);
      // Assert
      expect(result).toBe(220); // 200 * 1.1
    });

    it('should return subtotal when tax rate is 0', () => {
      expect(computeTotal(baseCart, 0)).toBe(200);
    });
  });

  describe('edge cases', () => {
    it('should return 0 for empty cart', () => {
      expect(computeTotal(buildCart({ items: [] }), 0.1)).toBe(0);
    });

    it('should throw when tax rate is negative', () => {
      expect(() => computeTotal(baseCart, -0.1)).toThrow('Invalid tax rate');
    });
  });
});

5 high-frequency anti-patterns (before / after comparison):

  • Anti-pattern 1: Testing implementation details instead of behavior
    expect(service._cache.has('key')).toBe(true) (internal private field)
    expect(await service.get('key')).toEqual(expectedData) (observable output)
  • Anti-pattern 2: Asserting multiple unrelated behaviors in one test
    ❌ A single it that simultaneously asserts return value, side-effect calls, and log output
    ✅ Split into three independent it blocks, each failing for exactly one reason
  • Anti-pattern 3: Using done callback for async (easy to miss the call, causing tests to always pass)
    it('should fetch', (done) => { fetchUser(1).then(u => { expect(u.id).toBe(1); done(); }); })
    it('should fetch', async () => { const u = await fetchUser(1); expect(u.id).toBe(1); })
  • Anti-pattern 4: Over-mocking so tests only test the double
    ❌ Mocking every sub-function called inside the function under test
    ✅ Only mock out-of-process boundaries (HTTP, DB, clock); let internal logic run for real
  • Anti-pattern 5: Global mock state causing test-order coupling
    jest.mock(...) at the top of describe and never reset; test 3 depends on state set by test 2
    ✅ Explicitly set each test's mock return values inside beforeEach

Parameterized tests (test.each): ideal when the same logic needs validating across multiple input/output combinations — avoids copy-pasting test bodies.

// test.each: validate multiple discount scenarios
test.each([
  [0,   100, 100],   // [discountRate, price, expected]
  [0.1, 100, 90],
  [0.5, 200, 100],
  [1,   50,  0],
])(
  'should apply %s discount to %s → %s',
  (rate, price, expected) => {
    expect(applyDiscount(price, rate)).toBe(expected);
  }
);

Snapshot tests — when to use and how to maintain:

  • ✅ Good for: UI component render output (React/Vue component trees), complex serialized objects (API response shape) — regression detection.
  • ❌ Not for: frequently changing components, output containing timestamps or random IDs (produces endless meaningless diffs).
  • Maintenance rule: snapshot files must be committed alongside code; snapshot changes in a PR require manual review to confirm they are intentional — never blindly run --updateSnapshot.
  • A large snapshot (>50 lines) usually signals the component has too many responsibilities — consider splitting it or switching to precise assertions (expect(el).toHaveTextContent('...')).

Mocking: do and don’t

Document team rules for mock / stub / fake so tests do not become “testing the double” or tightly coupled to implementation.

Mock usage (replace terms with your project glossary).
Do Don’t
Isolate I/O, clocks, randomness, third-party SDKs outside the process Mock private helpers inside the unit under test or widen production visibility just for tests
Use fakes with readable domain behavior (in-memory repos) Over-mock so tests pass when types/protocols drift from reality
Assert what was called and with which args at collaboration boundaries Assert implementation details (private fields, irrelevant call order)
Set double return values inside each test; avoid implicit globals Mass resetMocks in afterEach without rebuilding scenario—hides leakage between tests
Use await / framework flush for async—assert after microtasks settle Bare setTimeout guessing delay or relying on machine speed

Mini tool: case name and fixture index

Turn a snake_case test id into a readable title (for docs or reports); the fixture counter is a local naming draft, unrelated to real repo fixtures.

Fixture draft index

fixture_000

---
name: unit-testing
description: Isolated, repeatable unit tests and assertions for business modules
---
# Essentials
1. Pyramid: unit tests >70%; keep full CI < 60s; unit 200 / integration 15 / E2E 7
2. Structure: describe/beforeEach/it; AAA comments; one behavioral scenario per it
3. Anti-patterns: no impl-detail assertions, no multi-behavior its, use async/await not done
4. Parameterized: use test.each for multi-input validation; snapshot only for regression, commit snapshots
5. Mock only out-of-process boundaries; set return values per-test in beforeEach

Back to skills More skills