DEV Community

Mohamed Idris
Mohamed Idris

Posted on

Learning Frontend Testing As If You Built It Yourself

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  │
└─────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Add to package.json:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:cov": "vitest run --coverage"
  }
}
Enter fullscreen mode Exit fullscreen mode

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)}`;
}
Enter fullscreen mode Exit fullscreen mode
// 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");
  });
});
Enter fullscreen mode Exit fullscreen mode

A few things worth knowing:

  • describe groups related tests, it (or test) 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
Enter fullscreen mode Exit fullscreen mode

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,
  },
});
Enter fullscreen mode Exit fullscreen mode
// test/setup.ts
import "@testing-library/jest-dom/vitest";
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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");
  });
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. getByRole (button, heading, textbox, link, ...). Tests both rendering and accessibility.
  2. getByLabelText for form fields.
  3. getByPlaceholderText, getByText, getByDisplayValue.
  4. getByAltText, getByTitle for images and tooltips.
  5. getByTestId as 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.
  • ...All versions 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);
Enter fullscreen mode Exit fullscreen mode
// test/setup.ts
import { server } from "./mocks";
import { beforeAll, afterAll, afterEach } from "vitest";

beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Enter fullscreen mode Exit fullscreen mode

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 })));
  // ...
});
Enter fullscreen mode Exit fullscreen mode

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([]) } }
}));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/);
});
Enter fullscreen mode Exit fullscreen mode

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 no waitForSelector needed.
  • 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/4 natively. Splitting the suite across four parallel runners turns a 12 minute run into 3.
  • Trace on failure. npx playwright test --trace=retain-on-failure records 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");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 node environment, 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");
});
Enter fullscreen mode Exit fullscreen mode

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>);
}
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

Two anti patterns to avoid:

  • setTimeout or hand spun delays. Always flaky.
  • Testing useEffect directly. 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
Enter fullscreen mode Exit fullscreen mode

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-node plus cache: npm shaves real time.
  • Upload Playwright traces as artifacts on failure. Future you will thank present you.
  • Ban .only and .skip in 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-testid unless 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:

  1. Vitest reads your vite.config and starts a Vite dev server.
  2. Vite transforms your source on the fly using esbuild or SWC.
  3. Tests run in worker threads, in parallel.
  4. JSDOM provides a fake DOM. React renders into it. Events go through React's synthetic event system.
  5. Assertions run, results stream to the reporter.

What really happens when you run Playwright:

  1. Playwright launches a real browser binary (Chromium by default).
  2. It opens your test URL in a fresh context (cookies, storage are isolated).
  3. Each await is an action recorded in a trace, with snapshots and network logs.
  4. Tests run in parallel across browser contexts within a single browser instance.
  5. 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, not fireEvent. 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 --ui mode 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 renderWithProviders on day one. Tests stay lean.
  • Keep one test file per source file, named the same way (Foo.tsx paired with Foo.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)