Tests that exercise behaviour rather than implementation let you refactor and upgrade React with confidence.
The standard stack for a Vite app is Vitest (test runner, Jest-compatible API), React Testing Library (RTL) and user-event, running in a simulated DOM (jsdom or happy-dom). Install them and set test.environment: "jsdom" in vite.config.
RTL's philosophy: test what the user sees and does, not internals. A test typically:
- renders the component:
render(<Counter />); - queries the DOM the way a user or screen reader would, preferring
screen.getByRole("button", { name: /save/i }), thengetByLabelText,getByText; test ids are a last resort; - interacts with
userEvent:const user = userEvent.setup(); await user.click(button); await user.type(input, "hi"); - asserts on the output, often with
@testing-library/jest-dommatchers liketoBeInTheDocument()ortoHaveValue().
Query variants matter: getBy... throws if nothing matches, queryBy... returns null (for asserting absence), and findBy... returns a promise that waits, which is what you need for anything async, like data that appears after a fetch.
Mock at the network boundary rather than mocking your own Hooks: MSW (Mock Service Worker) intercepts fetch and returns fixtures, so the component's real data-loading code runs. Wrap components that need context or a router in the same providers they use in the app (a custom renderWithProviders helper).
Avoid asserting on state values, Hook calls or component instances; those are implementation details that break on refactors. For pure logic (reducers, formatters) plain unit tests are simplest. End-to-end tests with Playwright cover whole flows in a real browser.
Example
// Counter.test.jsx (run with: npx vitest)
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/vitest";
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</>
);
}
describe("Counter", () => {
it("increments when the button is clicked", async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole("button", { name: /increment/i }));
await user.click(screen.getByRole("button", { name: /increment/i }));
expect(screen.getByText("Count: 2")).toBeInTheDocument();
expect(screen.queryByText("Count: 0")).not.toBeInTheDocument();
});
});Remember
- Render, query by role/label, interact with user-event, assert on what the user sees
getBythrows,queryByreturns null,findBywaits for async UI- Mock the network (MSW), not your components' internals
Watch out for
- Using
getBy...right after triggering an async load; useawait findBy...so the test waits.