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 testandbun 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
getByTestIdunless 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)