Testing Strategy
Testing approach: unit/integration/e2e split, what to test, test structure (AAA pattern), mocking strategies, and coverage targets.
Testing Pyramid
Follow the testing pyramid — more unit tests, fewer integration tests, even fewer e2e tests:
/ E2E \ ~5% — Critical user journeys
/----------\
/ Integration \ ~20% — Module boundaries, API contracts
/----------------\
/ Unit Tests \ ~75% — Functions, components, utilities
/____________________\
| Level | Speed | Scope | Quantity |
|---|---|---|---|
| Unit | Fast (ms) | Single function/component | Many |
| Integration | Medium (seconds) | Module interactions, DB, API | Some |
| E2E | Slow (seconds-minutes) | Full user flows | Few |
What to Test
Always Test
- Business logic and calculations
- Data transformations and mappings
- Validation rules
- Error handling paths
- Edge cases: empty inputs, boundary values, null/undefined
- Public API of modules (exported functions)
- State transitions (reducers, state machines)
- Critical user journeys (e2e)
Don't Test
- Third-party library internals
- Simple getters/setters with no logic
- Framework boilerplate (constructor, lifecycle method existence)
- Implementation details (private methods, internal state shape)
- Constants and configuration values
- CSS styling (use visual regression tools instead)
Test Structure — AAA Pattern
Every test should follow Arrange, Act, Assert:
describe("OrderService", () => {
describe("calculateTotal", () => {
it("applies percentage discount to subtotal", () => {
// Arrange
const items = [
{ name: "Widget", price: 25.00, quantity: 2 },
{ name: "Gadget", price: 15.00, quantity: 1 },
];
const discount = { type: "percentage", value: 10 };
// Act
const total = calculateTotal(items, discount);
// Assert
expect(total).toBe(58.50); // (50 + 15) * 0.90
});
});
});
Naming Rules
- Describe blocks name the unit —
describe("OrderService"),describe("calculateTotal") - Test names describe the behavior —
it("applies percentage discount to subtotal") - Use the pattern:
it("<expected behavior> when <condition>") - Don't start with "should" —
it("returns null for invalid input")notit("should return null...")
Structure Rules
- One assertion per test (conceptual, not literal — multiple
expectcalls are fine if testing one behavior) - No logic in tests — no
if,for, orswitchin test code - No test interdependencies — each test sets up and tears down its own state
- Use
beforeEachfor shared setup, notbeforeAll— isolation matters more than speed - Group related tests with nested
describeblocks
Mocking Strategies
When to Mock
| Mock | Don't Mock |
|---|---|
| External APIs and services | The unit under test |
| Database calls (in unit tests) | Simple utility functions |
| File system access | Data transformations |
| Timers and dates | Pure functions |
| Network requests | Collaborators (in integration tests) |
| Third-party services | Standard library methods |
Mocking Hierarchy
Prefer lighter mocking techniques when possible:
- Stubs — return a fixed value:
jest.fn().mockReturnValue(42) - Spies — observe calls without changing behavior:
jest.spyOn(service, 'save') - Fakes — lightweight in-memory implementation:
new InMemoryUserRepository() - Mocks — full behavior replacement:
jest.mock('./database')
Rules
- Mock at the boundary — mock the database client, not the repository method
- Don't mock what you don't own — wrap third-party APIs in your own adapter, mock the adapter
- Reset mocks between tests — use
afterEach(() => jest.restoreAllMocks()) - Verify interactions sparingly — prefer asserting on output over asserting mock was called
// Good — mock at the boundary
const mockDb = { query: jest.fn().mockResolvedValue([{ id: 1, name: "Alice" }]) };
const repo = new UserRepository(mockDb);
const user = await repo.findById(1);
expect(user.name).toBe("Alice");
// Bad — mocking the unit under test
jest.spyOn(repo, "findById").mockResolvedValue({ id: 1, name: "Alice" });
Test Doubles for Common Scenarios
// Fixed time
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2025-06-15T12:00:00Z"));
});
afterEach(() => jest.useRealTimers());
// API responses
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: { id: 1 } }),
});
global.fetch = mockFetch;
// Environment variables
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv, API_KEY: "test-key" };
});
afterEach(() => {
process.env = originalEnv;
});
Integration Tests
- Test module boundaries — service calls repository, repository calls database
- Use a real (test) database — SQLite in-memory or Docker containers
- Test API endpoints end-to-end — use supertest or similar
- Seed data in
beforeEach— don't rely on database state from other tests - Test error paths — connection failures, timeouts, constraint violations
describe("POST /api/users", () => {
it("creates a user and returns 201", async () => {
const response = await request(app)
.post("/api/users")
.send({ email: "[email protected]", name: "Test User" })
.expect(201);
expect(response.body.data).toMatchObject({
email: "[email protected]",
name: "Test User",
});
expect(response.body.data.id).toBeDefined();
});
it("returns 400 for invalid email", async () => {
const response = await request(app)
.post("/api/users")
.send({ email: "not-an-email", name: "Test User" })
.expect(400);
expect(response.body.error.code).toBe("VALIDATION_ERROR");
});
});
E2E Tests
- Test critical user journeys only — sign up, purchase, core workflow
- Use realistic data — not "test123" or "foo bar"
- Use data-testid attributes —
data-testid="submit-button", not CSS selectors - Handle async operations explicitly — wait for elements, not fixed timeouts
- Run in CI against a staging environment
- Keep under 10 minutes total
Coverage Targets
| Metric | Target | Notes |
|---|---|---|
| Line coverage | 80%+ | Higher for critical modules |
| Branch coverage | 75%+ | Ensures conditional paths are tested |
| Function coverage | 85%+ | All public functions tested |
| Critical paths | 100% | Payment, auth, data integrity |
Rules
- Coverage is a floor, not a ceiling — don't write bad tests to hit a number
- Track coverage trends — it should go up or stay flat, never down
- Enforce in CI — fail the build if coverage drops below the threshold
- Exclude generated code — don't count auto-generated files, configs, or type definitions
Test File Organization
src/
services/
order.service.ts
order.service.test.ts # Unit tests colocated
api/
routes/
users.route.ts
tests/
integration/
api/
users.test.ts # Integration tests separate
e2e/
flows/
checkout.test.ts # E2E tests separate
fixtures/
users.json # Shared test data
helpers/
test-db.ts # Shared test utilities
Anti-patterns
- Testing implementation details — test behavior, not how it's implemented
- Snapshot overuse — snapshots are brittle; use them for serializable output, not UI
- Flaky tests — fix or delete them; a flaky test is worse than no test
- Test setup duplication — extract to helper functions or fixtures
- Ignoring test failures — a skipped test is a known bug you're choosing to keep
- 100% coverage obsession — diminishing returns past 85%; focus on meaningful tests