Trust is earned, not given

A different perspective

2026-04-14 · Projects

React.js, part 18: testing modern React — React Testing Library, Vitest, and queries that survive refactors

Part 18from the React.js series · 20 parts in all

Modern React made a lot of old testing advice wrong: act warnings, shallow rendering, and snapshot-everything all aged badly once components suspended, streamed, and ran actions. Part 18 is a testing setup that keeps working across those changes, built on one idea — test the component the way a user meets it.

The stack

npm i -D vitest @testing-library/react @testing-library/user-event \
        @testing-library/jest-dom jsdom
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: ['./vitest.setup.ts'],   // imports @testing-library/jest-dom
    globals: true,
  },
});

Query by role, not by test id

The queries that survive a refactor are the ones a screen reader would use. Prefer getByRole, getByLabelText, and visible text; a test that breaks when you rename a CSS class was never testing behavior:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';

it('submits the signup form', async () => {
  const user = userEvent.setup();
  render(<SignupForm />);

  await user.type(screen.getByLabelText(/email/i), '[email protected]');
  await user.click(screen.getByRole('button', { name: /subscribe/i }));

  // findBy* waits - no manual act(), no arbitrary timeouts.
  expect(await screen.findByText(/check your inbox/i)).toBeInTheDocument();
});

user-event dispatches the full event sequence a real browser would (focus, pointer, keyboard), which is why it catches bugs that a single synthetic click does not.

Suspense and async components

Components that suspend need a boundary in the test tree, and the assertion should wait for the resolved content rather than mocking the loading state away:

render(
  <Suspense fallback={<p>loading</p>}>
    <InvoiceTable customerId="c1" />
  </Suspense>
);

expect(screen.getByText('loading')).toBeInTheDocument();
expect(await screen.findByRole('table')).toBeInTheDocument();

Server Actions and network

Mock the module boundary, not React. With Vitest that is a one-liner, and it lets you test the action's success and failure branches without a server:

vi.mock('@/app/actions', () => ({
  subscribe: vi.fn(async () => ({ ok: true })),
}));

The rule underneath all of it: assert on what the user can perceive, wait with findBy* instead of waitFor plus a timeout, and never assert on component internals. Next: putting the React Compiler into production and measuring whether it actually helped.