Saved
Frontend Pattern

Component Testing

Test components in isolation to verify their behavior and output.

Difficulty Intermediate

By Den Odell

Component Testing

Problem

Components break in subtle ways that only appear in production after users encounter edge cases that developers never manually tested. Developers spend considerable time manually clicking through the same UI flows repeatedly to verify each change works correctly, slowing down development velocity, while regression bugs slip through code reviews because there is no automated verification that components still render correctly, handle user interactions properly, or manage state as expected after refactoring. Changes to shared components or CSS affect dozens of dependent components in unexpected ways with no systematic way to detect breakage, causing confidence in refactoring to drop because developers fear breaking existing functionality without realizing it. Time-to-market suffers as QA teams must manually test the same scenarios over and over, while edge cases like empty states, error states, and loading states get forgotten during manual testing but surface in production, and components that work in isolation break when integrated because their interfaces or assumptions changed.

Solution

Write tests that render components in isolation, simulate user interactions like clicks and form input, and assert on output to verify behavior independently from the rest of the application. Mount components with specific props and state to test different scenarios including success paths, error states, edge cases, and loading states. Use testing libraries that encourage testing components the way users interact with them rather than testing implementation details. Mock external dependencies like API calls, timers, and browser APIs to make tests fast and deterministic. This catches regressions early in the development cycle and ensures components work correctly before integrating them into larger systems. Tests serve as living documentation that describes how components should behave in different scenarios. The test suite provides confidence to refactor aggressively, knowing that breaking changes will be caught immediately.

Example

This example demonstrates how to render a component in isolation, simulate user interaction, and verify expected behavior.

React Testing Library

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

test('button calls onClick when clicked', () => {
  // Create a mock function to track if onClick is called
  const handleClick = jest.fn();

  // Render the Button component in isolation with test props
  render(<Button onClick={handleClick}>Click me</Button>);

  // Simulate a user clicking the button
  fireEvent.click(screen.getByText('Click me'));

  // Assert the onClick handler was called exactly once
  expect(handleClick).toHaveBeenCalledTimes(1);
});

test('form submission with user input', async () => {
  const handleSubmit = jest.fn();
  const user = userEvent.setup();

  render(<LoginForm onSubmit={handleSubmit} />);

  // Simulate user typing
  await user.type(screen.getByLabelText('Email'), 'user@example.com');
  await user.type(screen.getByLabelText('Password'), 'password123');

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

  // Assert form submitted with correct data
  expect(handleSubmit).toHaveBeenCalledWith({
    email: 'user@example.com',
    password: 'password123'
  });
});

Testing Async Behavior

test('displays user data after loading', async () => {
  // Mock API call
  const mockUser = { id: 1, name: 'John Doe' };
  jest.spyOn(global, 'fetch').mockResolvedValue({
    json: async () => mockUser
  });

  render(<UserProfile userId={1} />);

  // Initially shows loading state
  expect(screen.getByText('Loading...')).toBeInTheDocument();

  // Wait for data to load
  await waitFor(() => {
    expect(screen.getByText('John Doe')).toBeInTheDocument();
  });

  // Cleanup
  global.fetch.mockRestore();
});

test('displays error message on failure', async () => {
  // Mock API failure
  jest.spyOn(global, 'fetch').mockRejectedValue(new Error('Failed to fetch'));

  render(<UserProfile userId={1} />);

  // Wait for error message
  await waitFor(() => {
    expect(screen.getByText('Failed to load user')).toBeInTheDocument();
  });

  global.fetch.mockRestore();
});

Testing Component State Changes

test('counter increments when button is clicked', () => {
  render(<Counter initialCount={0} />);

  const button = screen.getByRole('button', { name: /increment/i });
  const count = screen.getByText('Count: 0');

  // Click increment button
  fireEvent.click(button);

  // Verify count increased
  expect(screen.getByText('Count: 1')).toBeInTheDocument();

  // Click again
  fireEvent.click(button);
  expect(screen.getByText('Count: 2')).toBeInTheDocument();
});

Vue Test Utils

import { mount } from '@vue/test-utils';
import Button from './Button.vue';

test('button calls onClick when clicked', async () => {
  // Create a mock function
  const handleClick = jest.fn();

  // Mount the component with test props
  const wrapper = mount(Button, {
    props: {
      onClick: handleClick
    },
    slots: {
      default: 'Click me'
    }
  });

  // Simulate a user clicking the button
  await wrapper.find('button').trigger('click');

  // Assert the onClick handler was called
  expect(handleClick).toHaveBeenCalledTimes(1);
});

test('form emits submit event with input data', async () => {
  const wrapper = mount(LoginForm);

  // Fill in form fields
  await wrapper.find('input[type="email"]').setValue('user@example.com');
  await wrapper.find('input[type="password"]').setValue('password123');

  // Submit form
  await wrapper.find('form').trigger('submit.prevent');

  // Check emitted event
  expect(wrapper.emitted('submit')).toBeTruthy();
  expect(wrapper.emitted('submit')[0]).toEqual([{
    email: 'user@example.com',
    password: 'password123'
  }]);
});

Svelte Testing Library

import { render, fireEvent, screen } from '@testing-library/svelte';
import Button from './Button.svelte';

test('button calls onClick when clicked', async () => {
  // Create a mock function
  const handleClick = jest.fn();

  // Render the component with test props
  const { component } = render(Button, {
    props: {
      onClick: handleClick
    }
  });

  // Simulate a user clicking the button
  await fireEvent.click(screen.getByRole('button'));

  // Assert the onClick handler was called
  expect(handleClick).toHaveBeenCalledTimes(1);
});

test('reactive statement updates display', async () => {
  const { component } = render(Counter, {
    props: { count: 0 }
  });

  expect(screen.getByText('Count: 0')).toBeInTheDocument();

  // Update prop programmatically
  component.$set({ count: 5 });

  expect(screen.getByText('Count: 5')).toBeInTheDocument();
});

Web Component Testing

describe('Button component', () => {
  let button;

  beforeEach(() => {
    // Create a fresh instance for each test
    button = document.createElement('custom-button');
    document.body.appendChild(button);
  });

  afterEach(() => {
    // Cleanup after each test
    document.body.removeChild(button);
  });

  test('button calls onClick when clicked', () => {
    const handleClick = jest.fn();
    button.addEventListener('click', handleClick);

    // Simulate click
    button.click();

    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  test('button updates text when attribute changes', () => {
    button.setAttribute('label', 'New Label');

    // Wait for attribute change to reflect
    expect(button.shadowRoot.querySelector('button').textContent).toBe('New Label');
  });
});

Testing with Mock Providers

test('component uses context value', () => {
  const mockContext = { user: { name: 'John' } };

  render(
    <UserContext.Provider value={mockContext}>
      <UserGreeting />
    </UserContext.Provider>
  );

  expect(screen.getByText('Hello, John')).toBeInTheDocument();
});

Testing Error Boundaries

test('error boundary catches component errors', () => {
  // Suppress console.error for this test
  const consoleSpy = jest.spyOn(console, 'error').mockImplementation();

  const ThrowError = () => {
    throw new Error('Test error');
  };

  render(
    <ErrorBoundary fallback={<div>Error occurred</div>}>
      <ThrowError />
    </ErrorBoundary>
  );

  expect(screen.getByText('Error occurred')).toBeInTheDocument();

  consoleSpy.mockRestore();
});

Accessibility Testing

import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

test('button has no accessibility violations', async () => {
  const { container } = render(<Button>Click me</Button>);

  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

test('form labels are associated with inputs', () => {
  render(<LoginForm />);

  const emailInput = screen.getByLabelText('Email');
  const passwordInput = screen.getByLabelText('Password');

  expect(emailInput).toBeInTheDocument();
  expect(passwordInput).toBeInTheDocument();
});

Snapshot Testing

test('component renders correctly', () => {
  const { container } = render(<Card title="Test" content="Content" />);

  // Save snapshot of rendered output
  expect(container.firstChild).toMatchSnapshot();
});

Testing Custom Hooks (React)

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

test('useCounter increments count', () => {
  const { result } = renderHook(() => useCounter(0));

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

  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(1);
});

Benefits

  • Catches component regressions before they reach production by automatically verifying behavior on every code change.
  • Provides fast feedback during development without manual testing - developers know immediately if their changes broke existing functionality.
  • Documents expected component behavior through test cases that serve as executable specifications.
  • Enables confident refactoring with automated regression detection - developers can restructure code knowing tests will catch breaking changes.
  • Reduces time spent on repetitive manual QA testing by automating verification of common scenarios.
  • Forces developers to think about edge cases and error states during implementation rather than discovering them in production.
  • Creates a safety net that allows teams to move faster by catching bugs early when they’re cheapest to fix.
  • Improves code quality by encouraging testable component designs with clear interfaces and minimal side effects.

Tradeoffs

  • Requires time and effort to write and maintain tests - adding tests for every component can double development time initially, though this pays off in reduced debugging time.
  • Tests can become brittle if they rely on implementation details like internal state or CSS classes rather than user-facing behavior, breaking when refactoring even though functionality is unchanged.
  • May give false confidence if tests don’t cover real-world scenarios - passing tests don’t guarantee the component works in production if tests use unrealistic mocks or miss critical edge cases.
  • Needs mocking for external dependencies like API calls, timers, and browser APIs which adds complexity and can hide integration issues that only surface when real dependencies are involved.
  • Can slow down development if test suite becomes too large or slow - waiting minutes for tests to run after each change kills developer productivity and discourages running tests frequently.
  • Snapshot tests are particularly brittle - they break on any output change, including harmless formatting differences, and developers often update snapshots without reviewing changes carefully.
  • Testing async behavior requires understanding timing and async utilities like waitFor and act, which have subtle behavior that leads to flaky tests if misunderstood.
  • Mocking frameworks like Jest add their own learning curve and API quirks - understanding how to mock modules, timers, and promises correctly takes experience.
  • Choosing what to test and at what level of granularity is difficult - too many unit tests create brittleness, too few miss important edge cases, and finding the right balance requires judgment.
  • Components that depend heavily on browser APIs, third-party libraries, or complex state management require extensive mocking that may not reflect production behavior accurately.
  • Maintaining tests as components evolve requires discipline - outdated tests that no longer reflect actual usage patterns provide little value and create maintenance burden.
  • Different testing philosophies (implementation vs behavior testing) create friction in teams - debates about testing strategy can slow down development and code review.
  • Test coverage metrics can be misleading - high coverage doesn’t guarantee quality if tests are poorly written or don’t cover meaningful scenarios.
Stay Updated

Get New Patterns
in Your Inbox

Join thousands of developers receiving regular insights on frontend architecture patterns

No spam. Unsubscribe anytime.