---
name: web-testing
description: "Web testing: Vitest unit tests, React Testing Library, Playwright E2E, MSW API mocking, testing hooks and async operations"
tags: [testing, vitest, playwright, react-testing-library, msw, e2e, frontend]
version: "2025.1"
---

# Web Testing

## Testing Pyramid

```
         [E2E Tests - Playwright]
           (slow, high confidence  -  few)
      [Integration Tests - RTL + MSW]
        (medium speed, realistic  -  moderate)
   [Unit Tests - Vitest]
     (fast, isolated  -  many)
```

## Vitest (Unit Testing)

```typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    include: ['**/*.{test,spec}.{ts,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov', 'html'],
      thresholds: { statements: 80, branches: 80, functions: 80, lines: 80 },
    },
  },
});
```

```typescript
// src/utils/format.test.ts
import { describe, it, expect } from 'vitest';
import { formatCurrency, formatDate, truncate } from './format';

describe('formatCurrency', () => {
  it('formats USD amounts correctly', () => {
    expect(formatCurrency(1234.5, 'USD')).toBe('$1,234.50');
  });

  it('handles zero', () => {
    expect(formatCurrency(0, 'USD')).toBe('$0.00');
  });

  it('handles negative amounts', () => {
    expect(formatCurrency(-99.99, 'USD')).toBe('-$99.99');
  });
});

describe('truncate', () => {
  it('returns original string when shorter than max', () => {
    expect(truncate('hello', 10)).toBe('hello');
  });

  it('truncates and adds ellipsis when longer than max', () => {
    expect(truncate('hello world', 8)).toBe('hello...');
  });

  it('handles empty string', () => {
    expect(truncate('', 5)).toBe('');
  });
});
```

### Testing Async Code

```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fetchUserProfile } from './api';

// Mock module
vi.mock('./api', () => ({
  fetchUserProfile: vi.fn(),
}));

describe('UserService', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('returns user data on success', async () => {
    const mockUser = { id: '1', name: 'John' };
    vi.mocked(fetchUserProfile).mockResolvedValue(mockUser);

    const result = await getUserProfile('1');
    expect(result).toEqual(mockUser);
    expect(fetchUserProfile).toHaveBeenCalledWith('1');
  });

  it('throws on network error', async () => {
    vi.mocked(fetchUserProfile).mockRejectedValue(new Error('Network error'));

    await expect(getUserProfile('1')).rejects.toThrow('Network error');
  });
});
```

## React Testing Library

```typescript
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

afterEach(() => {
  cleanup();
});
```

```tsx
// src/components/LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { LoginForm } from './LoginForm';

describe('LoginForm', () => {
  const user = userEvent.setup();

  it('renders email and password fields', () => {
    render(<LoginForm onSubmit={vi.fn()} />);

    expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
  });

  it('shows validation error for invalid email', async () => {
    render(<LoginForm onSubmit={vi.fn()} />);

    await user.type(screen.getByLabelText(/email/i), 'invalid');
    await user.click(screen.getByRole('button', { name: /sign in/i }));

    expect(await screen.findByText(/valid email/i)).toBeInTheDocument();
  });

  it('calls onSubmit with form data on valid submission', async () => {
    const handleSubmit = vi.fn();
    render(<LoginForm onSubmit={handleSubmit} />);

    await user.type(screen.getByLabelText(/email/i), 'user@example.com');
    await user.type(screen.getByLabelText(/password/i), 'password123');
    await user.click(screen.getByRole('button', { name: /sign in/i }));

    await waitFor(() => {
      expect(handleSubmit).toHaveBeenCalledWith({
        email: 'user@example.com',
        password: 'password123',
      });
    });
  });

  it('disables submit button while loading', () => {
    render(<LoginForm onSubmit={vi.fn()} isLoading />);

    expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled();
  });
});
```

### Testing Custom Hooks

```tsx
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('initializes with default value', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  it('initializes with provided value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  it('increments count', () => {
    const { result } = renderHook(() => useCounter());
    act(() => result.current.increment());
    expect(result.current.count).toBe(1);
  });

  it('resets count to initial value', () => {
    const { result } = renderHook(() => useCounter(5));
    act(() => result.current.increment());
    act(() => result.current.reset());
    expect(result.current.count).toBe(5);
  });
});
```

## MSW (Mock Service Worker)

```typescript
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: '1', name: 'Alice', email: 'alice@example.com' },
      { id: '2', name: 'Bob', email: 'bob@example.com' },
    ]);
  }),

  http.get('/api/users/:id', ({ params }) => {
    const { id } = params;
    if (id === '999') {
      return new HttpResponse(null, { status: 404 });
    }
    return HttpResponse.json({ id, name: 'Alice', email: 'alice@example.com' });
  }),

  http.post('/api/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: '3', ...body }, { status: 201 });
  }),

  http.delete('/api/users/:id', () => {
    return new HttpResponse(null, { status: 204 });
  }),
];

// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

// src/test/setup.ts
import { server } from '../mocks/server';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
```

```tsx
// Using MSW in tests with per-test overrides
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';

describe('UserList', () => {
  it('shows error state on API failure', async () => {
    // Override handler for this test only
    server.use(
      http.get('/api/users', () => {
        return new HttpResponse(null, { status: 500 });
      })
    );

    render(<UserList />);
    expect(await screen.findByText(/failed to load/i)).toBeInTheDocument();
  });
});
```

## Playwright (E2E Testing)

```typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'mobile', use: { ...devices['iPhone 14'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});
```

```typescript
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Authentication', () => {
  test('successful login redirects to dashboard', async ({ page }) => {
    await page.goto('/login');

    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('password123');
    await page.getByRole('button', { name: 'Sign in' }).click();

    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  });

  test('shows error for invalid credentials', async ({ page }) => {
    await page.goto('/login');

    await page.getByLabel('Email').fill('wrong@example.com');
    await page.getByLabel('Password').fill('wrongpass');
    await page.getByRole('button', { name: 'Sign in' }).click();

    await expect(page.getByText('Invalid credentials')).toBeVisible();
    await expect(page).toHaveURL('/login');
  });
});

// e2e/checkout.spec.ts
test('completes checkout flow', async ({ page }) => {
  await page.goto('/products');

  // Add item to cart
  await page.getByRole('button', { name: 'Add to cart' }).first().click();
  await expect(page.getByTestId('cart-count')).toHaveText('1');

  // Navigate to checkout
  await page.getByRole('link', { name: 'Cart' }).click();
  await page.getByRole('button', { name: 'Checkout' }).click();

  // Fill shipping info
  await page.getByLabel('Address').fill('123 Main St');
  await page.getByLabel('City').fill('New York');
  await page.getByRole('button', { name: 'Place order' }).click();

  // Verify confirmation
  await expect(page.getByText('Order confirmed')).toBeVisible();
});
```

## Do's

- Write unit tests for business logic and utility functions
- Use React Testing Library queries by role, label, and text (user-centric)
- Use MSW for API mocking in both tests and development
- Write E2E tests for critical user journeys (login, checkout, signup)
- Use `userEvent` over `fireEvent` for realistic user interaction simulation
- Set up CI to run tests on every pull request
- Use `screen.getByRole` as the primary query method
- Test error states, loading states, and empty states

## Don'ts

- Do not test implementation details (internal state, method calls)
- Do not use `container.querySelector`  -  use RTL queries instead
- Do not write E2E tests for everything; reserve for critical paths
- Do not use `waitFor` wrapping `getBy*` queries (they already throw)
- Do not skip flaky tests permanently  -  fix the root cause
- Do not mock everything; prefer integration over isolation
- Do not use `sleep` or fixed timeouts  -  use proper waiters
- Do not test third-party library internals

## Troubleshooting

| Problem | Cause | Solution |
|---------|-------|----------|
| "Unable to find element" | Element not rendered yet | Use `findByX` (async) instead of `getByX` |
| Test passes alone, fails in suite | Shared state between tests | Add `cleanup()` in `afterEach`, reset mocks |
| MSW not intercepting | Server not started or wrong handler | Check `server.listen()` in setup, verify URL pattern |
| Playwright timeout | Slow page load or wrong selector | Increase timeout, use more specific locators |
| Snapshot test breaks on every change | Testing too much visual detail | Narrow snapshot scope or use targeted assertions |
| "act() warning" | State update outside act | Wrap state updates in `act()` or use `findBy` queries |
