All articlesEngineering

9 React Testing Best Practices for Better Design and Quality of Your Tests

A Senior Engineer’s Guide to Effective React Testing (includes Cheat Sheet)

Petar IvanovPetar Ivanov
7 min read
On this page

Many developers struggle to make their tests both effective and efficient.

Solid testing is a must-have if you care about your application, customers, and business.

As a Senior Software Engineer with experience in testing and software design, I’ve read, reviewed, and written many tests.

Over the years, I’ve distilled a set of best practices that significantly improved the quality and maintainability of my tests.

In this blog post, I will share 9 tips to help you write and design better tests in your React applications.

1. Favor Arrange-Act-Assert (AAA) pattern

The Arrange-Act-Assert (AAA) pattern brings clarity and structure to your tests.

By dividing your test into three distinct parts, you make it easier to read, follow, understand, and maintain.

This pattern helps prevent tests from becoming complex and 🍝.

This ensures that each test focuses on a specific behavior of the app.

In summary, the Arrange-Act-Assert (AAA) pattern helps with readability and consistency, allowing you to grasp what the test is verifying for others and future you.

TSX
it('should toggle create payment profile dialog', async () => {
  // <strong>Arrange</strong>
  render(<PaymentProfiles />);

  // <strong>Act</strong>
  fireEvent.click(await screen.findByTestId(testIds.addButton));

  // <strong>Assert</strong>
  const dialog = await screen.findByRole('dialog');
  expect(dialog).toBeInTheDocument();
});

Sometimes we might not need the Act and that’s fine.

TSX
it('should display server error', async () => {
  // <strong>Arrange</strong>
  server.use(
    graphql.query('GetCardPaymentProfiles', (_, __, ctx) =>
      resDelay(ctx.status(500)),
    ),
  );
  render(<PaymentProfiles />);

  // <strong>Assert</strong>
  expect(await screen.findByTestId(testIds.error)).toBeInTheDocument();
});

2. Avoid testing too many things at once

Testing multiple functionalities in a single test can make bugs and issues hard to find.

It’s better to write smaller, focused tests that cover only one aspect of the component’s behavior and functionality.

This simplifies debugging and ensures each test has a clear purpose.

It also reduces the cognitive load when maintaining the tests since you’re focused only on one scenario.

⛔ Avoid testing too many things at once.

TSX
it('<strong>should increment and decrement the counter</strong>', () => {
  render(<Counter initialCount={0} />);
  
  fireEvent.click(screen.getByText('Increment'));
  
  expect(screen.getByTestId('count')).toHaveTextContent('1');
  
  fireEvent.click(screen.getByText('Decrement'));
  
  expect(screen.getByTestId('count')).toHaveTextContent('0');
});

✅ Prefertesting only one aspect of the component’s behavior and functionality.

TSX
it('<strong>should increment the counter</strong>', () => {
  render(<Counter initialCount={0} />);
  
  fireEvent.click(screen.getByText('Increment'));
  
  expect(screen.getByTestId('count')).toHaveTextContent('1');
});

it('<strong>should decrement the counter</strong>', () => {
  render(<Counter initialCount={1} />);
  
  fireEvent.click(screen.getByText('Decrement'));
  
  expect(screen.getByTestId('count')).toHaveTextContent('0');
});

3. Be careful with snapshot tests

Snapshot tests can be helpful but they can also become a maintenance headache.

They should be treated carefully.

Over-reliance on snapshots can lead to neglecting tests that don’t effectively test components’s scenarios and catch regressions.

If you have snapshots that are too broad, they will always fail due to insignificant changes.

As a rule of thumb, I prefer to add snapshot tests for “dummy” or stateless UI components and not for stateful ones.

This way if a style is not applied or changed due to a bug, the snapshot test will fail and someone will have to look into it.

Another place where snapshot tests can be useful is for critical components with stable structures.

Keep snapshot tests small and focused.

TypeScript
it('<strong>should load and display invoices</strong>', async () => {
  renderComponent();

  expect(screen.getByTestId(testIds.loading)).toBeInTheDocument();

  await waitForElementToBeRemoved(() =>
    screen.getByTestId(testIds.loading),
  );

  expect(await screen.findByTestId(testIds.invoices)).toBeInTheDocument();
  <strong>expect(screen.getByTestId(testIds.invoices)).toMatchSnapshot();</strong>
});

4. Test the Happy Path first

Start by testing the most common and expected use cases of your components.

Ensure that the core functionality and business logic works as expected before diving into edge cases.

This way you verify that the component behaves correctly in the main case with normal conditions, providing a solid foundation for further testing.

If the core business case doesn’t work, what’s the chance that other edge cases will work as expected?

TypeScript
describe('<strong>Invoices</strong>', () => {
  //
  // <strong>Happy Path</strong>
  //
  it('<strong>should load and display invoices</strong>', async () => {
    renderComponent();

    expect(screen.getByTestId(testIds.loading)).toBeInTheDocument();

    await waitForElementToBeRemoved(() =>
      screen.getByTestId(testIds.loading),
    );

    expect(await screen.findByTestId(testIds.invoices)).toBeInTheDocument();
    expect(screen.getByTestId(testIds.invoices)).toMatchSnapshot();
  });
});

5. Test Edge Cases and Errors

After you verified that the happy path works as expected, continue with testing how your component handles edge cases and errors like invalid inputs, delayed requests, etc.

This ensures correctness and robustness by verifying that the component can handle real-world scenarios gracefully.

TypeScript
describe('<strong>Invoices</strong>', () => {
  //
  // <strong>Edge Cases</strong>
  //
  it('<strong>should load and display empty message</strong>', async () => {
    server.use(
      graphql.query('GetInvoices', (_, __, ctx) =>
        resDelay(
          ctx.status(200),
          ctx.data({
            viewer: { account: { invoices: { nodes: [] } } },
          }),
        ),
      ),
    );

    renderComponent();

    expect(await screen.findByText(/No Invoices/)).toBeInTheDocument();
  });
  
  it('<strong>should not display empty message if refetching data</strong>', async () => {
    queryClient.setDefaultOptions({
      queries: {
        refetchOnMount: 'always',
        initialData: [],
      },
    });

    renderComponent();

    expect(screen.getByTestId(testIds.loading)).toBeInTheDocument();

    expect(screen.queryByText(/No Invoices/)).toBeNull();

    await waitForElementToBeRemoved(() =>
      screen.getByTestId(testIds.loading),
    );

    expect(await screen.findByTestId(testIds.invoices)).toBeInTheDocument();

    expect(screen.queryAllByText(/PDF/)[0]).toBeInTheDocument();
  });
  
  //
  // <strong>Errors</strong>
  //
  it('<strong>should display server error</strong>', async () => {
    server.use(
      graphql.query('GetInvoices', (_, __, ctx) =>
        resDelay(ctx.status(500)),
      ),
	  );

	  renderComponent();

    expect(await screen.findByTestId(testIds.error)).toBeInTheDocument();
  });
});

6. Focus on Integration tests

Integration tests verify that different parts of your application work together as expected.

These type of tests have a higher chance to catch issues that unit tests might miss.

Integration tests provide confidence that the system works as a whole, not just isolated units.

The ROI (Return on Investment) of the integration tests is much higher compared to unit tests and E2E tests.

This doesn’t mean you don’t need them but for sure you should have more integration tests.

The more your tests resembles the way your software is used, the more confidence they can give you.

TSX
it('<strong>should log in and see the dashboard</strong>', async () => {
  render(<App />);
  
  fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'testuser' } });
  fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password' } });
  fireEvent.click(screen.getByText('Log In'));
  
  expect(await screen.findByText('Welcome to your dashboard')).toBeInTheDocument();
});

7. Don’t test third-party libraries

Your tests should focus on your code and application, not the internal functionality of external libraries.

Trust that well-maintained libraries have their own tests.

Testing third-party modules can lead to fragile tests which can break when the library updates, no matter if you haven’t changed their usage.

⛔ Avoid testing the internals of third-party modules.

✅ Prefer testing how your component works with the third-party library.

TypeScript
it('should not display empty message if refetching data', async () => {
  queryClient.setDefaultOptions({
    queries: {
      refetchOnMount: 'always',
      initialData: [],
    },
  });

	renderComponent();

  expect(screen.getByTestId(testIds.loading)).toBeInTheDocument();

  expect(screen.queryByText(/No Invoices/)).toBeNull();

  await waitForElementToBeRemoved(() =>
    screen.getByTestId(testIds.loading),
  );

  expect(await screen.findByTestId(testIds.invoices)).toBeInTheDocument();

  expect(screen.queryAllByText(/PDF/)[0]).toBeInTheDocument();
});

8. Don’t focus on test coverage percentage

If you have 100% test coverage, this doesn’t mean high-quality tests or no bugs at all.

It’s better to focus on meaningful tests, instead of adding tests chasing coverage metrics.

⛔ Avoid writing tests that only serve to increase test coverage.

TSX
//
// <strong>Meaningless test only to satisfy test coverage metrics</strong>
//
it('<strong>should log in and see the dashboard</strong>', async () => {
  render(<App />);
  // <strong>No assertions</strong>
});

✅ Prefer adding valuable tests that verify the component’s behavior and functionality.

TSX
it('<strong>should log in and see the dashboard</strong>', async () => {
  // <strong>Arrange</strong>
  render(<App />);
  
  // <strong>Act</strong>
  fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'testuser' } });
  fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password' } });
  fireEvent.click(screen.getByText('Log In'));
  
  // <strong>Assert</strong>
  expect(await screen.findByText('Welcome to your dashboard')).toBeInTheDocument();
});

9. Remove unnecessary tests

As your application and codebase evolve, some tests might become redundant or irrelevant.

Regularly review and clean up your tests.

Tests are part of the codebase, so they should be treated as such - regularly reviewed and updated.

This reduces maintenance overhead and keeps your tests lean and efficient.

So when a feature is deprecated or a component is removed, delete the related tests.

TL;DR

  1. Favor Arrange-Act-Assert (AAA) pattern
  2. Avoid testing too many things at once
  3. Be careful with snapshot tests
  4. Test the Happy Path first
  5. Test Edge Cases and Errors
  6. Focus on Integration tests
  7. Don’t test third-party libraries
  8. Don’t focus on test coverage percentage
  9. Remove unnecessary tests

Related articles

Whenever you’re ready, here’s how I can help you:

  1. 1.

    The Conscious React: React architecture, design & clean code — 100+ production tips across 6 chapters, updated for React 19, plus 4 companion repos you can clone and run.

  2. 2.

    The Conscious Node: Node.js architecture, design & clean code — 157 production tips across 10 chapters, from module boundaries to the transactional outbox and zero-downtime deploys.

  3. 3.

    The JavaScript Architect Bundle: Both books + all React companion repos + CLAUDE.md rulesets + both playbooks. The complete path from developer to architect.

  4. 4.

    Free Resources: Architecture playbooks, cheat-sheets, and the JavaScript Architect Roadmap — practical guides for leveling up to senior.

The T-Shaped Dev

Join 30K+ engineers leveling up to architect

One practical tip on JavaScript, React, Node.js, and software architecture every week. No spam, unsubscribe anytime.

Petar Ivanov

Written by

Petar Ivanov

Software engineer, author, and speaker. I help JavaScript developers grow from Mid → Senior → Architect — production-grade React, Node.js, and AI systems.