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
waitForandact, 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.