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 ↑
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 singleitthat simultaneously asserts return value, side-effect calls, and log output
✅ Split into three independentitblocks, each failing for exactly one reason -
Anti-pattern 3: Using
donecallback 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 ofdescribeand never reset; test 3 depends on state set by test 2
✅ Explicitly set each test's mock return values insidebeforeEach
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.
| 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