Jan Hesters

Senior Developer Answers The Most Asked Questions About React Testing

This article answers the most Googled questions about React testing, like "What is a unit test in React?", "How do you test code with REST API calls?", "When should you use act with React Testing Library?", and so much more.

To kick things off, we're going to start with:

What is unit testing in React?

When people talk about "unit testing in React," they typically mean tests for some helper functions, reducers, hooks, or React components.

We could move on to the next question, but here's the catch:

You may not know there are different kinds of tests.

1.) Unit Tests

Unit tests test something in isolation.

I recommend three test frameworks to you: Jest and Vitest because they're the most popular, and RITEway because it has an API that forces you to write good tests.

For example, you can have a test for a pure function add that takes two numbers and returns their sum.

unit-test-example.test.ts
import { describe, expect, test } from 'vitest';
 
const add = (a: number, b: number) => a + b;
 
describe('add()', () => {
  test('given two numbers: returns the sum', () => {
    const actual = add(1, 2);
    const expected = 3;
 
    expect(actual).toEqual(expected);
  });
});

If you've never seen a test before, they usually look similar to this. There is a describe function that tells you about the component you're testing.

Then there is a test function that contains prose describing what behavior you're testing.

Finally, there is an expect function responsible for asserting that the behavior you're testing is what you expect.

Unit tests are ideal for pure functions that, given the same input, always return the same output and lack any side effects.

Usually, functions with side effects are better tested with ...

2.) Integration Tests

Integration tests test how different things work together.

You can use the same test frameworks for integration tests as you do for unit tests.

Assume you have a function requireUserExists that takes a user ID and returns a user if it exists; otherwise, it throws a 404 error.

features/user-account/user-account-helpers.server.ts
import { UserAccount } from '@prisma/client';
import { throwIfEntityIsMissing } from '~/utils/throw-if-entity-is-missing.server';
 
export async function requireUserExists(userId: UserAccount['id']) {
  const user = await retrieveUserAccountFromDatabaseById(userId);
  return throwIfEntityIsMissing(user);
}

This function requires an integration test because both requireUserExists consists of two functions and it has side effects.

  1. requireUserExists uses retrieveUserAccountFromDatabaseById and throwIfEntityIsMissing. Therefore, any test for requireUserExists will also test whether these two functions successfully work together to create the desired outcome.
  2. Additionally, requireUserExists has two side effects. As a reminder, any observable interaction with the outside world or the program's state outside the function's scope is a side effect. First, requireUserExists retrieves a user from the database, which is an asynchronous operation. Second, it throws an error if the user is not found.

Here's how you can write tests for requireUserExists.

features/user-account/user-account-helpers.server.test.ts
import { describe, expect, onTestFinished, test } from 'vitest';
 
import { createPopulatedUserAccount } from '~/features/user-account/user-account-factories.server';
import { requireUserExists } from '~/features/user-account/user-account-helpers.server';
import {
  deleteUserAccountFromDatabaseById,
  saveUserAccountToDatabase,
} from '~/features/user-account/user-account-model.server';
 
async function setup() {
  const user = createPopulatedUserAccount();
  await saveUserAccountToDatabase(user);
 
  onTestFinished(async () => {
    await deleteUserAccountFromDatabaseById(user.id);
  });
 
  return { user };
}
 
describe('requireUserExists()', () => {
  test('given a user ID for an existing user: returns the user', async () => {
    const { user } = await setup();
 
    const actual = await requireUserExists(user.id);
    const expected = user;
 
    expect(actual).toEqual(expected);
  });
 
  test('given a user ID for a non-existing user: throws a 404 error', async () => {
    expect.assertions(1);
 
    const userId = 'non-existing-user-id';
 
    try {
      await requireUserExists(userId);
    } catch (error) {
      if (error instanceof Response) {
        expect(error.status).toEqual(404);
      }
    }
  });
});

When you write integration tests that modify a database, you either need to mock the database or write setup and teardown code to clean up after the test. If you're unfamiliar with mocking, it means replacing a function with a test double.

You should know that "Mocking Is A Code Smell", but that's a topic for another article. In this tutorial, you're going to use a setup function.

It uses a factory function createPopulatedUserAccount to create a user and then saves it to the database.

It then uses the onTestFinished hook, which runs after the test has finished and deletes the user from the database.

Using that function, you can write two tests.

The first one tests that the function returns the user if it exists. The setup function is called within the test, saves a user to the database, and automatically deletes it after the test has finished.

The second one tests that the function throws an error if the user does not exist.

For this test, you need to call expect.assertions(1) to tell Vitest that you want requireUserExists to fail and one assertion to occur. You're using try and catch here, and if requireUserExists succeeds and doesn't throw an error, the test will fail because of the expect.assertions(1) call. If it throws as you expect, the test will go into the catch block, execute the expect assertion, and the test will pass.

And finally, there are also ...

3.) Functional Tests

Functional tests test your app from your user's perspective.

You can either write functional tests for React components with the previously mentioned frameworks together with React Testing Library (RTL), or you can use a framework like Playwright or Cypress that spins up an actual browser.

React Testing Library is used together with Jest or Vitest to write functional tests for your React components, and you mock the DOM using JSDOM or Happy DOM. You will learn how to install those and use React Testing Library later in this article.

An E2E test framework like Playwright or Cypress uses an actual browser to visit your app and then interact with it.

RTL runs faster than E2E test frameworks, but E2E test frameworks give you more confidence that your app works as expected.

Here's what a functional test using Playwright looks like.

functional-test-example.spec.ts
import { expect, test } from '@playwright/test';
 
test.describe('example.com', () => {
  test("given any user: can visit the page and read its text", async ({
    page,
  }) => {
    // Navigate to the page.
    await page.goto('https://example.com');
 
    // Verify the heading is present.
    await expect(
      page.getByRole('heading', { name: 'Example Domain', level: 1 }),
    ).toBeVisible();
 
    // Verify the page contains expected text.
    await expect(
      page.getByText('This domain is for use in illustrative examples'),
    ).toBeVisible();
 
    // Verify the link to more information.
    await expect(
      page.getByRole('link', { name: 'More information...' }),
    ).toHaveAttribute('href', 'https://www.iana.org/domains/example');
  });
});

You visit a page and then verify that the page contains the expected headings, texts, and links. You can also interact with the page, like clicking buttons or filling out forms, but more on that later.

These definitions for the different types of tests are not always clear-cut. The line between unit tests, integration tests, and functional tests is blurry.

For example, notice how tests for a reducer usually test its actions, its selectors, and the reducer together, so they could also be called integration tests.

some-reducer.test.ts
describe('selectMondayIsActive() selector', () => {
  test('given initial state: returns true', () => {
    const state = rootReducer();
 
    const actual = selectMondayIsActive(state);
    const expected = true;
 
    expect(actual).toEqual(expected);
  });
 
  test('given Monday is deactivated: returns false', () => {
    const state = rootReducer(undefined, toggleMondayIsActive());
 
    const actual = selectMondayIsActive(state);
    const expected = false;
 
    expect(actual).toEqual(expected);
  });
 
  test('given Monday is activated again: returns true', () => {
    const actions = [toggleMondayIsActive(), toggleMondayIsActive()];
    const state = actions.reduce(rootReducer, rootReducer());
 
    const actual = selectMondayIsActive(state);
    const expected = true;
 
    expect(actual).toEqual(expected);
  });
});

And tests for React components usually interact with the component from the perspective of a user, so they could be called functional tests. But, if the React component renders another component or uses a hook, it could also be called an integration test because it consists of multiple units. Still, if you only test that component in isolation, it could also be called a unit test.

You will develop a feeling for what to call a test based on the context over time.

Remember:

“When in Rome, do as the Romans do.” - Julius Caesar

In other words, if you're working in a team, you should follow the conventions of your team.

Now let's move on to the next question:

How to do unit testing in React?

In your React project, install Vitest.

npm install --save-dev vitest

If you're reading this article in the future, for the latest instructions on how to set up Vitest, check out their docs.

Add a test script to your package.json.

package.json
{
  "scripts": {
    "test": "vitest --reporter=verbose"
  }
}

Create a quick sanity check test to make sure your configuration worked.

app/sanity-check.test.ts
import { describe, expect, test } from "@jest/globals";  
 
describe("sanity check", () => {
  test("true is true", () => {
    expect(true).toEqual(true);
  });
});

It simply asserts that true is true.

Run your tests:

npm test

You should see the test pass.

 DEV  v2.1.5 /Users/jan/dev/senior-developer-answers-the-most-asked-questions-about-react-testing
 
 app/sanity-check.test.ts (1)
 sanity check (1)
 true is true
 
 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  23:27:28
   Duration  333ms (transform 19ms, setup 59ms, collect 4ms, tests 1ms, environment 108ms, prepare 33ms)
 
 PASS  Waiting for file changes...
       press h to show help, press q to quit

Now add React Testing Library.

npm install --save-dev @testing-library/react @testing-library/dom @testing-library/jest-dom @types/react @types/react-dom happy-dom @vitejs/plugin-react vite-tsconfig-paths

Create a vitest.config.mts file in the root of your project:

vitest.config.mts
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
 
export default defineConfig({
  plugins: [react(), tsconfigPaths()],
  server: {
    port: 3000,
  },
  test: {
    environment: 'happy-dom',
    globals: true,
    setupFiles: ['./src/tests/setup-test-environment.ts'],
    include: ['./src/**/*.{spec,test}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
    exclude: [
      '**/node_modules/**',
      '**/build/**',
      '**/postgres-data/**',
    ],
    coverage: {
      reporter: ['text', 'json', 'html'],
    },
  },
});

Then create a file called src/tests/setup-test-environment.ts to set up your test environment.

src/tests/setup-test-environment.ts
import '@testing-library/jest-dom/vitest';
 
// See https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html#configuring-your-testing-environment.
 
// @ts-expect-error globalThis is not defined in TypeScript.
globalThis.IS_REACT_ACT_ENVIRONMENT = true;

Now you are ready to write your React component tests with React Testing Library.

What does testing a React app with Jest do?

Jest is a test runner. It provides helper functions so you can write tests and executes those functions for you when you run your tests. This tutorial uses Vitest because it has the same API as Jest but runs faster. Everything you learn in this article is applicable to Jest.

The main difference in usage is that Jest is frequently configured to expose its methods globally. This means that in any file with .test.ts or .test.tsx, you can use describe, test, expect, and so on without importing them.

Vitest or Jest alone is a bad choice for writing functional tests for your React apps, though.

You should use React Testing Library, too. With RTL, you can simulate user interactions.

React Testing Library, together with Happy DOM or Jest DOM, renders your React component in a mocked browser environment for you.

Testing your React app with RTL and Jest or Vitest gives you confidence that your app works as expected.

How to run React Testing Library?

If you've set up React Testing Library with Vitest as described above, you can now run your React component tests.

src/app/greeting.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
 
function Greeting() {
  return (
    <div>
      <h1>Hello World</h1>
 
      <p>Some description</p>
    </div>
  );
}
 
describe('Greeting component', () => {
  test('renders a greeting', () => {
    render(<Greeting />);
 
    expect(
      screen.getByRole('heading', { name: /hello world/i }),
    ).toBeInTheDocument();
 
    expect(screen.getByText(/some description/i)).toBeInTheDocument();
  });
});

This example shows you how to test a function component. You use React Testing Library's render function to render the component, and then you use the screen object to query the DOM.

The same works for class components because Testing Library abstracts away the implementation details for you.

src/app/farewell.test.tsx
import { render, screen } from '@testing-library/react';
import { Component } from 'react';
import { describe, expect, test } from 'vitest';
 
class StayFocused extends Component {
  render() {
    return <h2>Stay focused!</h2>;
  }
}
 
describe('StayFocused component', () => {
  test('renders a stay focused call to action', () => {
    render(<StayFocused />);
 
    expect(
      screen.getByRole('heading', { name: /stay focused/i }),
    ).toBeInTheDocument();
  });
});

So everything you learn about React testing going forward applies to class components as well.

I want to point out the "blurriness" of test definitions here one more time. Notice how both of these tests test the component from the perspective of a user. Therefore, you can legitimately call them functional tests. But they also test the component in isolation, so they could also be called unit tests.

How to test a function in React Testing Library?

If you mean a function component, scroll back up to the part where you test the first React component, Greeting, because that's a function component.

If you're talking about functions that are being passed as props to your React components, you need to mock them. This allows you to test that your functions are called as expected.

How to mock a function in React Testing Library?

You can create a mock function with vi.fn(). In Jest, you use jest.fn().

In other words, the ability to mock has nothing to do with React Testing Library. Your respective test runner or framework provides the mock functions.

some-test.test.tsx
const onChange = vi.fn();
 
render(<SomeComponent onChange={onChange} />);

Every time you call a mock function, it stores its call arguments, returns, and instances. You can then make assertions on the mock function.

Let's use mocking and look at an example of a button to show you:

How to test a button with React Testing Library?

Create a simple button component and write a test for it.

src/app/button.test.tsx
import { render, screen } from '@testing-library/react';
import type { MouseEventHandler } from 'react';
import { describe, expect, test, vi } from 'vitest';
 
function Button({
  onClick,
}: {
  onClick: MouseEventHandler<HTMLButtonElement>;
}) {
  return <button onClick={onClick}>Click me</button>;
}
 
describe('Button', () => {
  test('given an onClick function and the button is clicked: calls the onClick function', () => {
    const onClick = vi.fn();
    render(<Button onClick={onClick} />);
 
    const button = screen.getByRole('button', { name: /click me/i });
    button.click();
 
    expect(onClick).toHaveBeenCalled();
  });
});

You mock the onClick function and then fire a click event directly on the button element via button.click(). This will call the mock, and you can assert that it was called.

Testing Library also exposes the fireEvent function, which you can use to fire events on the component.

src/app/button.test.tsx
import { fireEvent, render, screen } from '@testing-library/react';
import type { MouseEventHandler } from 'react';
import { describe, expect, test, vi } from 'vitest';
 
function Button({
  onClick,
}: {
  onClick: MouseEventHandler<HTMLButtonElement>;
}) {
  return <button onClick={onClick}>Click me</button>;
}
 
describe('Button', () => {
  test('given an onClick function and the button is clicked: calls the onClick function', () => {
    const onClick = vi.fn();
    render(<Button onClick={onClick} />);
 
    const button = screen.getByRole('button', { name: /click me/i });
    fireEvent.click(button);
 
    expect(onClick).toHaveBeenCalled();
  });
});

The difference between button.click() and fireEvent.click() lies in how they trigger the click event. button.click() directly calls the DOM element's click method, bypassing React's synthetic event system and only firing the click event. In contrast, fireEvent.click() simulates the click event using dispatchEvent, integrating with React's event system but still only triggering the click event without intermediate events like mousedown or focus.

For more realistic user interactions, you want to use the userEvent library because it simulates full user interactions. So install it.

npm install --save-dev @testing-library/user-event

Next, import it in your test and use the setup method to create a user.

src/app/button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { MouseEventHandler } from 'react';
import { describe, expect, test, vi } from 'vitest';
 
function Button({
  onClick,
}: {
  onClick: MouseEventHandler<HTMLButtonElement>;
}) {
  return <button onClick={onClick}>Click me</button>;
}
 
describe('Button', () => {
  test('given an onClick prop and the button is clicked: calls the onClick function', async () => {
    const user = userEvent.setup();
 
    const onClick = vi.fn();
 
    render(<Button onClick={onClick} />);
 
    await user.click(screen.getByRole('button', { name: /click me/i }));
 
    expect(onClick).toHaveBeenCalled();
  });
});

You can then let the user click the button and assert that the mock was called. This mimics user actions by triggering the same browser events those actions would cause. The user object exposes many other interactions, like typing, selecting options, and so on.

If the interaction with the button changes some internal state that you can observe in your user interface, then you do NOT need to mock anything.

src/app/button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { describe, expect, test } from 'vitest';
 
function Button() {
  const [count, setCount] = useState(0);
 
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Click me</button>
      <div>{count}</div>
    </div>
  );
}
 
describe('Button', () => {
  test('given the button is clicked: increments the count', async () => {
    render(<Button />);
 
    expect(screen.getByText(/0/i)).toBeInTheDocument();
    expect(screen.queryByText(/1/i)).not.toBeInTheDocument();
 
    await user.click(screen.getByRole('button', { name: /click me/i }));
 
    expect(screen.getByText(/1/i)).toBeInTheDocument();
  });
});

In this test, your component renders the current count, which you can increment by clicking the button.

Then you assert that, by default, the count is 0 and that the incremented count is NOT shown. To test for the absence of elements, you need to use the queryBy instead of the getBy query.

Finally, you use Testing Library's userEvent to click the button and assert that the count was incremented.

Of all four button tests that you saw, this one is the cleanest because it only interacts with your React component as your users would, without the need to mock, making it the most "functional" test. If you need to mock, you should still prefer using userEvent over fireEvent or the native DOM methods.

As a summary:

  • Calling methods like button.click() calls the DOM element's click method, bypassing React's synthetic event system and only firing the click event.
  • Calling fireEvent.click() simulates the click event using dispatchEvent, integrating with React's event system but still only triggering the click event without intermediate events like mousedown or focus.
  • Using userEvent simulates full user interactions, including intermediate events like mousedown or focus.

How to debug React Testing Library?

If you want to debug your tests, call the screen.debug() method.

src/app/debug.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, test } from 'vitest';
 
describe('screen.debug example', () => {
  test('demonstrates screen.debug usage', () => {
    render(<div>Hello World</div>);
 
    // Log the current state of the DOM.
    screen.debug();
 
    // Log a specific element.
    screen.debug(screen.getByText('Hello World'));
  });
});

There are two ways to use screen.debug(). Calling it without arguments will print out the whole DOM tree to the console.

<body>
  <div>
    <div>
      Hello World
    </div>
  </div>
</body>

You can see here that render implicitly wraps the component in a body and a div.

If you want to debug a specific element and its children, you can pass a selector to screen.debug().

screen.debug(screen.getByText('Hello World'));

This will print out the DOM tree around the element.

<div>
  Hello World
</div>

To debug the button test you saw before, you can also add console.logs wherever you want.

src/app/button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { MouseEventHandler } from 'react';
import { describe, expect, test, vi } from 'vitest';
 
function Button({
  onClick,
}: {
  onClick: MouseEventHandler<HTMLButtonElement>;
}) {
  return (
    <button
      onClick={event => {
        onClick(event);
        console.log('after the button click');
      }}
    >
      Click me
    </button>
  );
}
 
describe('Button', () => {
  test('given an onClick prop and the button is clicked: calls the onClick function', async () => {
    const user = userEvent.setup();
 
    const onClick = vi.fn();
 
    render(<Button onClick={onClick} />);
 
    await user.click(screen.getByRole('button', { name: /click me/i }));
 
    expect(onClick).toHaveBeenCalled();
  });
});

This will then show up in the output of your test.

stdout | app/example.test.tsx > Button > given an onClick prop and the button is clicked: calls the onClick function
after the button click
 
 app/example.test.tsx (1)
 Button (1)
 given an onClick prop and the button is clicked: calls the onClick function
 
 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  10:56:02
   Duration  153ms
 
 PASS  Waiting for file changes...
       press h to show help, press q to quit

How to mock an API in React Testing Library?

The best way is to use MSW, which stands for Mock Service Worker.

Install MSW in your project.

npm install msw --save-dev

Now you can write a test for a component called Fetcher that fetches a user from a placeholder API.

src/app/fetcher.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http } from 'msw';
import { setupServer } from 'msw/node';
import { useState } from 'react';
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
 
function Fetcher() {
  const [user, setUser] = useState<{ name: string; id: string } | null>(null);
 
  return (
    <div>
      <button
        onClick={() => {
          fetch('https://jsonplaceholder.typicode.com/users/1')
            .then(response => response.json())
            .then(setUser);
        }}
      >
        Fetch
      </button>
 
      <div>{user ? `User name: ${user.name}` : 'Please fetch the user.'}</div>
    </div>
  );
}
 
const server = setupServer(
  http.get(
    'https://jsonplaceholder.typicode.com/users/1',
    async () =>
      new Response(JSON.stringify({ name: 'Jane Doe', id: '1' }), {
        headers: { 'Content-Type': 'application/json' },
      }),
  ),
);
 
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
 
describe('Fetcher', () => {
  test("given the fetch button is clicked: renders the user's name", async () => {
    const user = userEvent.setup();
 
    render(<Fetcher />);
 
    await user.click(screen.getByRole('button', { name: /fetch/i }));
 
    expect(await screen.findByText(/user name: jane doe/i)).toBeInTheDocument();
  });
});

The Fetcher component renders a button. When clicked, it fetches a user from a placeholder API.

You use setupServer to create a mock server. The server intercepts the fetch request and returns a response you define. Intercepting requests gives you faster tests than making actual API requests and makes the response consistent.

To integrate MSW with a test runner, use the beforeAll, afterEach, and afterAll hooks to set up the following steps:

  • Start mocking before tests begin using server.listen().
  • Reset request handlers between tests with server.resetHandlers().
  • Restore native request modules after tests finish using server.close().

How to test a dropdown in React?

Create a dropdown component so you can write tests for it.

src/app/dropdown.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, test } from 'vitest';
 
function Dropdown() {
  return (
    <div>
      <label htmlFor="fruits">Pick a fruit:</label>
 
      <select name="fruits" id="fruits">
        <option value="banana">Banana</option>
        <option value="orange">Orange</option>
        <option value="apple">Apple</option>
      </select>
    </div>
  );
}
 
describe('Dropdown', () => {
  test('given the user picks values: renders the correct value', async () => {
    const user = userEvent.setup();
 
    render(<Dropdown />);
 
    expect(screen.getByDisplayValue(/banana/i)).toBeInTheDocument();
 
    expect(screen.queryByDisplayValue(/orange/i)).not.toBeInTheDocument();
 
    const select = screen.getByLabelText(/pick a fruit/i);
 
    await user.selectOptions(select, 'orange');
 
    expect(screen.getByDisplayValue(/orange/i)).toBeInTheDocument();
  });
});

The Dropdown component renders a label and a select element. In this tutorial, you're going to create it using a select element, but it will work the same way with a custom component from some component library.

Now you can use the selectOptions method from userEvent to select an option from the dropdown and assert that the correct value is rendered.

How to test useEffect in React Testing Library?

Did you know that React 18 and up runs useEffect twice on mount in strict mode? Let me know on X if you want me to write an article to break down how many React developers are using useEffect wrong.

Anyway, let's look at a correct usage of useEffect in an OfflineIndicator component.

src/app/offline-indicator.test.tsx
import { act, render, screen } from '@testing-library/react';
import { useEffect, useState } from 'react';
import { describe, expect, test } from 'vitest';
 
function OfflineIndicator() {
  const [isOffline, setIsOffline] = useState(false);
 
  useEffect(() => {
    if (typeof window !== 'undefined') {
      function handleOnline() {
        setIsOffline(false);
      }
 
      function handleOffline() {
        setIsOffline(true);
      }
 
      window.addEventListener('online', handleOnline);
      window.addEventListener('offline', handleOffline);
 
      return function cleanup() {
        window.removeEventListener('online', handleOnline);
        window.removeEventListener('offline', handleOffline);
      };
    }
  }, []);
 
  return <div>{isOffline ? 'Offline' : 'Online'}</div>;
}
 
describe('OfflineIndicator', () => {
  test('given the user switches between offline and online: renders the correct state', () => {
    render(<OfflineIndicator />);
 
    expect(screen.getByText(/online/i)).toBeInTheDocument();
 
    act(() => {
      window.dispatchEvent(new Event('offline'));
    });
 
    expect(screen.getByText(/offline/i)).toBeInTheDocument();
 
    act(() => {
      window.dispatchEvent(new Event('online'));
    });
 
    expect(screen.getByText(/online/i)).toBeInTheDocument();
  });
});

The OfflineIndicator component uses useEffect to detect when the user goes offline or online. It then renders a div element that shows a corresponding string about the state.

You can write a test for the OfflineIndicator component that checks if the component renders the correct state when the user goes offline or online. To do this, you need to simulate the user going offline and online, which you do with window.dispatchEvent. You wrap this in act to ensure all updates have been processed and applied to the DOM before you make any assertions. You're going to learn more about act in a moment. But before that, I want to point out something important.

When you test a useEffect hook inside a component, you only test what the user sees as a result of the useEffect hook. This is what Kent C. Dodds, the creator of React Testing Library, means when he says:

“Avoid testing implementation details.” - Kent C. Dodds

Avoiding testing implementation details means focusing on what the user sees instead of how the component works under the hood. Test behaviors and visible outputs, like rendered elements or text, instead of functions, hooks, or internal states. This keeps tests resilient to refactoring because they break only when functionality or user experience changes—NOT when internal code changes.

React Testing Library: When to use act?

In the example above, you used act when simulating switching between online and offline.

React handles updates asynchronously. When you change the state of a component, React schedules a re-render of the component and then decides at some point to apply the changes to the DOM.

act() ensures all updates have been processed and applied to the DOM before you make any assertions.

Testing Library wraps its handlers—such as fireEvent or userEvent—in act for you. But since dispatching the online and offline events is custom, you need to wrap them in act yourself.

How to test hooks in React?

You can test hooks without using them inside a component.

Extract the offline detection logic into a custom hook.

src/app/use-is-offline.test.tsx
import { act, renderHook } from '@testing-library/react';
import { useEffect, useState } from 'react';
import { describe, expect, test } from 'vitest';
 
export function useIsOffline() {
  const [isOffline, setIsOffline] = useState(false);
 
  useEffect(() => {
    if (typeof window !== 'undefined') {
      function handleOnline() {
        setIsOffline(false);
      }
 
      function handleOffline() {
        setIsOffline(true);
      }
 
      window.addEventListener('online', handleOnline);
      window.addEventListener('offline', handleOffline);
 
      return function cleanup() {
        window.removeEventListener('online', handleOnline);
        window.removeEventListener('offline', handleOffline);
      };
    }
  }, []);
 
  return isOffline;
}
 
describe('useIsOffline()', () => {
  test('detects whether the user is offline or not', () => {
    const { result } = renderHook(() => useIsOffline());
 
    expect(result.current).toEqual(false);
 
    act(() => {
      window.dispatchEvent(new Event('offline'));
    });
 
    expect(result.current).toEqual(true);
 
    act(() => {
      window.dispatchEvent(new Event('online'));
    });
 
    expect(result.current).toEqual(false);
  });
});

The custom useIsOffline hook is straightforward. You simply use the same useEffect hook to set up the event listeners and return the isOffline state.

To test custom hooks, you can use the renderHook function from @testing-library/react. renderHook exposes a .current property on its result object to provide access to the latest value returned by the hook being tested. You can think of result as a ref for the most recently committed value.

Apart from that, the test does exactly the same thing as the test for the OfflineIndicator component.

How to do E2E testing in React?

End-to-end (E2E) testing is the practice of testing the entire application from start to finish. That means running the application in a real browser, interacting with it as a user would, and making requests to a real database.

E2E testing is a subset of functional tests. The more realistic your functional tests are, the more you can call them E2E tests.

By definition, E2E tests do NOT care about what framework you're using. So any test written with E2E test frameworks like Playwright, Cypress, or Selenium is how you do E2E testing with React.

Here's another example E2E test for you. Again, this test will work with React as well as with raw HTML and Vanilla JavaScript.

playwright/e2e/example/playwright.spec.ts
import { expect, test } from '@playwright/test';
 
test.describe('Wikipedia Search', () => {
  test('page should let you search for "Playwright" and render an article about it', async ({
    page,
  }) => {
    // Navigate to Wikipedia's homepage.
    await page.goto('https://www.wikipedia.org/');
 
    // Select the search input field using its role and accessible name.
    const searchInput = page.getByRole('searchbox', {
      name: 'Search Wikipedia',
    });
    await searchInput.fill('Playwright');
 
    // Click the search button using its role and accessible name.
    const searchButton = page.getByRole('button', { name: 'Search' });
    await searchButton.click();
 
    // Verify that the page title contains "Playwright".
    await expect(page).toHaveTitle(/Playwright/);
 
    // Optionally, verify that the first heading matches "Playwright".
    const firstHeading = page.getByRole('heading', {
      level: 1,
      name: 'Playwright',
    });
    await expect(firstHeading).toHaveText('Playwright');
  });
});

You can write a test that spins up a real browser, visits the Wikipedia homepage, searches for "Playwright," and verifies that the page title contains "Playwright."

It is an E2E test because it uses a real browser, makes real network requests, and queries Wikipedia's database.

Learn senior fullstack secrets

Subscribe to my newsletter for weekly updates on new videos, articles, and courses. You'll also get exclusive bonus content and discounts.