A balanced testing strategy catches bugs efficiently. Here's how to structure your tests at each level.
Testing Pyramid#
/\
/ \ E2E Tests (few)
/----\ - Critical user journeys
/ \ - Slow, expensive
/--------\
/ \ Integration Tests (some)
/ \ - Component interactions
/--------------\- API endpoints
/ \
/------------------\ Unit Tests (many)
- Fast, isolated
- Business logic
Unit Tests#
1// Test isolated units of code
2import { describe, it, expect } from 'vitest';
3import { calculateDiscount, formatPrice } from './pricing';
4
5describe('calculateDiscount', () => {
6 it('applies percentage discount', () => {
7 expect(calculateDiscount(100, { type: 'percentage', value: 10 }))
8 .toBe(90);
9 });
10
11 it('applies fixed discount', () => {
12 expect(calculateDiscount(100, { type: 'fixed', value: 15 }))
13 .toBe(85);
14 });
15
16 it('does not go below zero', () => {
17 expect(calculateDiscount(10, { type: 'fixed', value: 15 }))
18 .toBe(0);
19 });
20
21 it('handles no discount', () => {
22 expect(calculateDiscount(100, null))
23 .toBe(100);
24 });
25});
26
27describe('formatPrice', () => {
28 it('formats USD correctly', () => {
29 expect(formatPrice(1234.56, 'USD')).toBe('$1,234.56');
30 });
31
32 it('formats EUR correctly', () => {
33 expect(formatPrice(1234.56, 'EUR')).toBe('€1,234.56');
34 });
35
36 it('handles zero', () => {
37 expect(formatPrice(0, 'USD')).toBe('$0.00');
38 });
39});1// Testing with mocks
2import { UserService } from './UserService';
3import { vi } from 'vitest';
4
5describe('UserService', () => {
6 const mockRepository = {
7 findById: vi.fn(),
8 save: vi.fn(),
9 };
10
11 const mockEmailService = {
12 sendWelcome: vi.fn(),
13 };
14
15 const service = new UserService(mockRepository, mockEmailService);
16
17 beforeEach(() => {
18 vi.clearAllMocks();
19 });
20
21 it('creates user and sends welcome email', async () => {
22 const userData = { email: 'test@example.com', name: 'Test' };
23 const savedUser = { id: '123', ...userData };
24
25 mockRepository.save.mockResolvedValue(savedUser);
26 mockEmailService.sendWelcome.mockResolvedValue(undefined);
27
28 const result = await service.createUser(userData);
29
30 expect(result).toEqual(savedUser);
31 expect(mockRepository.save).toHaveBeenCalledWith(userData);
32 expect(mockEmailService.sendWelcome).toHaveBeenCalledWith('test@example.com');
33 });
34
35 it('throws on invalid email', async () => {
36 const userData = { email: 'invalid', name: 'Test' };
37
38 await expect(service.createUser(userData))
39 .rejects.toThrow('Invalid email');
40
41 expect(mockRepository.save).not.toHaveBeenCalled();
42 });
43});Integration Tests#
1// Test component interactions
2import { describe, it, expect, beforeAll, afterAll } from 'vitest';
3import { createTestDatabase, cleanupDatabase } from './test-utils';
4import { UserRepository } from './UserRepository';
5import { prisma } from './db';
6
7describe('UserRepository', () => {
8 beforeAll(async () => {
9 await createTestDatabase();
10 });
11
12 afterAll(async () => {
13 await cleanupDatabase();
14 });
15
16 beforeEach(async () => {
17 await prisma.user.deleteMany();
18 });
19
20 const repository = new UserRepository(prisma);
21
22 it('creates and retrieves user', async () => {
23 const created = await repository.create({
24 email: 'test@example.com',
25 name: 'Test User',
26 });
27
28 const retrieved = await repository.findById(created.id);
29
30 expect(retrieved).toEqual(created);
31 });
32
33 it('returns null for non-existent user', async () => {
34 const result = await repository.findById('non-existent');
35 expect(result).toBeNull();
36 });
37
38 it('updates user', async () => {
39 const user = await repository.create({
40 email: 'test@example.com',
41 name: 'Original Name',
42 });
43
44 const updated = await repository.update(user.id, {
45 name: 'New Name',
46 });
47
48 expect(updated.name).toBe('New Name');
49 expect(updated.email).toBe('test@example.com');
50 });
51});1// API integration tests
2import { describe, it, expect } from 'vitest';
3import request from 'supertest';
4import { app } from './app';
5import { prisma } from './db';
6
7describe('POST /api/users', () => {
8 beforeEach(async () => {
9 await prisma.user.deleteMany();
10 });
11
12 it('creates a new user', async () => {
13 const response = await request(app)
14 .post('/api/users')
15 .send({
16 email: 'test@example.com',
17 name: 'Test User',
18 });
19
20 expect(response.status).toBe(201);
21 expect(response.body).toMatchObject({
22 email: 'test@example.com',
23 name: 'Test User',
24 });
25 expect(response.body.id).toBeDefined();
26 });
27
28 it('returns 400 for invalid email', async () => {
29 const response = await request(app)
30 .post('/api/users')
31 .send({
32 email: 'invalid',
33 name: 'Test',
34 });
35
36 expect(response.status).toBe(400);
37 expect(response.body.error).toContain('email');
38 });
39
40 it('returns 409 for duplicate email', async () => {
41 await prisma.user.create({
42 data: { email: 'test@example.com', name: 'Existing' },
43 });
44
45 const response = await request(app)
46 .post('/api/users')
47 .send({
48 email: 'test@example.com',
49 name: 'New User',
50 });
51
52 expect(response.status).toBe(409);
53 });
54});End-to-End Tests#
1// Playwright E2E tests
2import { test, expect } from '@playwright/test';
3
4test.describe('User Registration', () => {
5 test('completes registration flow', async ({ page }) => {
6 // Navigate to registration
7 await page.goto('/register');
8
9 // Fill form
10 await page.fill('[name="email"]', 'newuser@example.com');
11 await page.fill('[name="password"]', 'SecurePass123!');
12 await page.fill('[name="confirmPassword"]', 'SecurePass123!');
13
14 // Submit
15 await page.click('button[type="submit"]');
16
17 // Verify redirect to dashboard
18 await expect(page).toHaveURL('/dashboard');
19
20 // Verify welcome message
21 await expect(page.locator('h1')).toContainText('Welcome');
22 });
23
24 test('shows validation errors', async ({ page }) => {
25 await page.goto('/register');
26
27 // Submit empty form
28 await page.click('button[type="submit"]');
29
30 // Check for error messages
31 await expect(page.locator('.error-email'))
32 .toContainText('Email is required');
33 await expect(page.locator('.error-password'))
34 .toContainText('Password is required');
35 });
36});
37
38test.describe('Checkout Flow', () => {
39 test.beforeEach(async ({ page }) => {
40 // Login before each test
41 await page.goto('/login');
42 await page.fill('[name="email"]', 'test@example.com');
43 await page.fill('[name="password"]', 'password123');
44 await page.click('button[type="submit"]');
45 await page.waitForURL('/dashboard');
46 });
47
48 test('completes purchase', async ({ page }) => {
49 // Add item to cart
50 await page.goto('/products/1');
51 await page.click('button:has-text("Add to Cart")');
52
53 // Go to checkout
54 await page.click('a:has-text("Checkout")');
55
56 // Fill payment info
57 await page.fill('[name="cardNumber"]', '4242424242424242');
58 await page.fill('[name="expiry"]', '12/25');
59 await page.fill('[name="cvv"]', '123');
60
61 // Complete purchase
62 await page.click('button:has-text("Pay")');
63
64 // Verify success
65 await expect(page.locator('.success-message'))
66 .toContainText('Order confirmed');
67 });
68});Test Data Management#
1// Factory functions for test data
2import { faker } from '@faker-js/faker';
3
4export function createUser(overrides: Partial<User> = {}): User {
5 return {
6 id: faker.string.uuid(),
7 email: faker.internet.email(),
8 name: faker.person.fullName(),
9 createdAt: faker.date.past(),
10 ...overrides,
11 };
12}
13
14export function createOrder(overrides: Partial<Order> = {}): Order {
15 return {
16 id: faker.string.uuid(),
17 userId: faker.string.uuid(),
18 items: [
19 {
20 productId: faker.string.uuid(),
21 quantity: faker.number.int({ min: 1, max: 5 }),
22 price: parseFloat(faker.commerce.price()),
23 },
24 ],
25 total: parseFloat(faker.commerce.price({ min: 10, max: 500 })),
26 status: 'pending',
27 createdAt: faker.date.recent(),
28 ...overrides,
29 };
30}
31
32// Usage in tests
33it('processes order', async () => {
34 const user = createUser();
35 const order = createOrder({ userId: user.id, status: 'pending' });
36
37 const result = await processOrder(order);
38
39 expect(result.status).toBe('completed');
40});When to Use Each#
Unit Tests:
✓ Business logic
✓ Utility functions
✓ Data transformations
✓ Edge cases
✓ Fast feedback
Integration Tests:
✓ Database operations
✓ API endpoints
✓ Service interactions
✓ External API integrations
✓ Error handling across layers
E2E Tests:
✓ Critical user journeys
✓ Multi-page flows
✓ Authentication flows
✓ Payment processes
✓ Cross-browser testing
Test Organization#
src/
├── components/
│ ├── Button.tsx
│ └── Button.test.tsx # Unit tests
├── services/
│ ├── UserService.ts
│ └── UserService.test.ts
├── api/
│ ├── users.ts
│ └── __tests__/
│ └── users.integration.test.ts
tests/
├── e2e/
│ ├── auth.spec.ts
│ └── checkout.spec.ts
├── fixtures/
│ └── users.json
└── utils/
└── test-helpers.ts
Best Practices#
General:
✓ Test behavior, not implementation
✓ Keep tests independent
✓ Use descriptive names
✓ Follow AAA pattern (Arrange, Act, Assert)
Unit Tests:
✓ Fast execution
✓ No external dependencies
✓ Mock at boundaries
✓ High coverage of logic
Integration Tests:
✓ Use test database
✓ Clean up between tests
✓ Test real interactions
✓ Cover error paths
E2E Tests:
✓ Focus on critical paths
✓ Use stable selectors
✓ Handle async properly
✓ Run in CI/CD
Conclusion#
A balanced testing strategy uses many unit tests for fast feedback, integration tests for component interactions, and targeted E2E tests for critical flows. Each level catches different bugs—invest in the right mix for your application's needs.