| name | testing-strategy |
| description | Comprehensive testing strategies including test pyramid, TDD methodology, testing patterns, coverage goals, and CI/CD integration. Use when writing tests, implementing TDD, reviewing test coverage, debugging test failures, or setting up testing infrastructure. |
Testing Strategy
This skill provides comprehensive guidance for implementing effective testing strategies across your entire application stack.
Test Pyramid
The Testing Hierarchy
/\
/ \
/E2E \ 10% - End-to-End Tests (slowest, most expensive)
/______\
/ \
/Integration\ 20% - Integration Tests (medium speed/cost)
/____________\
/ \
/ Unit Tests \ 70% - Unit Tests (fast, cheap, focused)
/__________________\
Rationale:
- 70% Unit Tests: Fast, isolated, catch bugs early
- 20% Integration Tests: Test component interactions
- 10% E2E Tests: Test critical user journeys
Why This Distribution?
Unit tests are cheap:
- Run in milliseconds
- No external dependencies
- Easy to debug
- High code coverage per test
Integration tests are moderate:
- Test real interactions
- Catch integration bugs
- Slower than unit tests
- More complex setup
E2E tests are expensive:
- Test entire system
- Catch UX issues
- Very slow (seconds/minutes)
- Brittle and hard to maintain
TDD (Test-Driven Development)
Red-Green-Refactor Cycle
1. Red - Write a failing test:
describe('Calculator', () => {
test('adds two numbers', () => {
const calculator = new Calculator();
expect(calculator.add(2, 3)).toBe(5); // FAILS - method doesn't exist
});
});
2. Green - Write minimal code to pass:
class Calculator {
add(a: number, b: number): number {
return a + b; // Simplest implementation
}
}
// Test now PASSES
3. Refactor - Improve the code:
class Calculator {
add(a: number, b: number): number {
// Add validation
if (!Number.isFinite(a) || !Number.isFinite(b)) {
throw new Error('Arguments must be finite numbers');
}
return a + b;
}
}
TDD Benefits
Design benefits:
- Forces you to think about API before implementation
- Leads to more testable, modular code
- Encourages SOLID principles
Quality benefits:
- 100% test coverage by design
- Catches bugs immediately
- Provides living documentation
Workflow benefits:
- Clear next step (make test pass)
- Confidence when refactoring
- Prevents over-engineering
Arrange-Act-Assert Pattern
The AAA Pattern
Every test should follow this structure:
test('user registration creates account and sends welcome email', async () => {
// ARRANGE - Set up test conditions
const userData = {
email: 'test@example.com',
password: 'SecurePass123',
name: 'Test User',
};
const mockEmailService = jest.fn();
const userService = new UserService(mockEmailService);
// ACT - Execute the behavior being tested
const result = await userService.register(userData);
// ASSERT - Verify the outcome
expect(result.id).toBeDefined();
expect(result.email).toBe(userData.email);
expect(mockEmailService).toHaveBeenCalledWith({
to: userData.email,
subject: 'Welcome!',
template: 'welcome',
});
});
Why AAA?
- Clear structure: Easy to understand what's being tested
- Consistent: All tests follow same pattern
- Maintainable: Easy to modify and debug
Mocking Strategies
When to Mock
✅ DO mock:
- External APIs
- Databases
- File system operations
- Time/dates
- Random number generators
- Network requests
- Third-party services
// Mock external API
jest.mock('axios');
test('fetches user data from API', async () => {
const mockData = { id: 1, name: 'John' };
(axios.get as jest.Mock).mockResolvedValue({ data: mockData });
const user = await fetchUser(1);
expect(user).toEqual(mockData);
});
When NOT to Mock
❌ DON'T mock:
- Pure functions (test them directly)
- Simple utility functions
- Domain logic
- Value objects
- Internal implementation details
// ❌ BAD - Over-mocking
test('validates email', () => {
const validator = new EmailValidator();
jest.spyOn(validator, 'isValid').mockReturnValue(true);
expect(validator.isValid('test@example.com')).toBe(true);
// This test is useless - you're testing the mock, not the code
});
// ✅ GOOD - Test real implementation
test('validates email', () => {
const validator = new EmailValidator();
expect(validator.isValid('test@example.com')).toBe(true);
expect(validator.isValid('invalid')).toBe(false);
});
Mocking Patterns
Stub (return predetermined values):
const mockDatabase = {
findUser: jest.fn().mockResolvedValue({ id: 1, name: 'John' }),
saveUser: jest.fn().mockResolvedValue(true),
};
Spy (track calls, use real implementation):
const emailService = new EmailService();
const sendSpy = jest.spyOn(emailService, 'send');
await emailService.send('test@example.com', 'Hello');
expect(sendSpy).toHaveBeenCalledTimes(1);
expect(sendSpy).toHaveBeenCalledWith('test@example.com', 'Hello');
Fake (lightweight implementation):
class FakeDatabase {
private data = new Map();
async save(key: string, value: any) {
this.data.set(key, value);
}
async get(key: string) {
return this.data.get(key);
}
}
Test Coverage Goals
Coverage Metrics
Line Coverage: Percentage of code lines executed
- Target: 80-90% for critical paths
Branch Coverage: Percentage of if/else branches tested
- Target: 80%+ (more important than line coverage)
Function Coverage: Percentage of functions called
- Target: 90%+
Statement Coverage: Percentage of statements executed
- Target: 80%+
Coverage Configuration
// package.json
{
"jest": {
"collectCoverage": true,
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 90,
"lines": 80,
"statements": 80
},
"./src/critical/": {
"branches": 95,
"functions": 95,
"lines": 95,
"statements": 95
}
},
"coveragePathIgnorePatterns": [
"/node_modules/",
"/tests/",
"/migrations/",
"/.config.ts$/"
]
}
}
What to Prioritize
High priority (aim for 95%+ coverage):
- Business logic
- Security-critical code
- Payment/billing code
- Data validation
- Authentication/authorization
Medium priority (aim for 80%+ coverage):
- API endpoints
- Database queries
- Utility functions
- Error handling
Low priority (optional coverage):
- UI components (use integration tests instead)
- Configuration files
- Type definitions
- Third-party library wrappers
Integration Testing
Database Integration Tests
import { PrismaClient } from '@prisma/client';
describe('UserRepository', () => {
let prisma: PrismaClient;
let repository: UserRepository;
beforeAll(async () => {
// Use test database
prisma = new PrismaClient({
datasources: { db: { url: process.env.TEST_DATABASE_URL } },
});
repository = new UserRepository(prisma);
});
beforeEach(async () => {
// Clean database before each test
await prisma.user.deleteMany();
});
afterAll(async () => {
await prisma.$disconnect();
});
test('creates user and retrieves by email', async () => {
// ARRANGE
const userData = {
email: 'test@example.com',
name: 'Test User',
password: 'hashed_password',
};
// ACT
const created = await repository.create(userData);
const retrieved = await repository.findByEmail(userData.email);
// ASSERT
expect(retrieved).toBeDefined();
expect(retrieved?.id).toBe(created.id);
expect(retrieved?.email).toBe(userData.email);
});
});
API Integration Tests
import request from 'supertest';
import { app } from '../src/app';
describe('User API', () => {
test('POST /api/users creates user and returns 201', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: 'test@example.com',
password: 'SecurePass123',
name: 'Test User',
})
.expect(201);
expect(response.body).toMatchObject({
email: 'test@example.com',
name: 'Test User',
});
expect(response.body.password).toBeUndefined(); // Never return password
});
test('POST /api/users returns 400 for invalid email', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: 'invalid-email',
password: 'SecurePass123',
name: 'Test User',
})
.expect(400);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
});
});
Service Integration Tests
describe('OrderService Integration', () => {
test('complete order flow', async () => {
// Create order
const order = await orderService.create({
userId: 'user_123',
items: [{ productId: 'prod_1', quantity: 2 }],
});
// Process payment
const payment = await paymentService.process({
orderId: order.id,
amount: order.total,
});
// Verify inventory updated
const product = await inventoryService.getProduct('prod_1');
expect(product.stock).toBe(originalStock - 2);
// Verify order status updated
const updatedOrder = await orderService.getById(order.id);
expect(updatedOrder.status).toBe('paid');
});
});
E2E Testing
Playwright Setup
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'webkit', use: { browserName: 'webkit' } },
],
});
E2E Test Example
import { test, expect } from '@playwright/test';
test.describe('User Registration Flow', () => {
test('user can register and login', async ({ page }) => {
// Navigate to registration page
await page.goto('/register');
// Fill registration form
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'SecurePass123');
await page.fill('[name="confirmPassword"]', 'SecurePass123');
await page.fill('[name="name"]', 'Test User');
// Submit form
await page.click('button[type="submit"]');
// Wait for redirect to dashboard
await page.waitForURL('/dashboard');
// Verify welcome message
await expect(page.locator('h1')).toContainText('Welcome, Test User');
});
test('shows validation errors for invalid input', async ({ page }) => {
await page.goto('/register');
await page.fill('[name="email"]', 'invalid-email');
await page.fill('[name="password"]', '123'); // Too short
await page.click('button[type="submit"]');
// Verify error messages displayed
await expect(page.locator('[data-testid="email-error"]'))
.toContainText('Invalid email');
await expect(page.locator('[data-testid="password-error"]'))
.toContainText('at least 8 characters');
});
});
Critical E2E Scenarios
Test these critical user journeys:
- User registration and login
- Checkout and payment flow
- Password reset
- Profile updates
- Critical business workflows
Performance Testing
Load Testing with k6
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 20 }, // Ramp up to 20 users
{ duration: '1m', target: 20 }, // Stay at 20 users
{ duration: '30s', target: 100 }, // Ramp up to 100 users
{ duration: '1m', target: 100 }, // Stay at 100 users
{ duration: '30s', target: 0 }, // Ramp down to 0 users
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% of requests under 500ms
http_req_failed: ['rate<0.01'], // Less than 1% error rate
},
};
export default function() {
const response = http.get('https://api.example.com/users');
check(response, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
});
sleep(1);
}
Benchmark Testing
import { performance } from 'perf_hooks';
describe('Performance Benchmarks', () => {
test('database query completes in under 100ms', async () => {
const start = performance.now();
await database.query('SELECT * FROM users WHERE email = ?', ['test@example.com']);
const duration = performance.now() - start;
expect(duration).toBeLessThan(100);
});
test('API endpoint responds in under 200ms', async () => {
const start = performance.now();
await request(app).get('/api/users/123');
const duration = performance.now() - start;
expect(duration).toBeLessThan(200);
});
});
Flaky Test Prevention
Common Causes of Flaky Tests
1. Race Conditions:
// ❌ BAD - Race condition
test('displays data', async () => {
fetchData();
expect(screen.getByText('Data loaded')).toBeInTheDocument();
// Fails intermittently if fetchData takes longer than expected
});
// ✅ GOOD - Wait for async operation
test('displays data', async () => {
fetchData();
await screen.findByText('Data loaded'); // Waits up to 1 second
});
2. Time Dependencies:
// ❌ BAD - Depends on current time
test('shows message for new users', () => {
const user = { createdAt: new Date() };
expect(isNewUser(user)).toBe(true);
// Fails if test runs slowly
});
// ✅ GOOD - Mock time
test('shows message for new users', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2025-10-16'));
const user = { createdAt: new Date('2025-10-15') };
expect(isNewUser(user)).toBe(true);
jest.useRealTimers();
});
3. Shared State:
// ❌ BAD - Tests share state
let counter = 0;
test('increments counter', () => {
counter++;
expect(counter).toBe(1);
});
test('increments counter again', () => {
counter++;
expect(counter).toBe(1); // Fails if first test ran
});
// ✅ GOOD - Isolated state
test('increments counter', () => {
const counter = new Counter();
counter.increment();
expect(counter.value).toBe(1);
});
Flaky Test Best Practices
- Always clean up after tests:
afterEach(async () => {
await database.truncate();
jest.clearAllMocks();
jest.useRealTimers();
});
- Use explicit waits, not delays:
// ❌ BAD
await sleep(1000);
// ✅ GOOD
await waitFor(() => expect(element).toBeInTheDocument());
- Isolate test data:
test('creates user', async () => {
const uniqueEmail = `test-${Date.now()}@example.com`;
const user = await createUser({ email: uniqueEmail });
expect(user.email).toBe(uniqueEmail);
});
Test Data Management
Test Fixtures
// fixtures/users.ts
export const testUsers = {
admin: {
email: 'admin@example.com',
password: 'AdminPass123',
role: 'admin',
},
regular: {
email: 'user@example.com',
password: 'UserPass123',
role: 'user',
},
};
// Usage in tests
import { testUsers } from './fixtures/users';
test('admin can delete users', async () => {
const admin = await createUser(testUsers.admin);
// Test admin functionality
});
Factory Pattern
class UserFactory {
static create(overrides = {}) {
return {
id: faker.datatype.uuid(),
email: faker.internet.email(),
name: faker.name.fullName(),
createdAt: new Date(),
...overrides,
};
}
static createMany(count: number, overrides = {}) {
return Array.from({ length: count }, () => this.create(overrides));
}
}
// Usage
test('displays user list', () => {
const users = UserFactory.createMany(5);
render(<UserList users={users} />);
expect(screen.getAllByRole('listitem')).toHaveLength(5);
});
Database Seeding
// seeds/test-seed.ts
export async function seedTestDatabase() {
// Create admin user
const admin = await prisma.user.create({
data: { email: 'admin@test.com', role: 'admin' },
});
// Create test products
const products = await Promise.all([
prisma.product.create({ data: { name: 'Product 1', price: 10 } }),
prisma.product.create({ data: { name: 'Product 2', price: 20 } }),
]);
return { admin, products };
}
// Usage
beforeEach(async () => {
await prisma.$executeRaw`TRUNCATE TABLE users CASCADE`;
const { admin, products } = await seedTestDatabase();
});
CI/CD Integration
GitHub Actions Configuration
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run type check
run: npm run type-check
- name: Run unit tests
run: npm run test:unit
- name: Run integration tests
run: npm run test:integration
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
- name: Run E2E tests
run: npm run test:e2e
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.json
fail_ci_if_error: true
Test Scripts Organization
// package.json
{
"scripts": {
"test": "npm run test:unit && npm run test:integration && npm run test:e2e",
"test:unit": "jest --testPathPattern=\\.test\\.ts$",
"test:integration": "jest --testPathPattern=\\.integration\\.ts$",
"test:e2e": "playwright test",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --ci --coverage --maxWorkers=2"
}
}
Test Performance in CI
Parallel execution:
jobs:
test:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npm test -- --shard=${{ matrix.shard }}/4
Cache dependencies:
- uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
Test Organization
File Structure
tests/
├── unit/ # Fast, isolated tests
│ ├── services/
│ │ ├── user-service.test.ts
│ │ └── order-service.test.ts
│ └── utils/
│ ├── validator.test.ts
│ └── formatter.test.ts
├── integration/ # Database, API tests
│ ├── api/
│ │ ├── users.integration.ts
│ │ └── orders.integration.ts
│ └── database/
│ └── repository.integration.ts
├── e2e/ # End-to-end tests
│ ├── auth.spec.ts
│ ├── checkout.spec.ts
│ └── profile.spec.ts
├── fixtures/ # Test data
│ ├── users.ts
│ └── products.ts
└── helpers/ # Test utilities
├── setup.ts
└── factories.ts
Test Naming Conventions
// Pattern: describe('Component/Function', () => test('should...when...'))
describe('UserService', () => {
describe('register', () => {
test('should create user when valid data provided', async () => {
// Test implementation
});
test('should throw error when email already exists', async () => {
// Test implementation
});
test('should hash password before saving', async () => {
// Test implementation
});
});
describe('login', () => {
test('should return token when credentials are valid', async () => {
// Test implementation
});
test('should throw error when password is incorrect', async () => {
// Test implementation
});
});
});
When to Use This Skill
Use this skill when:
- Setting up testing infrastructure
- Writing unit, integration, or E2E tests
- Implementing TDD methodology
- Reviewing test coverage
- Debugging flaky tests
- Optimizing test performance
- Configuring CI/CD pipelines
- Establishing testing standards
- Training team on testing practices
- Improving code quality through testing
Remember: Good tests give you confidence to refactor, catch bugs early, and serve as living documentation. Invest in your test suite and it will pay dividends throughout the project lifecycle.