---
name: web-testing
description: "Web testing: Vitest unit tests, React Testing Library, Playwright E2E, MSW API mocking, testing hooks and async operations. Use when writing or reviewing web tests with Vitest, Testing Library, Playwright or MSW."
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 |
