Testing Strategies for Modern Applications
Testing is essential for building reliable software. Here's how to implement a comprehensive testing strategy.
Testing Pyramid
/\
/ \ E2E Tests (Few)
/----\ Integration Tests (Some)
/------\ Unit Tests (Many)
/--------\
Unit Testing
Setup with Vitest
npm install -D vitest @testing-library/react @testing-library/jest-domvitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
},
});Basic Unit Test
// utils/math.ts
export function add(a: number, b: number): number {
return a + b;
}
export function divide(a: number, b: number): number {
if (b === 0) throw new Error('Cannot divide by zero');
return a / b;
}
// utils/math.test.ts
import { describe, it, expect } from 'vitest';
import { add, divide } from './math';
describe('Math utils', () => {
describe('add', () => {
it('adds two positive numbers', () => {
expect(add(2, 3)).toBe(5);
});
it('handles negative numbers', () => {
expect(add(-1, 1)).toBe(0);
});
});
describe('divide', () => {
it('divides two numbers', () => {
expect(divide(10, 2)).toBe(5);
});
it('throws error when dividing by zero', () => {
expect(() => divide(10, 0)).toThrow('Cannot divide by zero');
});
});
});Testing React Components
// Button.tsx
interface ButtonProps {
onClick: () => void;
children: React.ReactNode;
disabled?: boolean;
}
export function Button({ onClick, children, disabled }: ButtonProps) {
return (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
);
}
// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';
describe('Button', () => {
it('renders children', () => {
render(<Button onClick={() => {}}>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('calls onClick when clicked', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('is disabled when disabled prop is true', () => {
render(<Button onClick={() => {}} disabled>Click me</Button>);
expect(screen.getByText('Click me')).toBeDisabled();
});
});Mocking
// userService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fetchUser } from './userService';
// Mock fetch globally
global.fetch = vi.fn();
describe('fetchUser', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns user data on success', async () => {
const mockUser = { id: '1', name: 'John' };
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
const result = await fetchUser('1');
expect(result).toEqual(mockUser);
expect(fetch).toHaveBeenCalledWith('/api/users/1');
});
it('throws error on failure', async () => {
(fetch as any).mockResolvedValueOnce({
ok: false,
status: 404,
});
await expect(fetchUser('999')).rejects.toThrow('User not found');
});
});Integration Testing
Testing API Routes
// api/users.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createServer } from '../server';
let server: any;
beforeAll(async () => {
server = await createServer();
});
afterAll(async () => {
await server.close();
});
describe('Users API', () => {
it('GET /users returns list of users', async () => {
const response = await fetch('http://localhost:3000/api/users');
const data = await response.json();
expect(response.status).toBe(200);
expect(Array.isArray(data)).toBe(true);
});
it('POST /users creates a new user', async () => {
const newUser = { name: 'John', email: 'john@test.com' };
const response = await fetch('http://localhost:3000/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newUser),
});
expect(response.status).toBe(201);
const data = await response.json();
expect(data.name).toBe(newUser.name);
});
});End-to-End Testing
Setup Playwright
npm install -D @playwright/test
npx playwright installplaywright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: 'http://localhost:3000',
screenshot: 'only-on-failure',
},
});E2E Test Example
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Authentication', () => {
test('user can login', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', 'user@test.com');
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('h1')).toContainText('Welcome');
});
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', 'invalid@test.com');
await page.fill('[name="password"]', 'wrong');
await page.click('button[type="submit"]');
await expect(page.locator('.error')).toContainText('Invalid credentials');
});
});Test Scripts
package.json
{
"scripts": {
"test": "vitest",
"test:watch": "vitest watch",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}
}Best Practices
- Test behavior, not implementation - Focus on what code does
- Keep tests isolated - No shared state between tests
- Use descriptive names - Tests as documentation
- Follow AAA pattern - Arrange, Act, Assert
- Mock external dependencies - APIs, databases
- Aim for good coverage - 80%+ is a good target
- Run tests in CI - Catch issues early
- Test edge cases - Empty arrays, null values
Good tests give you confidence to refactor and deploy without fear!