Testing Strategies for Modern Applications

May 12, 2024 (1y ago)

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-dom

vitest.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 install

playwright.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

  1. Test behavior, not implementation - Focus on what code does
  2. Keep tests isolated - No shared state between tests
  3. Use descriptive names - Tests as documentation
  4. Follow AAA pattern - Arrange, Act, Assert
  5. Mock external dependencies - APIs, databases
  6. Aim for good coverage - 80%+ is a good target
  7. Run tests in CI - Catch issues early
  8. Test edge cases - Empty arrays, null values

Good tests give you confidence to refactor and deploy without fear!