Last 30 Days
No notifications
Testing ensures your UI works as expected and prevents regressions. Modern frontend testing focuses on testing behavior (what the user sees and does) rather than implementation details.
| Level | Tool | Speed | Scope |
| Unit tests | Vitest | โก Fast | Individual functions |
| Component tests | React Testing Library | ๐ Fast | Single components |
| Integration tests | RTL + MSW | ๐ Medium | Feature flows |
| E2E tests | Playwright / Cypress | ๐ข Slow | Full app |
> "The more your tests resemble the way your software is used, the more confidence they can give you." โ Kent C. Dodds
RTL provides utilities to:
render( )getByRole, getByText, getByLabelTextfireEvent.click(), userEvent.type()expect(element).toBeInTheDocument()| Priority | Query | Example |
| 1st | getByRole | getByRole("button", { name: "Submit" }) |
| 2nd | getByLabelText | Form inputs |
| 3rd | getByText | Non-interactive text |
| 4th | getByTestId | Last resort |
Vitest is Vite-native, Jest-compatible, and blazing fast. It supports ESM, TypeScript, and JSX out of the box with a familiar API: describe, it, expect, vi.fn().
Tests give you the courage to change code. Without them, every refactor is a gamble โ does this still work? Did I break the cart? With tests, you press save, hit run, and the green ticks tell you the app still does what users need.
The mindset shift: you're not testing functions, you're testing behaviour. "When the user clicks Buy, the cart shows 1 item." That's a test. The shape of the internal state isn't.
| Layer | What it tests | Tool | Speed |
| Unit | A single function or pure component | Jest / Vitest | Fastest, ms |
| Integration | A component + its children + a fake API | RTL + MSW | Fast, 10s of ms |
| E2E | Real browser, real backend, real flow | Playwright / Cypress | Slow, seconds |
You want lots of unit tests, enough integration tests, and a handful of critical-path E2E tests. Inverting that pyramid is the single most common mistake.
Vitest is the modern default for Vite/Next projects (Jest's API, much faster). Same syntax either way:
import { describe, it, expect } from "vitest";function add(a: number, b: number) { return a + b; }
describe("add", () => {
it("adds two numbers", () => {
expect(add(2, 3)).toBe(5);
});
it("handles negatives", () => {
expect(add(-1, 1)).toBe(0);
});
});
Run with vitest (watch mode) or vitest run (one-shot for CI).
expect(x).toBe(5); // strict equality (===)
expect(obj).toEqual({ a: 1 }); // deep equality
expect(arr).toContain("apple");
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenCalledWith("hello");
expect(value).toBeTruthy();
expect(value).toBeNull();
expect(promise).rejects.toThrow("Boom");
expect(node).toBeInTheDocument(); // from jest-dombeforeEach(() => { /* reset before each test */ });
afterEach(() => { /* clean up after each */ });
beforeAll(() => { /* once before all */ });
afterAll(() => { /* once after all */ });Always reset shared state (mocks, fake timers, the DOM) between tests โ flaky tests almost always come from forgetting this.
RTL renders a real React tree into JSDOM and gives you queries that mimic how a user finds things on the page.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>Count: {n}</button>;
}
it("increments on click", async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole("button"));
expect(screen.getByRole("button")).toHaveTextContent("Count: 1");
});
Notice we never asked about state. We clicked like a user, then asserted what the user would see. That's RTL's whole philosophy.
1. getByRole โ what assistive tech sees (button, heading, textbox)
2. getByLabelText โ form fields by their label
3. getByPlaceholderText โ fallback for unlabelled inputs
4. getByText โ for visible content
5. getByTestId โ last resort, when nothing semantic exists
If you reach for getByTestId first, your component probably has accessibility issues.
1. Testing implementation details ("the state is now {count: 1}") โ refactor breaks the test, app still works. Test outputs/behaviour instead.
2. Querying with CSS selectors / class names โ those change with styling. Use roles & text.
3. Using fireEvent instead of userEvent โ userEvent simulates real user actions (focus, hover, key delays). Default to it.
4. Asserting before async work finishes โ use findBy* or waitFor for things that appear later.
5. Mocking everything โ over-mocking gives green tests on broken apps. Only mock the network and external SDKs.
6. Chasing 100% coverage โ coverage is a smoke detector, not a goal. 70% on critical paths beats 95% on getters.
waitForAnything that appears after a useEffect or fetch is asynchronous. RTL has three flavours:
screen.getByText("Loaded"); // throws immediately if missing
screen.queryByText("Loaded"); // returns null if missing (good for "not present")
await screen.findByText("Loaded"); // waits up to 1 sFor a custom condition:
await waitFor(() => {
expect(mockSave).toHaveBeenCalledWith({ id: 1 });
});MSW (Mock Service Worker) intercepts fetch/XHR at the network layer โ your component code stays untouched.
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";const server = setupServer(
http.get("/api/users", () => HttpResponse.json([{ id: 1, name: "Asha" }]))
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Now any fetch("/api/users") in your component receives the fake response. Same handlers can power your dev environment.
import { vi } from "vitest";const onClick = vi.fn();
render(<Button onClick={onClick} />);
await user.click(screen.getByRole("button"));
expect(onClick).toHaveBeenCalledOnce();
// Mock an entire module
vi.mock("../api", () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1 }),
}));
vi.useFakeTimers();
render(<SearchBox />);
await user.type(screen.getByRole("textbox"), "react");
vi.advanceTimersByTime(300); // skip the 300 ms debounce
expect(searchSpy).toHaveBeenCalledWith("react");
vi.useRealTimers();Fake timers turn slow tests into instant tests, and make race conditions deterministic.
render for ProvidersApps usually need a router, theme, store, and query client around every test. Wrap once:
function renderWithProviders(ui: ReactElement) {
return render(
<QueryClientProvider client={new QueryClient()}>
<ThemeProvider><MemoryRouter>{ui}</MemoryRouter></ThemeProvider>
</QueryClientProvider>
);
}Now every test starts with one line: renderWithProviders(.
expect(container).toMatchSnapshot();Great for stable presentational components, terrible for everything else (every CSS tweak breaks them and people start blindly updating snapshots). Reach for inline assertions by default.
import { renderHook, act } from "@testing-library/react";const { result } = renderHook(() => useCounter());
act(() => { result.current.increment(); });
expect(result.current.count).toBe(1);
act wraps any state update so React can flush effects before assertions.
import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);it("has no a11y violations", async () => {
const { container } = render(<MyForm />);
expect(await axe(container)).toHaveNoViolations();
});
Catches missing labels, low contrast, missing landmarks โ for free.
Playwright drives a real browser, perfect for the 5 critical flows in your app (sign up, log in, checkout, post creation, search).
import { test, expect } from "@playwright/test";test("user can log in", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("a@b.com");
await page.getByLabel("Password").fill("hunter2");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL("/dashboard");
await expect(page.getByRole("heading")).toContainText("Welcome");
});
Auto-waits, screenshots on failure, traces, parallel runs across Chromium / Firefox / WebKit.
Tools like Percy, Chromatic (for Storybook) or Playwright's toHaveScreenshot() snapshot the rendered UI as an image and diff it against the baseline. Catches "the button shifted 2 px and now overlaps the price" โ the kind of bug unit tests never see.
--shard=1/4).1. Add Vitest to a project, write 5 unit tests for pure helpers.
2. Render a form with RTL, fill it via userEvent, assert the success message appears.
3. Replace a manual vi.mock("./api") with MSW โ feel the test become more realistic.
4. Add one Playwright test for your most important flow (signup or checkout). Watch it auto-screenshot when it fails.