Component Testing
Test components in isolation to verify their behavior and output.
In this article
Problem
A bug that breaks checkout for 10,000 users can ship with nothing more than “it works on my machine” behind it.
You clicked through the form once, it worked, you shipped it, but you never tested the error state or what happens when someone pastes an emoji into the email field. That’s where the bugs are. Without automated tests, every refactor becomes terrifying; you change a shared button component and maybe you broke twelve pages, maybe you didn’t. You won’t know until QA spends a week clicking through everything or customers start complaining.
Solution
Component tests simulate user interactions and verify the output, covering the happy path, error path, loading state, empty state, and edge cases you’d never remember to check manually.
Testing Library’s main idea is to test components the way users use them: finding elements by their accessible names and asserting on what appears on screen after clicks and typing. Implementation details like internal state stay out of the tests.
Mocked APIs keep tests fast and deterministic. A test with mocked responses runs in milliseconds and never fails because the server was slow or down.
Example
Each example drives a component through user interactions and asserts on what users see.
Basic Component Testing
import { render, fireEvent, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('button calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('form submission with user input', async () => {
const handleSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('Email'), 'user@example.com');
await user.type(screen.getByLabelText('Password'), 'password123');
await user.click(screen.getByRole('button', { name: /log in/i }));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123'
});
}); import { mount } from '@vue/test-utils';
import Button from './Button.vue';
import LoginForm from './LoginForm.vue';
test('button calls onClick when clicked', async () => {
const handleClick = jest.fn();
const wrapper = mount(Button, {
props: { onClick: handleClick },
slots: { default: 'Click me' }
});
await wrapper.find('button').trigger('click');
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('form emits submit event with input data', async () => {
const wrapper = mount(LoginForm);
await wrapper.find('input[type="email"]').setValue('user@example.com');
await wrapper.find('input[type="password"]').setValue('password123');
await wrapper.find('form').trigger('submit.prevent');
expect(wrapper.emitted('submit')[0]).toEqual([{
email: 'user@example.com',
password: 'password123'
}]);
}); import { render, fireEvent, screen } from '@testing-library/svelte';
import Button from './Button.svelte';
import Counter from './Counter.svelte';
test('button calls onClick when clicked', async () => {
const handleClick = jest.fn();
render(Button, { props: { onClick: handleClick } });
await fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('display updates when props change', async () => {
const { rerender } = render(Counter, { props: { count: 0 } });
expect(screen.getByText('Count: 0')).toBeInTheDocument();
await rerender({ count: 5 });
expect(screen.getByText('Count: 5')).toBeInTheDocument();
}); describe('Button component', () => {
let button;
beforeEach(() => {
button = document.createElement('custom-button');
document.body.appendChild(button);
});
afterEach(() => {
document.body.removeChild(button);
});
test('button calls onClick when clicked', () => {
const handleClick = jest.fn();
button.addEventListener('click', handleClick);
button.click();
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('button updates text when attribute changes', () => {
button.setAttribute('label', 'New Label');
expect(button.shadowRoot.querySelector('button').textContent).toBe('New Label');
});
}); Testing Async Behavior
Mocking API responses lets you test loading and error states deterministically:
test('displays user data after loading', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ id: 1, name: 'John Doe' })
});
render(<UserProfile userId={1} />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
global.fetch.mockRestore();
});
test('displays error message on failure', async () => {
jest.spyOn(global, 'fetch').mockRejectedValue(new Error('Failed to fetch'));
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText('Failed to load user')).toBeInTheDocument();
});
global.fetch.mockRestore();
});
Testing Component State Changes
This test simulates interactions and checks that the UI updates correctly:
test('counter increments when button is clicked', () => {
render(<Counter initialCount={0} />);
const button = screen.getByRole('button', { name: /increment/i });
fireEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
fireEvent.click(button);
expect(screen.getByText('Count: 2')).toBeInTheDocument();
});
Testing with Mock Providers
Wrapping components in mock context providers gives you control over their dependencies:
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
This test checks that the error boundary catches the error and shows its fallback UI:
test('error boundary catches component errors', () => {
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
Automated accessibility checks catch common issues like missing labels:
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 />);
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
});
Snapshot Testing
Snapshots detect unintended changes to component output:
test('component renders correctly', () => {
const { container } = render(<Card title="Test" content="Content" />);
expect(container.firstChild).toMatchSnapshot();
});
Testing Custom Hooks (React)
Hooks can be tested in isolation with renderHook:
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
- Regressions get caught before production, while they’re still quick to fix and before a customer has to report them.
- Refactoring becomes possible; with tests in place, you can restructure code with confidence instead of avoiding legacy components.
- Tests serve as documentation, helping new developers understand what components are supposed to do.
- Manual QA time shrinks since computers handle repetitive verification better than humans.
- Writing tests forces you to think about edge cases upfront. Error states and boundary conditions get considered before users encounter them.
- Components become better designed; hard-to-test components usually have design problems, so writing tests pushes you toward better architecture.
Tradeoffs
- Tests take time to write. Thorough tests can add a lot to initial development time, though they reduce debugging time later.
- Brittle tests are an ongoing problem; tests that check implementation details like CSS classes or internal state break on every refactor even though nothing is broken.
- Passing tests don’t mean the code works. Unrealistic mocks or missed edge cases give false confidence until the bug shows up in production.
- Mocking adds complexity and hides integration issues. Your tests pass with mocked APIs, but the real API behaves differently.
- Slow test suites hurt productivity; if you have to wait three minutes for tests after every change, you’ll stop running them.
- Snapshot tests break on harmless formatting changes, and developers reflexively update them without reviewing what changed.
- Async testing is hard.
waitForandacthave subtle timing behavior that leads to flaky tests if you don’t understand them well. - You need judgment to decide what to test. Too many unit tests create brittleness and too few miss important cases. Some components resist testing entirely.
- Outdated tests are worse than no tests, providing false confidence and maintenance burden without catching real bugs. Coverage metrics can be equally misleading.
Summary
Component testing verifies that your components render correctly and respond to user interactions. Tests written from the user’s perspective interact with components the way users do and leave implementation details alone, so they catch real bugs while letting you refactor freely.