Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Frontend Testing

A well-structured testing strategy is essential for maintainable frontend applications. This guide covers the testing pyramid, tools, and patterns that come up in interviews.

The Testing Pyramid

        ╱  E2E  ╲           ← Few, slow, high confidence
       ╱──────────╲         ← Playwright, Cypress
      ╱  Integration ╲       ← Component testing
     ╱────────────────╲     ← React Testing Library
    ╱    Unit Tests     ╲   ← Many, fast, focused
   ╱──────────────────────╲ ← Vitest, Jest
  • Unit tests — test individual functions, hooks, or utilities in isolation
  • Component tests — test components with their UI and logic (but mocked dependencies)
  • E2E tests — test the entire application in a real browser from the user’s perspective

Unit Testing with Vitest / Jest

Vitest is the modern replacement for Jest — faster (native ESM, Vite-powered), and compatible with Jest’s API.

// utils/format.js
export function formatDate(date) {
  return new Intl.DateTimeFormat('en-US', {
    year: 'numeric', month: 'short', day: 'numeric'
  }).format(new Date(date));
}

export function truncate(str, maxLength = 50) {
  if (str.length <= maxLength) return str;
  return str.slice(0, maxLength - 3) + '...';
}

// utils/format.test.js
import { describe, it, expect } from 'vitest';
import { formatDate, truncate } from './format';

describe('formatDate', () => {
  it('formats a date string', () => {
    expect(formatDate('2025-01-15')).toBe('Jan 15, 2025');
  });

  it('returns "Invalid Date" for invalid input', () => {
    expect(formatDate('not-a-date')).toBe('Invalid Date');
  });
});

describe('truncate', () => {
  it('returns the string unchanged if shorter than maxLength', () => {
    expect(truncate('Hello', 10)).toBe('Hello');
  });

  it('truncates long strings and adds ellipsis', () => {
    expect(truncate('A very long string that exceeds the limit', 20))
      .toBe('A very long string ...');
  });

  it('uses default maxLength of 50', () => {
    const short = 'short';
    expect(truncate(short)).toBe(short);
  });
});

Testing Custom Hooks

import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

it('increments and decrements', () => {
  const { result } = renderHook(() => useCounter());

  expect(result.current.count).toBe(0);

  act(() => result.current.increment());
  expect(result.current.count).toBe(1);

  act(() => result.current.decrement());
  expect(result.current.count).toBe(0);
});

Component Testing with React Testing Library

React Testing Library (RTL) tests components the way users interact with them — by finding elements via accessible roles, labels, and text content, not by CSS selectors or component internals.

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';

// ❌ Bad — testing implementation details
test('calls onChange when input changes', () => {
  const onChange = vi.fn();
  render(<LoginForm onChange={onChange} />);
  fireEvent.change(screen.getByTestId('email-input'), {
    target: { value: 'john@example.com' }
  });
  expect(onChange).toHaveBeenCalled();
});

// ✅ Good — testing user behavior
test('submits the form with email and password', async () => {
  const onSubmit = vi.fn();
  render(<LoginForm onSubmit={onSubmit} />);

  // Find by accessible label
  await userEvent.type(screen.getByLabelText('Email'), 'john@example.com');
  await userEvent.type(screen.getByLabelText('Password'), 'secret123');
  await userEvent.click(screen.getByRole('button', { name: /sign in/i }));

  expect(onSubmit).toHaveBeenCalledWith({
    email: 'john@example.com',
    password: 'secret123',
  });
});

// ✅ Good — testing error state
test('shows error message for invalid email', async () => {
  render(<LoginForm />);

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

  expect(screen.getByText(/please enter a valid email/i)).toBeInTheDocument();
});

Key RTL query methods (ordered by preference):

QueryFinds byUse when…
getByRoleARIA roleAlways prefer — simulates real usage
getByLabelTextLabel textForm inputs
getByPlaceholderTextPlaceholderSecondary option for inputs
getByTextText contentHeadings, paragraphs, buttons
getByTestIddata-testidLast resort — when no accessible label exists

End-to-End Testing with Playwright

Playwright tests run in real browsers, simulating real user interactions:

import { test, expect } from '@playwright/test';

test('user can log in and see dashboard', async ({ page }) => {
  await page.goto('/login');

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

  // Wait for navigation and assertion
  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByText('Welcome, John')).toBeVisible();
});

test('search filters products', async ({ page }) => {
  await page.goto('/products');

  await page.getByPlaceholder('Search products...').fill('laptop');
  await page.getByRole('button', { name: 'Search' }).click();

  const results = page.getByRole('listitem');
  await expect(results).toHaveCount(3);
  await expect(results.first()).toContainText('MacBook');
});

Playwright advantages: Multi-browser (Chromium, Firefox, WebKit), auto-wait for elements, network interception, visual comparisons, parallel execution.

Accessibility Testing

// Automated a11y testing with axe-core + Vitest
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);

test('has no accessibility violations', async () => {
  const { container } = render(<Navigation />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Tools: axe-core (automated), Lighthouse (CI integration), keyboard testing (manual), screen reader testing (manual).

Visual Regression Testing

Detect unintended visual changes by comparing screenshots:

// Playwright visual comparisons
test('homepage matches snapshot', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('homepage.png');
});

// Compare specific element
test('card component matches snapshot', async ({ page }) => {
  await page.goto('/products/1');
  await expect(page.getByTestId('product-card')).toHaveScreenshot('card.png');
});

Tools: Playwright screenshots, Percy (BrowserStack), Chromatic (Storybook), BackstopJS.

Mocking Strategies

import { vi } from 'vitest';

// Mock a module
vi.mock('./api', () => ({
  fetchUsers: vi.fn().mockResolvedValue([{ id: 1, name: 'John' }]),
}));

// Mock a function temporarily
const original = window.fetch;
window.fetch = vi.fn().mockResolvedValue({
  json: () => Promise.resolve({ data: 'mocked' }),
});

// Mock timers
vi.useFakeTimers();
// ... code that uses setTimeout
vi.advanceTimersByTime(1000);
vi.useRealTimers();

// Spy on a method
const spy = vi.fn();
render(<Button onClick={spy} />);
await userEvent.click(screen.getByRole('button'));
expect(spy).toHaveBeenCalledTimes(1);

Test Organization

src/
├── components/
│   ├── Button/
│   │   ├── Button.tsx
│   │   └── Button.test.tsx        ← co-located tests
│   └── LoginForm/
│       ├── LoginForm.tsx
│       └── LoginForm.test.tsx
├── hooks/
│   ├── useCounter.ts
│   └── useCounter.test.ts
├── utils/
│   ├── format.ts
│   └── format.test.ts
└── e2e/
    ├── auth.spec.ts                ← end-to-end tests (separate folder)
    └── products.spec.ts

Naming convention: describe('ComponentName', () => { it('should do X when Y', ...) }) — describe what it should do, not how.

Interview Questions

Q: What’s the difference between unit tests, integration tests, and E2E tests? A: Unit tests verify individual functions/components in isolation (fast, focused). Integration tests verify that components work together correctly. E2E tests simulate real user flows in a browser (slow, high confidence). Follow the testing pyramid — many unit tests, some integration, few E2E.

Q: Why does React Testing Library recommend testing by role/label rather than by test ID? A: Testing by accessible attributes (role, label, text) ensures components work for all users — including screen reader users. If a button can’t be found by its role, a screen reader user can’t find it either. data-testid is a last resort for elements without accessible identifiers.

Q: When would you mock an API call vs use the real API in tests? A: Mock API calls in unit and component tests for speed, determinism, and isolation. Use real APIs (or a test server) in E2E tests to catch integration issues. Avoid over-mocking — mock at the network level (MSW) rather than internal modules when possible, to test real integration paths.

Q: What is visual regression testing? A: Visual regression testing captures screenshots of UI components and compares them against a baseline. If the rendered output changes unexpectedly (layout shift, color change), the test fails. Tools: Playwright screenshots, Percy, Chromatic. Catches CSS/layout bugs that functional tests miss.

References