A Practical Testing Strategy for 2026: Unit, Integration, and End-to-End
Table of contents
- Introduction
- The guiding principle: confidence, not friction
- The testing pyramid (and why the shape matters)
- Layer 1: Unit tests with Vitest
- Layer 2: Integration / component tests (the missing middle)
- Layer 3: End-to-end with Playwright
- Wiring it into the workflow
- Closing thoughts
Introduction
Most frontend codebases I’ve worked on start the same way: zero tests, ship fast, and “we’ll add tests later.” Then “later” arrives as a production incident. The hard part of testing was never writing the assertions—it’s deciding what to test, at which layer, and doing it in a way that adds confidence without becoming the thing everyone resents.
This article is the playbook I landed on after building a testing foundation from nothing on a real, fast-moving React app. It’s organized the way I think about it: concepts first, then the three layers—unit, integration/component, and end-to-end—each with the tool that, in 2026, is basically what every frontend dev reaches for:
- Vitest — unit + component test runner (Vite-native, fast, great DX).
- Testing Library + happy-dom — render React and assert like a user.
- MSW (Mock Service Worker) — mock the network at the boundary, not the modules.
- Playwright — end-to-end journeys in a real browser.
The goal isn’t a coverage number. It’s confidence.
The guiding principle: confidence, not friction
Before any tool, decide the philosophy. Mine, in one line: tests should make you braver about shipping, not add a gate everyone fights.
That single idea drives a lot of concrete decisions:
- No hard coverage gates. A “you must hit 80%” rule produces tests written to satisfy the number, not to catch bugs. CI runs the suite so it stays green; it does not block a merge over a percentage.
- Coverage grows per-PR. The rule is simple: every PR ships with tests for the important behavior it touched. Coverage accretes from real work instead of a mandated sprint.
- ”Important” means blast radius, not line count. Money flows, the auth gateway, the high-traffic core paths, and shared primitives reused everywhere. A formatter used in one tooltip is not the same risk as the function that maps every API error.
If you internalize one thing: test the parts that would hurt in production, and let the rest be covered opportunistically.
The testing pyramid (and why the shape matters)
The classic pyramid still holds in 2026:
/\
/ \ E2E (few, slow, high-confidence, real browser)
/----\
/ \ Integration / Component (more, medium speed)
/--------\
/ \ Unit (many, fast, pure logic)
/------------\
- Unit tests are cheap and fast—run hundreds in seconds. Pure functions, no DOM, no network.
- Integration/component tests are the missing middle in most codebases. They render components and exercise hooks against a mocked network. This is where most “it broke in prod” bugs actually live.
- E2E tests are expensive and slower, so you keep them few and reserve them for the critical journeys—but they’re the only layer that proves the whole thing works together, and they double as living documentation.
A common anti-pattern is the “ice cream cone”: lots of slow E2E, no unit/integration. You want the opposite. Push logic down to fast unit tests; use E2E only for the journeys that matter.
Layer 1: Unit tests with Vitest
Unit tests cover pure logic: formatters, validators, calculations, mappers, reducers, schema parsing. No rendering, no I/O. If a function takes inputs and returns outputs, it’s a unit-test candidate.
Vitest is the default runner now. It shares your Vite config and transforms, starts instantly, and has a Jest-compatible API, so there’s almost no learning curve.
// format-currency.test.ts
import { describe, expect, it } from "vitest";
import { formatCurrency } from "./format-currency";
describe("formatCurrency", () => {
it("formats whole dollars without decimals", () => {
expect(formatCurrency(1234)).toBe("$1,234");
});
it("abbreviates large values", () => {
expect(formatCurrency(1_500_000, { compact: true })).toBe("$1.5M");
});
});
A few hard-won lessons for the unit layer:
Pin the timezone. Date-formatting tests are a classic source of “passes on my machine, fails in CI.” new Date("2026-07-04") parses as UTC midnight, then formats to the previous day for anyone west of UTC. Pin the runner to UTC so tests are deterministic regardless of host:
// vitest.config.ts
export default defineConfig({
test: {
env: { TZ: "UTC" },
// ...
},
});
Validate your schemas. If you use Zod (and in 2026 you probably do), your schemas are your wire contract. Test that they accept the shapes the backend really sends and reject the ones it doesn’t:
it("accepts the uppercase statuses the API emits", () => {
expect(StatusSchema.safeParse("PAID").success).toBe(true);
expect(StatusSchema.safeParse("paid").success).toBe(false); // wrong casing
});
Tests find real bugs—keep the test honest. While writing a test for a number formatter I found it returned "--2K" for -2000 (it prefixed a minus and divided the already-negative number). The right move is to fix the source and keep the test asserting the correct output—never codify the bug to make the test pass. That single test paid for itself.
Layer 2: Integration / component tests (the missing middle)
This is the layer most teams skip, and it’s the highest-value one to add. Here you render real components and exercise hooks, with the network mocked at the boundary.
The 2026 stack for this:
- Vitest with
environment: "happy-dom"(lighter and faster than jsdom). - @testing-library/react to render and query like a user (
getByRole,findByText). - @testing-library/jest-dom for matchers like
toBeInTheDocument(). - MSW to intercept
fetch/XHR and return controlled responses.
A shared render helper
Components need providers (router, data-cache, theme). Wrap them once so every test isn’t boilerplate:
// test-utils.tsx
function AllProviders({ children }: { children: ReactNode }) {
return (
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>
<ThemeProvider>
<TooltipProvider>{children}</TooltipProvider>
</ThemeProvider>
</SWRConfig>
);
}
export function renderWithProviders(ui: ReactElement) {
return render(ui, { wrapper: AllProviders });
}
Note the
provider: () => new Map()on the data cache. That gives a fresh cache per render, so server-state never leaks between tests—one of the sneakiest sources of flaky integration tests.
Mocking the network with MSW
The key idea behind MSW: mock at the network boundary, not the module. You don’t stub your fetch wrapper or your hook—you intercept the actual HTTP request. That means the real client code (auth headers, error mapping, response validation) runs in the test, which is exactly what you want to cover.
// setup.ts (loaded once per test file)
import "@testing-library/jest-dom/vitest";
import { afterAll, afterEach, beforeAll } from "vitest";
import { server } from "./msw/server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
onUnhandledRequest: "error" is worth calling out: any request a test doesn’t explicitly mock fails loudly, instead of silently hitting a real endpoint or hanging.
Now a data hook test reads naturally:
it("surfaces a backend error with its code and message", async () => {
server.use(
http.post("*/v1/summary", () =>
HttpResponse.json(
{ code: "unavailable", displayable_message: "Try again later." },
{ status: 400 },
),
),
);
const { result } = renderHookWithProviders(() => useSummary());
await waitFor(() => expect(result.current.error).toBeDefined());
expect(result.current.error?.status).toBe(400);
expect(result.current.error?.message).toBe("Try again later.");
});
This single test exercises the hook, the data-fetching library, and the API client’s error mapping—far more confidence than a unit test of any one piece.
Forms and stores live here too
Two other things that belong in this layer:
- Forms (React Hook Form + Zod): assert that validation messages surface, that a valid submit calls the handler with the right data, and that the form resets.
- Global stores (Zustand and friends): you can test these directly via
getState()—they’re mostly pure logic. Just remember a store is a module singleton, so reset its state between tests (merge, don’t replace, or you’ll wipe the actions).
One gotcha: vi.mock is hoisted
If you mock a module, Vitest hoists vi.mock(...) above your imports. Declare module mocks at the top level, or they silently no-op. (Bun’s test runner doesn’t hoist the same way—so if you migrate from bun:test to Vitest, audit your mocks.)
Layer 3: End-to-end with Playwright
E2E is where you drive a real browser through a real journey: log in, navigate, click, assert. Playwright is the tool. It’s fast, reliable, and has excellent tracing.
But E2E is also where most of the operational pain lives, so here are the decisions that actually matter.
Decide where E2E runs
E2E needs a backend. If your app talks to a shared dev backend and many contributors push PRs, running E2E in CI on every PR is a recipe for flakiness and data collisions. A pragmatic stance:
- E2E is local-dev-run, not a per-PR CI gate. Each developer runs it on their machine with their own credentials (loaded from
.env). - Its value is confidence + living documentation: a green E2E run is something a human can trust without reading the diff—especially valuable when non-dev contributors are touching the codebase.
(This is a real trade-off, not a universal law. With an isolated/ephemeral test backend, CI E2E is great. With a shared one, local-first is saner.)
The golden rule: every test self-cleans
The fastest way to make everyone hate E2E is to pollute the shared environment with test data. The fix is a self-cleaning fixture: create your data via API, hand it to the test, and delete it in teardown—whether the test passes or fails.
// fixtures.ts
export const test = base.extend({
createScenario: async ({ api }, use) => {
const createdIds: string[] = [];
const create = async (input = {}) => {
const res = await api.post("/v1/thing", { data: { /* ... */ } });
const json = await res.json();
createdIds.push(json.id);
return json;
};
await use(create);
// Teardown runs on pass OR fail — nothing leaks.
for (const id of createdIds) {
await api.delete(`/v1/thing/${id}`);
}
},
});
Create data via API, not by clicking through the UI. API setup is faster and far more reliable than driving the create flow, and a UI-based teardown that runs once at the end of the suite will silently leak data the moment it fails partway through. (Ask me how I know.)
Auth: the trap with in-memory tokens
Here’s a subtle one that cost me real debugging time, and it’s increasingly common in 2026. The standard Playwright pattern is log in once, save storageState, reuse it everywhere. That works only if the auth token persists in cookies or localStorage.
Many modern apps now keep the access token in memory (a module variable) with a short-lived HttpOnly refresh-token cookie—better security, but it breaks storageState reuse:
storageStatecan’t capture an in-memory token. A reused context boots logged-out and must exchange the refresh cookie for a new token.- If the refresh token is single-use / rotated, the first context to refresh invalidates the saved cookie—so every later context gets a 401 and bounces to login.
The robust fix is to stop reusing a stale saved session: log in fresh per worker, so each worker has a live in-memory token for its own browser context.
authedPage: [
async ({ browser }, use) => {
const context = await browser.newContext(); // no storageState
const page = await context.newPage();
await loginViaUi(page); // fresh login -> live in-memory token
await use(page);
await context.close();
},
{ scope: "worker" },
],
The lesson generalizes: understand how your app actually holds auth before you design the test harness. A test setup written for localStorage tokens will quietly break the day someone moves the token in-memory.
What a journey looks like
test("redirects unauthenticated access to a protected route", async ({ page }) => {
await page.goto("/dashboard");
await expect(page).toHaveURL(/\/login/);
});
test("switches context via the account selector", async ({ authedPage }) => {
await authedPage.getByTestId("account-select-trigger").click();
await authedPage.getByPlaceholder("Search...").fill("Acme");
await authedPage.getByText("Acme").first().click();
await expect(authedPage).toHaveURL(/\/advertiser\//);
});
A small but high-leverage habit: add data-testid to the handful of elements your E2E suite keys off (the account switcher, primary submit buttons). It’s a tiny, stable affordance that saves you from brittle text/role selectors.
Wiring it into the workflow
The toolkit only delivers if it’s frictionless to run and gently enforced:
- Scripts:
test(Vitest run),test:watch,coverage, and a separatetest:e2e(Playwright). Anyone can run them in one command. - CI: run the unit + integration suite on every PR as a plain green check—no coverage gate. Optionally surface a diff-coverage comment for visibility, never as a blocker.
- Conventions in writing: document where tests live (co-located
*.test.ts(x); E2E intests/), which runner to use when, and the “every PR ships tests” norm—so the habit survives turnover. - Lint/format with something fast (Biome is my pick in 2026) so the test files stay consistent without thought.
Closing thoughts
If I compress everything into a checklist:
- Decide the philosophy first: confidence, not friction. No hard gates; grow coverage per-PR; test by blast radius.
- Unit (Vitest): pure logic, formatters, schemas, calculations. Pin
TZ=UTC. Fix bugs the tests find; never codify them. - Integration (Vitest + Testing Library + happy-dom + MSW): the missing middle—render components and hooks, mock the network at the boundary, isolate the cache per test.
- E2E (Playwright): few, critical journeys; self-clean via API; log in fresh per worker if your token lives in memory; decide local-vs-CI based on whether your backend is shared.
- Make it easy to run and gently enforced, and write the conventions down.
The technologies here—Vitest, Testing Library, happy-dom, MSW, Playwright—are, honestly, what most frontend teams will be using in 2026. But the tools are the easy part. The real value is the shape: a fast, broad base of unit and integration tests for everyday confidence, and a thin, reliable layer of E2E journeys that prove the whole thing works—and document it while they’re at it.