Component Testing
Problem
“It works on my machine.” Famous last words before you ship a bug that breaks checkout for 10,000 users.
You clicked through the form once, it worked, you shipped it, but you never tested the empty state, the error state, or what happens when someone pastes an emoji into the email field. That’s where the bugs live. 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
Write tests that render components, simulate user interactions, and verify the output: the happy path, error path, loading state, empty state, and edge cases you’d never remember to check manually.
The key insight from Testing Library: test components the way users use them. Find elements by their accessible names, simulate clicks and typing, assert on what appears on screen. Don’t test implementation details like internal state.
Mock your APIs to keep tests fast and deterministic. A test with mocked responses runs in milliseconds and never fails because the server was having a bad day.
Example
Rendering components, simulating interactions, asserting 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';
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';
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('reactive statement updates display', async () => {
const { component } = render(Counter, { props: { count: 0 } });
expect(screen.getByText('Count: 0')).toBeInTheDocument();
component.$set({ 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
Mock API responses to test loading and error states deterministically:
test('displays user data after loading', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({
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
Simulate interactions and verify 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
Wrap components with mock context providers to control 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
Verify that error boundaries catch and display 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)
Test hooks in isolation using 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, and bugs get caught early when they’re cheap to fix. A test failure during development beats a customer complaint every time.
- Refactoring becomes possible; when tests have your back, you can restructure code confidently instead of tiptoeing around 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, empty states, and boundary conditions get considered before users encounter them.
- Components become better designed; hard-to-test components usually have design problems, so testing pressure improves your architecture.
Tradeoffs
- Tests take time to write. Thorough tests can double initial development time, though it pays off in reduced debugging later.
- Brittle tests are a constant battle; test implementation details like CSS classes or internal state, and your tests break on every refactor even though nothing is actually broken.
- Passing tests don’t mean the code works. Unrealistic mocks or missed edge cases create false confidence until production proves you wrong.
- Mocking adds complexity and hides integration issues. Your tests pass with mocked APIs, but the real API behaves differently.
- Slow test suites kill productivity; waiting three minutes for tests after every change means developers stop running them.
- Snapshot tests break on harmless formatting changes, and developers reflexively update them without reviewing what actually changed.
- Async testing is genuinely hard.
waitForandacthave subtle timing behavior that leads to flaky tests if you don’t understand them deeply. - Deciding what to test requires judgment. Too many unit tests create brittleness, too few miss important cases, and 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. Test from the user’s perspective: query elements the way users find them, simulate real interactions, and avoid implementation details. This approach catches real bugs while letting you refactor freely.