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.