Skip to content

Frontend Testing (React/TypeScript)

Stack Overview

Tool Purpose
Jest Test runner for unit/integration tests
Testing Library Component testing utilities
userEvent Realistic user interaction simulation
Playwright End-to-end browser testing
MSW API mocking (optional)

Test Organization

frontend/
├── tests/
│   ├── __mocks__/           # Module mocks (next-intl, etc.)
│   │   ├── next-intl.tsx
│   │   └── next-intl-navigation.tsx
│   ├── components/
│   │   ├── LoginForm.test.tsx
│   │   ├── AuthGuard.test.tsx
│   │   └── FormField.test.tsx
│   ├── hooks/
│   │   ├── useAuth.test.tsx
│   │   └── usePrefersReducedMotion.test.ts
│   ├── pages/
│   │   └── page.test.tsx
│   └── utils/
│       └── storage.test.ts
├── e2e/
│   ├── helpers/
│   │   ├── auth.ts          # Login helpers, mock data
│   │   └── coverage.ts      # Coverage collection
│   ├── auth.setup.ts        # Auth state caching
│   ├── auth-login.spec.ts
│   └── settings-*.spec.ts
├── jest.config.js
├── jest.setup.js
└── playwright.config.ts

Jest Configuration

// jest.config.js
module.exports = {
  testEnvironment: './jest.environment.js',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  testMatch: ['<rootDir>/tests/**/*.test.ts', '<rootDir>/tests/**/*.test.tsx'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '^next-intl$': '<rootDir>/tests/__mocks__/next-intl.tsx',
    '^next-intl/navigation$': '<rootDir>/tests/__mocks__/next-intl-navigation.tsx',
  },
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/lib/api/generated/**',  // Generated code
    '!src/components/ui/**',       // shadcn/ui components
    '!src/**/*.d.ts',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

Jest Setup

// jest.setup.js
import '@testing-library/jest-dom';

// Mock browser APIs not available in jsdom
global.IntersectionObserver = jest.fn().mockImplementation(() => ({
  observe: jest.fn(),
  unobserve: jest.fn(),
  disconnect: jest.fn(),
}));

global.BroadcastChannel = jest.fn().mockImplementation(() => ({
  postMessage: jest.fn(),
  close: jest.fn(),
  addEventListener: jest.fn(),
  removeEventListener: jest.fn(),
}));

// Reset mocks between tests
beforeEach(() => {
  jest.clearAllMocks();
  localStorage.clear();
  sessionStorage.clear();
});

Component Testing Patterns

Basic Component Test

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from '@/components/auth/LoginForm';

// Wrapper with required providers
const createWrapper = () => {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false },
    },
  });

  return ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
};

describe('LoginForm', () => {
  it('renders email and password fields', () => {
    render(<LoginForm />, { wrapper: createWrapper() });

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

Testing User Interactions

Always use userEvent over fireEvent — it simulates real browser behavior:

import userEvent from '@testing-library/user-event';

it('submits form with entered credentials', async () => {
  const user = userEvent.setup();
  const mockSubmit = jest.fn();

  render(<LoginForm onSubmit={mockSubmit} />, { wrapper: createWrapper() });

  // Type in fields
  await user.type(screen.getByLabelText(/email/i), 'user@example.com');
  await user.type(screen.getByLabelText(/password/i), 'Password123!');

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

  expect(mockSubmit).toHaveBeenCalledWith({
    email: 'user@example.com',
    password: 'Password123!',
  });
});

Testing Form Validation

it('shows validation errors for empty fields', async () => {
  const user = userEvent.setup();

  render(<LoginForm />, { wrapper: createWrapper() });

  // Submit without filling fields
  await user.click(screen.getByRole('button', { name: /sign in/i }));

  // Wait for validation errors
  await waitFor(() => {
    expect(screen.getAllByText(/required/i).length).toBeGreaterThanOrEqual(2);
  });
});

it('shows error for invalid email format', async () => {
  const user = userEvent.setup();

  render(<LoginForm />, { wrapper: createWrapper() });

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

  await waitFor(() => {
    expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
  });
});

Testing API Errors

it('displays API error message on login failure', async () => {
  const user = userEvent.setup();

  // Mock the login mutation to fail
  mockMutateAsync.mockRejectedValueOnce([
    { code: 'INVALID_CREDENTIALS', message: 'Invalid email or password' },
  ]);

  render(<LoginForm />, { wrapper: createWrapper() });

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

  await waitFor(() => {
    expect(screen.getByText(/invalid email or password/i)).toBeInTheDocument();
  });
});

Testing Loading States

it('disables submit button while submitting', async () => {
  const user = userEvent.setup();

  // Make mutation hang
  mockMutateAsync.mockImplementation(() => new Promise(() => {}));

  render(<LoginForm />, { wrapper: createWrapper() });

  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 }));

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

Hook Testing

Use renderHook for testing hooks in isolation:

import { renderHook, act } from '@testing-library/react';
import { useAuth } from '@/hooks/useAuth';

describe('useAuth', () => {
  it('returns false when not authenticated', () => {
    mockAuthState = { isAuthenticated: false, user: null };

    const { result } = renderHook(() => useAuth(), {
      wrapper: createWrapper(),
    });

    expect(result.current.isAuthenticated).toBe(false);
    expect(result.current.user).toBeNull();
  });

  it('returns user when authenticated', () => {
    mockAuthState = {
      isAuthenticated: true,
      user: { id: '1', email: 'user@example.com' },
    };

    const { result } = renderHook(() => useAuth(), {
      wrapper: createWrapper(),
    });

    expect(result.current.isAuthenticated).toBe(true);
    expect(result.current.user?.email).toBe('user@example.com');
  });
});

Testing Hooks with State Changes

import { renderHook, act } from '@testing-library/react';
import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion';

it('updates when system preference changes', () => {
  const listeners: Function[] = [];
  const mockMediaQueryList = {
    matches: false,
    addEventListener: jest.fn((_, handler) => listeners.push(handler)),
    removeEventListener: jest.fn(),
  };

  window.matchMedia = jest.fn().mockReturnValue(mockMediaQueryList);

  const { result } = renderHook(() => usePrefersReducedMotion());

  expect(result.current).toBe(false);

  // Simulate preference change
  act(() => {
    mockMediaQueryList.matches = true;
    listeners.forEach((listener) => listener({ matches: true }));
  });

  expect(result.current).toBe(true);
});

it('cleans up listener on unmount', () => {
  const { unmount } = renderHook(() => usePrefersReducedMotion());

  unmount();

  expect(mockRemoveEventListener).toHaveBeenCalledWith('change', expect.any(Function));
});

Mocking Patterns

Module Mocks

For libraries that don't work in jsdom, create mock files:

// tests/__mocks__/next-intl.tsx
export const useTranslations = (namespace: string) => {
  return (key: string) => key;  // Return the key as-is
};

export const useLocale = () => 'en';

export const NextIntlClientProvider = ({ children }: { children: React.ReactNode }) => (
  <>{children}</>
);

Inline Mocks

// Mock a specific module for one test file
jest.mock('@/lib/api/client', () => ({
  apiClient: {
    get: jest.fn(),
    post: jest.fn(),
  },
}));

Mocking Heavy Libraries

For animation libraries, syntax highlighters, etc.:

jest.mock('framer-motion', () => ({
  motion: {
    div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
    h1: ({ children, ...props }: any) => <h1 {...props}>{children}</h1>,
  },
  AnimatePresence: ({ children }: any) => <>{children}</>,
  useInView: () => true,
}));

jest.mock('next/image', () => ({
  __esModule: true,
  default: (props: any) => <img {...props} />,
}));

Mocking Navigation

// tests/__mocks__/next-intl-navigation.tsx
export const mockPush = jest.fn();
export const mockReplace = jest.fn();

export const createNavigation = () => ({
  Link: ({ children, href }: any) => <a href={href}>{children}</a>,
  useRouter: () => ({
    push: mockPush,
    replace: mockReplace,
    back: jest.fn(),
    forward: jest.fn(),
  }),
  usePathname: () => '/',
  redirect: jest.fn(),
});

Accessibility Testing

Query by role, not test-id:

// Good - queries by accessible role
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { level: 1 });

// Avoid - queries by implementation detail
screen.getByTestId('submit-button');

Testing ARIA Attributes

it('sets aria-invalid when field has error', () => {
  render(
    <FormField
      label="Email"
      name="email"
      error={{ type: 'required', message: 'Email is required' }}
    />
  );

  expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true');
});

it('links error message to input via aria-describedby', () => {
  render(
    <FormField
      label="Email"
      name="email"
      error={{ type: 'required', message: 'Email is required' }}
    />
  );

  const input = screen.getByRole('textbox');
  expect(input).toHaveAttribute('aria-describedby', expect.stringContaining('email-error'));
});

it('announces error with role="alert"', () => {
  render(
    <FormField
      label="Email"
      name="email"
      error={{ type: 'required', message: 'Email is required' }}
    />
  );

  expect(screen.getByRole('alert')).toHaveTextContent('Email is required');
});

E2E Testing with Playwright

Configuration

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

export default defineConfig({
  testDir: './e2e',
  timeout: 30000,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : 16,

  projects: [
    // Auth setup - runs first, saves login state
    { name: 'setup', testMatch: /auth\.setup\.ts/ },

    // Tests requiring admin login
    {
      name: 'admin tests',
      testMatch: /admin-.*\.spec\.ts/,
      dependencies: ['setup'],
      use: { storageState: '.auth/admin.json' },
    },

    // Tests requiring regular user login
    {
      name: 'user tests',
      testMatch: /settings-.*\.spec\.ts/,
      dependencies: ['setup'],
      use: { storageState: '.auth/user.json' },
    },

    // Public pages - no auth needed
    {
      name: 'public tests',
      testMatch: /(homepage|auth-).*\.spec\.ts/,
    },
  ],

  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
});

Auth State Caching

Save login state once, reuse across tests:

// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

const ADMIN_STORAGE_STATE = '.auth/admin.json';
const USER_STORAGE_STATE = '.auth/user.json';

setup('authenticate as admin', async ({ page }) => {
  await page.goto('/login');
  await page.fill('input[name="email"]', 'admin@example.com');
  await page.fill('input[name="password"]', 'AdminPassword123!');
  await page.click('button[type="submit"]');

  // Wait for redirect to dashboard
  await expect(page).toHaveURL('/dashboard');

  // Save signed-in state
  await page.context().storageState({ path: ADMIN_STORAGE_STATE });
});

setup('authenticate as regular user', async ({ page }) => {
  await page.goto('/login');
  await page.fill('input[name="email"]', 'user@example.com');
  await page.fill('input[name="password"]', 'UserPassword123!');
  await page.click('button[type="submit"]');

  await expect(page).toHaveURL('/dashboard');
  await page.context().storageState({ path: USER_STORAGE_STATE });
});

E2E Test Example

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

test.describe('Login Flow', () => {
  // Clear state before each test in this describe block
  test.beforeEach(async ({ page, context }) => {
    await context.clearCookies();
    await page.goto('/login');
  });

  test('displays login form', async ({ page }) => {
    await expect(page.locator('h2')).toContainText('Sign in');
    await expect(page.locator('input[name="email"]')).toBeVisible();
    await expect(page.locator('input[name="password"]')).toBeVisible();
  });

  test('shows validation errors for empty form', async ({ page }) => {
    await page.click('button[type="submit"]');

    await expect(page.locator('#email-error')).toBeVisible();
    await expect(page.locator('#password-error')).toBeVisible();
  });

  test('redirects to dashboard on successful login', async ({ page }) => {
    await page.fill('input[name="email"]', 'user@example.com');
    await page.fill('input[name="password"]', 'UserPassword123!');
    await page.click('button[type="submit"]');

    await expect(page).toHaveURL('/dashboard');
  });

  test('shows error for invalid credentials', async ({ page }) => {
    await page.fill('input[name="email"]', 'user@example.com');
    await page.fill('input[name="password"]', 'wrong-password');
    await page.click('button[type="submit"]');

    await expect(page.locator('[role="alert"]')).toContainText(/invalid/i);
  });
});

Debugging Failed E2E Tests

Capture artifacts on failure:

test.afterEach(async ({ page }, testInfo) => {
  if (testInfo.status !== 'passed') {
    // Attach screenshot
    const screenshot = await page.screenshot({ fullPage: true });
    await testInfo.attach('screenshot', {
      body: screenshot,
      contentType: 'image/png',
    });

    // Attach HTML
    const html = await page.content();
    await testInfo.attach('dom.html', {
      body: html,
      contentType: 'text/html',
    });

    // Attach console logs
    await testInfo.attach('url', {
      body: page.url(),
      contentType: 'text/plain',
    });
  }
});

Running Tests

# Jest (unit/integration)
bun test                        # Run all tests
bun run test:watch              # Watch mode
bun run test:coverage           # With coverage report

# Playwright (E2E)
bun run test:e2e                # Run all E2E tests
bun run test:e2e -- --ui        # Interactive UI mode
bun run test:e2e -- --debug     # Debug mode (step through)
bun run test:e2e -- --headed    # See the browser

# Run specific test file
bun test -- LoginForm.test.tsx
npx playwright test auth-login.spec.ts

Checklist

Before merging:

  • All tests pass (bun test and bun run test:e2e)
  • New components have tests for rendering, interactions, and edge cases
  • Forms tested: validation, submission, error handling
  • Accessibility: query by role, test ARIA attributes
  • No getByTestId unless absolutely necessary
  • Heavy libraries mocked (framer-motion, syntax highlighters)
  • Async code uses waitFor, not arbitrary delays
  • E2E tests use auth caching (not logging in per test)