Part 5.3 — Testing Components, Hooks & UI State
Most frontend bugs don’t come from syntax errors.
They come from state transitions: loading → success → error, disabled → enabled, empty → populated.
In this post, you’ll learn how to test React components and hooks in a way that mirrors how users actually experience your app.
We’ll focus on behavior, not internals.
1. Start with a Real Component
Consider a simple but realistic component that loads data:
type User = {
id: number;
name: string;
};
export function UserList() {
const [users, setUsers] = React.useState<User[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(setUsers)
.catch(() => setError('Failed to load users'))
.finally(() => setLoading(false));
}, []);
if (loading) return <p>Loading…</p>;
if (error) return <p role="alert">{error}</p>;
return (
<ul>
{users.map(u => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}This component has:
async behavior
loading state
error handling
conditional rendering
Perfect for testing.
2. Test What the User Sees (Not State)
Bad test:
expect(component.state.loading).toBe(true);Good test:
it('shows loading state initially', () => {
render(<UserList />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});You’re asserting visible behavior, not internal mechanics.
3. Mock Fetch for Controlled Responses
In your test file:
beforeEach(() => {
(fetch as jest.Mock).mockResolvedValue({
json: async () => [{ id: 1, name: 'Alice' }],
});
});Now test success:
it('renders users after loading', async () => {
render(<UserList />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
});findByText waits for async UI updates—exactly what you want.
4. Test Error States Explicitly
it('shows error when request fails', async () => {
(fetch as jest.Mock).mockRejectedValueOnce(new Error('fail'));
render(<UserList />);
expect(await screen.findByRole('alert')).toHaveTextContent(
'Failed to load users',
);
});This test proves your UI fails gracefully.
5. Prefer userEvent Over fireEvent
userEvent simulates real interactions.
Example component:
export function Counter() {
const [count, setCount] = React.useState(0);
return (
<>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</>
);
}Test it:
import userEvent from '@testing-library/user-event';
it('increments counter on click', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});This matches real user behavior.
6. Testing Forms & Validation Feedback
Example form:
export function LoginForm() {
const [email, setEmail] = React.useState('');
const [error, setError] = React.useState('');
function submit() {
if (!email.includes('@')) {
setError('Invalid email');
}
}
return (
<form>
<input
aria-label="Email"
value={email}
onChange={e => setEmail(e.target.value)}
/>
<button type="button" onClick={submit}>Submit</button>
{error && <p role="alert">{error}</p>}
</form>
);
}Test validation:
it('shows validation error for invalid email', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText('Email'), 'invalid');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(screen.getByRole('alert')).toHaveTextContent('Invalid email');
});You’re testing feedback, not form internals.
7. Testing Conditional UI (Disabled States)
<button disabled={!email}>Submit</button>Test:
expect(
screen.getByRole('button', { name: /submit/i }),
).toBeDisabled();Then enable:
await user.type(screen.getByLabelText('Email'), 'a@b.com');
expect(
screen.getByRole('button', { name: /submit/i }),
).toBeEnabled();These tests catch subtle UX regressions.
8. Testing Custom Hooks (Without Testing Implementation)
Custom hook:
export function useToggle() {
const [value, setValue] = React.useState(false);
return {
value,
toggle: () => setValue(v => !v),
};
}Test with a wrapper component:
function TestComponent() {
const { value, toggle } = useToggle();
return (
<>
<span>{String(value)}</span>
<button onClick={toggle}>Toggle</button>
</>
);
}Test behavior:
it('toggles value', async () => {
const user = userEvent.setup();
render(<TestComponent />);
expect(screen.getByText('false')).toBeInTheDocument();
await user.click(screen.getByRole('button'));
expect(screen.getByText('true')).toBeInTheDocument();
});This avoids coupling tests to hook internals.
9. Async UI Requires Async Assertions
Always use:
findBy*waitFor
Example:
await waitFor(() =>
expect(screen.getByText('Loaded')).toBeInTheDocument(),
);Never rely on arbitrary timeouts.
10. Avoid Snapshot Testing for Stateful Components
Snapshots break easily when UI evolves.
Prefer:
role-based queries
text assertions
visibility checks
Snapshots are fine for:
icons
pure layout components
Not for logic-heavy UI.
11. Keep Tests Small & Focused
One test = one behavior.
Bad:
it('does everything', () => { ... });Good:
it('shows loading state');
it('renders data');
it('handles error');Clear intent makes failures obvious.
12. Summary
You now know how to:
test async UI state transitions
mock fetch cleanly
use userEvent for realistic interactions
validate form feedback
test conditional rendering
test custom hooks via behavior
avoid brittle snapshots
In Part 5.4, you’ll go deeper into mocking REST APIs, error cases, retries, and keeping frontend tests fast and deterministic.
Related
Part 7.5 — Build Optimization with Vite: Code Splitting & Env Handling
Vite makes development fast by default. This post shows how to make production builds just as intentional—lean bundles, predictable envs, and code that ships only when needed.
Part 7.4 — Performance Hooks: useMemo, useCallback & useDeferredValue
Performance hooks are scalpels, not band-aids. This post teaches you how to use them intentionally—only where they solve real problems.
Part 7.3 — Custom Hooks as Architecture: Patterns & Pitfalls
Custom hooks aren’t just helpers. Used well, they define architectural seams in your React app. Used poorly, they hide complexity and make refactoring painful.
Comments