Skip to content

Part 5.3 — Testing Components, Hooks & UI State

Site Console Site Console
4 min read Updated Jan 11, 2026 Frontend Design 0 comments
Testing React 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

Leave a comment

Sign in to leave a comment.

Comments