If you have ever pushed a "tiny" change to production on a Friday and watched the bug reports roll in over the weekend, you know why testing exists. Manual checking does not scale. A teammate refactors a util, your screen still looks fine, and three pages you never opened are quietly broken.
Tests are how you stop being scared of your own code.
That is the gap testing fills.
What is frontend testing, really
Think of tests as a tiny robot that opens your app for you, faster and more thorough than you ever could. The robot clicks, types, waits, asserts. It does this on every commit. When something breaks, the robot tells you within seconds.
There are three sizes of robot, and a senior frontend engineer uses all three on purpose:
- Unit tests poke a single function. Tiny, fast, run by the thousands.
- Component / integration tests mount one component (or a small tree) and interact with it. Real DOM, real events, mocked network.
- End to end tests drive a real browser through a real app. Slow, expensive, the closest thing to a user.
The 2026 sweet spot in the React world: Vitest for the first two, Playwright for the third. React Testing Library for the assertions and queries inside Vitest.
That is the whole vibe.
Let's pretend we are building one
We want a frontend testing setup that is fast in development, accurate in CI, and gives us confidence to refactor without panic. We will not build it from scratch. We will assemble it from three modern tools.
For the running example, we are testing pieces of an "Adopt a cat" app: a formatPrice helper, an AdoptForm component, and the full happy path of finding and adopting a cat.
Decision 1: Three layers, three tools
┌─────────────────────────────────────────────────┐
│ E2E tests Playwright ~few │
│ ────────────── │
│ Component tests Vitest + RTL ~many │
│ ────────────── │
│ Unit tests Vitest ~lots │
└─────────────────────────────────────────────────┘
The number of tests should look roughly like that triangle. Lots of cheap unit tests, a healthy middle of component tests, a small number of expensive E2E tests for the critical paths.
The trap senior engineers warn about: a flat shape, where you have hundreds of E2E tests and almost no unit or component tests. Your CI takes 40 minutes, every flaky test wastes the team's morning, and a small refactor breaks 60 tests at once.
Decision 2: Unit tests with Vitest
Install:
npm i -D vitest
Add to package.json:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:cov": "vitest run --coverage"
}
}
A unit test is a function that calls another function and asserts the result.
// src/lib/format-price.ts
export function formatPrice(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
// src/lib/format-price.test.ts
import { describe, it, expect } from "vitest";
import { formatPrice } from "./format-price";
describe("formatPrice", () => {
it("formats whole dollars", () => {
expect(formatPrice(500)).toBe("$5.00");
});
it("formats cents with two decimals", () => {
expect(formatPrice(1234)).toBe("$12.34");
});
it("handles zero", () => {
expect(formatPrice(0)).toBe("$0.00");
});
});
A few things worth knowing:
-
describegroups related tests,it(ortest) is one test. -
expect(x).toBe(y)checks strict equality..toEqual(y)for deep equality..toBeTruthy(),.toContain(),.toThrow()for the rest. - Vitest uses Vite's config, so your aliases, plugins, and TypeScript setup work for free.
- Tests run in parallel by default, so write them isolated. No shared global state.
The senior level habits:
- Test the contract, not the implementation. Same input, same output. Refactors should not break unit tests.
- One assertion per test in spirit. A test that checks five different things is five tests in a trench coat.
- Name tests as sentences. "formats whole dollars" reads better than "test1".
- Skip happy path snapshots. A snapshot of a function output is fine. A snapshot of a 200 line HTML tree is a tax you will pay forever.
Decision 3: Component tests with React Testing Library
Install:
npm i -D @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
Configure Vitest for the DOM:
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./test/setup.ts"],
globals: true,
},
});
// test/setup.ts
import "@testing-library/jest-dom/vitest";
Now you can mount components and interact with them:
// src/components/AdoptForm.tsx
"use client";
import { useState } from "react";
export function AdoptForm({ onAdopt }: { onAdopt: (name: string) => void }) {
const [name, setName] = useState("");
return (
<form onSubmit={(e) => { e.preventDefault(); if (name) onAdopt(name); }}>
<label htmlFor="name">Cat name</label>
<input id="name" value={name} onChange={(e) => setName(e.target.value)} />
<button type="submit" disabled={!name}>Adopt</button>
</form>
);
}
// src/components/AdoptForm.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { AdoptForm } from "./AdoptForm";
describe("AdoptForm", () => {
it("disables the submit button when the name is empty", () => {
render(<AdoptForm onAdopt={vi.fn()} />);
expect(screen.getByRole("button", { name: /adopt/i })).toBeDisabled();
});
it("calls onAdopt with the typed name", async () => {
const user = userEvent.setup();
const onAdopt = vi.fn();
render(<AdoptForm onAdopt={onAdopt} />);
await user.type(screen.getByLabelText(/cat name/i), "Mochi");
await user.click(screen.getByRole("button", { name: /adopt/i }));
expect(onAdopt).toHaveBeenCalledWith("Mochi");
});
});
The single most important rule of React Testing Library:
Find elements the way a user would. By role, by label, by visible text. Almost never by class name or test id.
The query priority, from best to worst:
-
getByRole(button,heading,textbox,link, ...). Tests both rendering and accessibility. -
getByLabelTextfor form fields. -
getByPlaceholderText,getByText,getByDisplayValue. -
getByAltText,getByTitlefor images and tooltips. -
getByTestIdas a last resort.
If your tests cannot find a button by its role, that is a sign the markup is not accessible. Tests guide you toward better HTML.
The four flavors of query:
-
getBy...throws if not found. Use for things that should be there now. -
queryBy...returns null if not found. Use for asserting absence:expect(queryByText("Loading")).toBeNull(). -
findBy...is async, retries until it appears. Use for things that show up after a state change or a fetch. -
...Allversions return arrays for matching multiple.
Decision 4: Mocking the network, the right way
Real components fetch data. In tests you do not want to hit a real API. Two modern choices:
MSW (Mock Service Worker), the senior favorite
MSW intercepts fetch calls at the network layer, so your component code uses real fetch but the response comes from your handlers.
// test/mocks.ts
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
export const handlers = [
http.get("/api/cats", () =>
HttpResponse.json([{ id: "1", name: "Mochi" }])
),
http.post("/api/adopt/:id", () => HttpResponse.json({ ok: true })),
];
export const server = setupServer(...handlers);
// test/setup.ts
import { server } from "./mocks";
import { beforeAll, afterAll, afterEach } from "vitest";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Now any fetch("/api/cats") in any test returns the mocked data. You can override per test:
import { http, HttpResponse } from "msw";
import { server } from "../test/mocks";
it("shows an error when the API fails", async () => {
server.use(http.get("/api/cats", () => new HttpResponse(null, { status: 500 })));
// ...
});
MSW is the closest thing to "real network" you can get without one. The same handlers work in dev (with the browser worker), in tests (with the Node server), and in Storybook.
Module mocks for everything else
For non network dependencies, use Vitest's vi.mock:
import { vi } from "vitest";
vi.mock("@/lib/db", () => ({
db: { post: { findMany: vi.fn().mockResolvedValue([]) } }
}));
Use sparingly. A test full of mocks tests the mocks, not the code.
Decision 5: End to end tests with Playwright
Install:
npm init playwright@latest
Playwright spins up real Chromium, Firefox, and WebKit, drives them through your app, and asserts what the user sees. It is the gold standard for E2E in 2026.
A single test of the adoption flow:
// e2e/adopt.spec.ts
import { test, expect } from "@playwright/test";
test("a user can adopt a cat", async ({ page }) => {
await page.goto("/cats");
await expect(page.getByRole("heading", { name: /cats looking for a home/i })).toBeVisible();
await page.getByRole("button", { name: "Adopt Mochi" }).click();
await expect(page.getByText("Mochi is going home")).toBeVisible();
await expect(page).toHaveURL(/\/cats\/adopted/);
});
The senior level habits:
-
Use the same query priorities as RTL.
getByRole,getByLabel,getByText. Skip CSS selectors when you can. -
Web first assertions auto retry.
await expect(locator).toBeVisible()keeps trying until the timeout. Almost nowaitForSelectorneeded. - Test the critical path, not every page. 20 to 30 well chosen E2E tests is plenty for most apps. The unit and component layers cover the rest.
-
Run against a real build (
next build && next start) in CI, not the dev server. You catch problems specific to production builds (missing"use client", env var issues, route handler bugs). -
Shard E2E in CI. Playwright supports
--shard=1/4natively. Splitting the suite across four parallel runners turns a 12 minute run into 3. -
Trace on failure.
npx playwright test --trace=retain-on-failurerecords a video, screenshots, and network for every failing test. Debugging gets ten times faster.
A few useful Playwright extras:
await page.locator("input[name=name]").fill("Mochi"); // fill a field
await page.getByRole("button", { name: "Adopt" }).click(); // click
await page.waitForURL("/dashboard"); // wait for navigation
await page.screenshot({ path: "fail.png" }); // screenshot on demand
await expect(page.getByText("Loading")).toHaveCount(0); // assert it disappeared
await expect(page.getByRole("alert")).toContainText("Saved");
For visual regression, Playwright has a built in toHaveScreenshot() that diffs against a stored baseline. Great for design system primitives.
Decision 6: A modern test pyramid for a real React app
A repeatable target for any new project:
unit: lots tests/lib/**/*.test.ts
component: per feature tests/components/**/*.test.tsx
e2e: critical paths e2e/**/*.spec.ts
What to test where:
- Pure functions, formatters, parsers, validators: unit.
- Single component behavior with props and events: component.
- A page or a feature working end to end (auth, checkout, signup, search): E2E.
-
Server actions, API routes, server queries: integration tests with a real test database (or a transactional rollback per test). Use Vitest with
nodeenvironment, not jsdom. - Skip: testing internal state, testing your component library, testing TypeScript types, testing third party hooks.
Decision 7: Make tests easy to read
The single best style guide for tests, in one rule:
A failing test should tell you what is wrong without you reading the code.
Two patterns help:
Arrange / Act / Assert
it("calls onAdopt with the typed name", async () => {
// Arrange
const user = userEvent.setup();
const onAdopt = vi.fn();
render(<AdoptForm onAdopt={onAdopt} />);
// Act
await user.type(screen.getByLabelText(/cat name/i), "Mochi");
await user.click(screen.getByRole("button", { name: /adopt/i }));
// Assert
expect(onAdopt).toHaveBeenCalledWith("Mochi");
});
Custom render helpers
Wrap once, reuse everywhere. Providers (router, query client, theme, i18n) live in one place:
// test/render.tsx
import { render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export function renderWithProviders(ui: React.ReactElement) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
Now every component test imports renderWithProviders and the noise stays out of the test body.
Decision 8: Testing async UI without flakes
Async tests fail intermittently when they assert at the wrong moment. The fix is to ask the testing library to wait for the right thing:
// wait for a thing to appear
expect(await screen.findByText("Adopted")).toBeInTheDocument();
// wait for a thing to disappear
await waitForElementToBeRemoved(() => screen.queryByText("Loading"));
// wait for any condition
await waitFor(() => {
expect(onAdopt).toHaveBeenCalled();
});
Two anti patterns to avoid:
-
setTimeoutor hand spun delays. Always flaky. -
Testing
useEffectdirectly. Test the user visible result, not the hook internals.
Decision 9: CI integration and speed
A CI pipeline that respects developers:
# .github/workflows/test.yml (sketch)
test:
- run: npm ci
- run: npm run lint
- run: npm run test:run -- --reporter=github
- run: npx playwright install --with-deps
- run: npm run build
- run: npx playwright test --shard=${{ matrix.shard }}/4
The order matters: lint first (cheapest), unit and component tests next (fast), build, then E2E (slowest, sharded).
The senior level habits:
- Fail fast. If lint fails, do not run E2E.
-
Cache dependencies.
actions/setup-nodepluscache: npmshaves real time. - Upload Playwright traces as artifacts on failure. Future you will thank present you.
-
Ban
.onlyand.skipin CI with a lint rule. They sneak in.
Decision 10: Senior level moves and pitfalls
A short list of habits that separate "writes tests" from "writes good tests":
- Test behavior, not structure. A test that breaks when you change the markup but not the behavior is testing too much.
-
Avoid
data-testidunless you must. They drift from the user experience. - Write the test first when the bug is hard. A failing test pinpoints the problem and stays as a regression guard.
- Never test third party libraries. They have their own tests.
- Test the boundary, not the internals. A component takes props, renders, and emits events. Test those.
- Keep tests independent. Order should not matter. No shared mutable state.
-
Mock at the network, not at every module. MSW > a forest of
vi.mock. - Run a real database in integration tests. SQLite in memory, Postgres in Docker, Mongo Memory Server. Mocked databases lie.
- Coverage is not a goal. 80 high quality tests beat 200 noisy ones.
- Delete tests that no longer earn their keep. A test you mute every other week is a tax, not an asset.
A peek under the hood
What really happens when you run vitest:
- Vitest reads your
vite.configand starts a Vite dev server. - Vite transforms your source on the fly using esbuild or SWC.
- Tests run in worker threads, in parallel.
- JSDOM provides a fake DOM. React renders into it. Events go through React's synthetic event system.
- Assertions run, results stream to the reporter.
What really happens when you run Playwright:
- Playwright launches a real browser binary (Chromium by default).
- It opens your test URL in a fresh context (cookies, storage are isolated).
- Each
awaitis an action recorded in a trace, with snapshots and network logs. - Tests run in parallel across browser contexts within a single browser instance.
- On failure, traces are saved, screenshots are taken, the next test still runs.
Two consequences for your time:
- Vitest is fast because Vite is fast. Cold start in milliseconds, hot reload of tests as you edit.
- Playwright is slow because browsers are slow. Use it sparingly, run in parallel, shard in CI.
Tiny tips that will save you later
-
screen.debug()prints the current DOM. Use it when you cannot find a node. -
logRoles(container)lists every accessible role. Pick the one your test should query. -
Use
userEvent, notfireEvent. It simulates real user actions including focus, keypresses, and accessible interactions. - Reset MSW handlers between tests. Otherwise tests bleed into each other.
-
Run Playwright in
--uimode locally. The time travel debugger is magical. - Snapshot only stable, narrow output. A function output, a serialized state. Not a 5000 character DOM string.
-
Write a small
renderWithProviderson day one. Tests stay lean. -
Keep one test file per source file, named the same way (
Foo.tsxpaired withFoo.test.tsx). Easy to find.
Wrapping up
So that is the whole story. We were tired of being scared of our own code. We built a three layer testing pyramid: lots of unit tests with Vitest, a healthy middle of component tests with React Testing Library, a small number of E2E tests with Playwright. We mocked the network with MSW, queried elements the way a user would, asserted behavior instead of internals, and let CI catch what we missed.
We learned to write tests as sentences, structure them as Arrange / Act / Assert, keep them independent, and delete the ones that stopped earning their keep. We chose tools that pay back our time: Vitest for speed, RTL for accessibility friendly queries, Playwright for real browser confidence, MSW for sane network mocks.
Once that map is in your head, tests stop feeling like a tax and start feeling like a backup brain that catches the regressions a tired Friday you would have shipped. You start refactoring without flinching. You start sleeping better.
Happy testing, and may your suite stay green.
Top comments (0)