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") not it("should return null...")
Structure Rules
- One assertion per test (conceptual, not literal -- multiple
expect calls are fine if testing one behavior)
- No logic in tests -- no
if, for, or switch in test code
- No test interdependencies -- each test sets up and tears down its own state
- Use
beforeEach for shared setup, not beforeAll -- isolation matters more than speed
- Group related tests with nested
describe blocks
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: "test@example.com", name: "Test User" })
.expect(201);
expect(response.body.data).toMatchObject({
email: "test@example.com",
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
1---2name: testing-strategy3description: Testing approach covering unit/integration/e2e split, what to test, AAA test structure, mocking strategies, and coverage targets. Use when writing tests, planning test coverage, or reviewing test quality.4---56# Testing Strategy78> Testing approach: unit/integration/e2e split, what to test, test structure (AAA pattern), mocking strategies, and coverage targets.910## Testing Pyramid1112Follow the testing pyramid -- more unit tests, fewer integration tests, even fewer e2e tests:1314```15 / E2E \ ~5% -- Critical user journeys16 /----------\17 / Integration \ ~20% -- Module boundaries, API contracts18 /----------------\19 / Unit Tests \ ~75% -- Functions, components, utilities20 /____________________\21```2223| Level | Speed | Scope | Quantity |24|-------|-------|-------|----------|25| Unit | Fast (ms) | Single function/component | Many |26| Integration | Medium (seconds) | Module interactions, DB, API | Some |27| E2E | Slow (seconds-minutes) | Full user flows | Few |2829## What to Test3031### Always Test3233- Business logic and calculations34- Data transformations and mappings35- Validation rules36- Error handling paths37- Edge cases: empty inputs, boundary values, null/undefined38- Public API of modules (exported functions)39- State transitions (reducers, state machines)40- Critical user journeys (e2e)4142### Don't Test4344- Third-party library internals45- Simple getters/setters with no logic46- Framework boilerplate (constructor, lifecycle method existence)47- Implementation details (private methods, internal state shape)48- Constants and configuration values49- CSS styling (use visual regression tools instead)5051## Test Structure -- AAA Pattern5253Every test should follow **Arrange, Act, Assert**:5455```typescript56describe("OrderService", () => {57 describe("calculateTotal", () => {58 it("applies percentage discount to subtotal", () => {59 // Arrange60 const items = [61 { name: "Widget", price: 25.00, quantity: 2 },62 { name: "Gadget", price: 15.00, quantity: 1 },63 ];64 const discount = { type: "percentage", value: 10 };6566 // Act67 const total = calculateTotal(items, discount);6869 // Assert70 expect(total).toBe(58.50); // (50 + 15) * 0.9071 });72 });73});74```7576### Naming Rules77781. **Describe blocks name the unit** -- `describe("OrderService")`, `describe("calculateTotal")`792. **Test names describe the behavior** -- `it("applies percentage discount to subtotal")`803. **Use the pattern**: `it("<expected behavior> when <condition>")`814. **Don't start with "should"** -- `it("returns null for invalid input")` not `it("should return null...")`8283### Structure Rules84851. **One assertion per test** (conceptual, not literal -- multiple `expect` calls are fine if testing one behavior)862. **No logic in tests** -- no `if`, `for`, or `switch` in test code873. **No test interdependencies** -- each test sets up and tears down its own state884. **Use `beforeEach` for shared setup, not `beforeAll`** -- isolation matters more than speed895. **Group related tests with nested `describe` blocks**9091## Mocking Strategies9293### When to Mock9495| Mock | Don't Mock |96|------|-----------|97| External APIs and services | The unit under test |98| Database calls (in unit tests) | Simple utility functions |99| File system access | Data transformations |100| Timers and dates | Pure functions |101| Network requests | Collaborators (in integration tests) |102| Third-party services | Standard library methods |103104### Mocking Hierarchy105106Prefer lighter mocking techniques when possible:1071081. **Stubs** -- return a fixed value: `jest.fn().mockReturnValue(42)`1092. **Spies** -- observe calls without changing behavior: `jest.spyOn(service, 'save')`1103. **Fakes** -- lightweight in-memory implementation: `new InMemoryUserRepository()`1114. **Mocks** -- full behavior replacement: `jest.mock('./database')`112113### Rules1141151. **Mock at the boundary** -- mock the database client, not the repository method1162. **Don't mock what you don't own** -- wrap third-party APIs in your own adapter, mock the adapter1173. **Reset mocks between tests** -- use `afterEach(() => jest.restoreAllMocks())`1184. **Verify interactions sparingly** -- prefer asserting on output over asserting mock was called119120```typescript121// Good -- mock at the boundary122const mockDb = { query: jest.fn().mockResolvedValue([{ id: 1, name: "Alice" }]) };123const repo = new UserRepository(mockDb);124const user = await repo.findById(1);125expect(user.name).toBe("Alice");126127// Bad -- mocking the unit under test128jest.spyOn(repo, "findById").mockResolvedValue({ id: 1, name: "Alice" });129```130131### Test Doubles for Common Scenarios132133```typescript134// Fixed time135beforeEach(() => {136 jest.useFakeTimers();137 jest.setSystemTime(new Date("2025-06-15T12:00:00Z"));138});139afterEach(() => jest.useRealTimers());140141// API responses142const mockFetch = jest.fn().mockResolvedValue({143 ok: true,144 json: async () => ({ data: { id: 1 } }),145});146global.fetch = mockFetch;147148// Environment variables149const originalEnv = process.env;150beforeEach(() => {151 process.env = { ...originalEnv, API_KEY: "test-key" };152});153afterEach(() => {154 process.env = originalEnv;155});156```157158## Integration Tests1591601. **Test module boundaries** -- service calls repository, repository calls database1612. **Use a real (test) database** -- SQLite in-memory or Docker containers1623. **Test API endpoints end-to-end** -- use supertest or similar1634. **Seed data in `beforeEach`** -- don't rely on database state from other tests1645. **Test error paths** -- connection failures, timeouts, constraint violations165166```typescript167describe("POST /api/users", () => {168 it("creates a user and returns 201", async () => {169 const response = await request(app)170 .post("/api/users")171 .send({ email: "test@example.com", name: "Test User" })172 .expect(201);173174 expect(response.body.data).toMatchObject({175 email: "test@example.com",176 name: "Test User",177 });178 expect(response.body.data.id).toBeDefined();179 });180181 it("returns 400 for invalid email", async () => {182 const response = await request(app)183 .post("/api/users")184 .send({ email: "not-an-email", name: "Test User" })185 .expect(400);186187 expect(response.body.error.code).toBe("VALIDATION_ERROR");188 });189});190```191192## E2E Tests1931941. **Test critical user journeys only** -- sign up, purchase, core workflow1952. **Use realistic data** -- not "test123" or "foo bar"1963. **Use data-testid attributes** -- `data-testid="submit-button"`, not CSS selectors1974. **Handle async operations explicitly** -- wait for elements, not fixed timeouts1985. **Run in CI against a staging environment**1996. **Keep under 10 minutes total**200201## Coverage Targets202203| Metric | Target | Notes |204|--------|--------|-------|205| Line coverage | 80%+ | Higher for critical modules |206| Branch coverage | 75%+ | Ensures conditional paths are tested |207| Function coverage | 85%+ | All public functions tested |208| Critical paths | 100% | Payment, auth, data integrity |209210### Rules2112121. **Coverage is a floor, not a ceiling** -- don't write bad tests to hit a number2132. **Track coverage trends** -- it should go up or stay flat, never down2143. **Enforce in CI** -- fail the build if coverage drops below the threshold2154. **Exclude generated code** -- don't count auto-generated files, configs, or type definitions216217## Test File Organization218219```220src/221 services/222 order.service.ts223 order.service.test.ts # Unit tests colocated224 api/225 routes/226 users.route.ts227tests/228 integration/229 api/230 users.test.ts # Integration tests separate231 e2e/232 flows/233 checkout.test.ts # E2E tests separate234 fixtures/235 users.json # Shared test data236 helpers/237 test-db.ts # Shared test utilities238```239240## Anti-patterns241242- **Testing implementation details** -- test behavior, not how it's implemented243- **Snapshot overuse** -- snapshots are brittle; use them for serializable output, not UI244- **Flaky tests** -- fix or delete them; a flaky test is worse than no test245- **Test setup duplication** -- extract to helper functions or fixtures246- **Ignoring test failures** -- a skipped test is a known bug you're choosing to keep247- **100% coverage obsession** -- diminishing returns past 85%; focus on meaningful tests