Testing React Apps - The No-Nonsense Guide

Write Tests That Actually Catch Bugs (Not Just Make Coverage Look Good)

100% test coverage doesn’t mean your app works. Test behavior, not implementation. Let’s write tests that matter.

Why Coverage Numbers Lie

I wrote tests that passed and hit 100% coverage. Then I refactored the button to use CSS modules. Every test failed. But the button still worked.

Lesson: Test behavior, not implementation details.

Kent C. Dodds’ principle: “The more your tests resemble the way your software is used, the more confidence they can give you.”

Test like a user, not a developer. Users don’t care about class names, component state, or hook dependencies. They care: Can they see it? Can they click it? Does it work?

The Testing Stack

npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event vitest

Why? Vitest runs fast. React Testing Library tests components like users interact with them. jest-dom gives useful assertions. user-event simulates realistic interactions.

Query Priority (Best to Worst)

Use this order:

  1. By role (most accessible): screen.getByRole('button', { name: /submit/i })
  2. By label text: screen.getByLabelText(/username/i)
  3. By placeholder/text: screen.getByPlaceholderText(), screen.getByText()
  4. By alt text (images): screen.getByAltText()
  5. Test IDs (last resort): screen.getByTestId()

Real Test Example

test('submits username and password', async () => {
  const handleSubmit = vi.fn().mockResolvedValue(undefined);
  const user = userEvent.setup();
  render(<LoginForm onSubmit={handleSubmit} />);

  await user.type(screen.getByLabelText(/username/i), 'john');
  await user.type(screen.getByLabelText(/password/i), 'secret123');
  await user.click(screen.getByRole('button', { name: /login/i }));

  expect(handleSubmit).toHaveBeenCalledWith('john', 'secret123');
});

What makes it good: Finds elements like users would. Tests actual behavior. No implementation details.

Key Testing Patterns

Async handling: Use findBy (built-in waiting) instead of waitFor().

Don’t test hooks directly. Test them through components that use them.

Mock only external dependencies (API calls, libraries). Test your actual components.

Arrange, Act, Assert: Setup → Perform action → Verify result.

Common Mistakes

  • Testing implementation (checking state, class names)
  • Too many mocks (you’re testing mocks, not code)
  • Tests that depend on each other (each test sets up its own data)
  • Snapshot testing everything (snapshots break on any change)
  • No assertions (code executes but doesn’t verify anything)

The Truth About Coverage

70-80% coverage of critical paths > 100% coverage of everything. Coverage ignores type definitions, config files, and trivial components. Focus on business logic and user flows.

The real checklist: Would test fail if feature breaks? Would test pass if feature works? Does test survive refactoring?

That’s all that matters.


Testing isn’t about coverage numbers. It’s about confidence that your code works and won’t break when you refactor.

Write tests users would care about.