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 6.6 — Async State, Side Effects & Server State Boundaries
Most React state bugs come from mixing UI state with server state. This post teaches you how to draw a hard line between them—and why that line matters.
Part 6.5 — Introducing Redux Toolkit the Right Way
Redux isn’t the default anymore—but when your app needs it, Redux Toolkit is the cleanest, safest way to introduce global state at scale.
Part 6.4 — Combining Context + useReducer for App-Level State
Context provides access. Reducers provide structure. Together, they form a powerful, lightweight architecture for managing app-level state in React.
Comments