Testing: Vitest & Playwright

You change one line in a helper function. Everything you can see still works, so you ship it. Three days later a support ticket says checkout is broken for anyone with a saved card — a code path you never opened once during the change. The line was fine in isolation and wrong in combination, and nothing told you until a customer did.

Tests are the thing that tells you first. Not to prove your code is correct — no test suite does that — but to give you the confidence to change code without holding the entire program in your head. A good suite turns “I think this still works” into “the machine just checked.” That is the whole return: you move faster because you’re less afraid to touch things.

This article is about the stack most JavaScript teams reach for in 2026. For years the default was a pile of separate tools glued together — Mocha to run tests, Chai for assertions, Sinon for mocks, Karma or a headless-browser wrapper for the DOM. It worked, but you assembled and maintained the glue yourself. Today two tools cover almost all of it: Vitest for unit and integration tests, and Playwright for real-browser end-to-end tests. Both are fast, both come batteries-included, and both are what you’d pick starting a project now.

This sits alongside Node & Runtimes and Build Tools — your test runner shares the same package.json, the same node_modules, and often the very same bundler.

What a test actually is

Strip away the tooling and a test is embarrassingly simple: run some code, then assert that what came out matches what you expected. If it doesn’t, fail loudly.

import { expect, test } from "vitest";

function add(a, b) {
  return a + b;
}

test("adds two numbers", () => {
  expect(add(2, 3)).toBe(5);
});

That’s a complete, runnable test. test (its alias it is identical) registers a named case. expect(value) wraps the actual result in an object whose methods — toBe, toEqual, and dozens more — are the matchers that do the comparing. If the matcher’s condition holds, the test passes silently; if not, it throws, and the runner reports a red failure with a diff.

Everything else in this article is layers on top of that core idea: how to organize thousands of these, how to fake the parts you can’t run for real, and how to drive a browser instead of a function.

The testing pyramid

Not all tests cost the same or buy the same thing. A useful mental model sorts them into three tiers by how much of the system they exercise.

E2Ea few · PlaywrightIntegrationsome · modules together, real DOMUnitmany · Vitest · pure functions & logicmilliseconds each, thousands of themfewer · slower · priciermany · fastmore like a real userhighestnarrowest
The testing pyramid. Unit tests are cheap and fast, so you write many; end-to-end tests exercise the real system and give the most confidence per test, but they're slow and brittle, so you write few. Integration tests sit in between.

Read it from the bottom up:

  • Unit tests check one piece — a function, a reducer, a class — in isolation, with its dependencies faked out. They run in milliseconds, so you can have thousands and run them on every save. When one fails it points at exactly one place. The catch: passing units don’t prove the units work together.
  • Integration tests wire several real pieces together — a component plus its hooks, a route handler plus its validation — usually still without a real network or real browser chrome. Slower and broader; they catch the seams unit tests miss.
  • End-to-end (E2E) tests drive the whole app in a real browser the way a person would: click the button, fill the form, expect the confirmation. They give the most confidence per test because they exercise the real thing, but they’re slow, they need a running app, and they break for boring reasons (a slow network, a moved button). Keep them few and focused on critical flows — login, checkout, the one journey you cannot afford to break.

Unit testing with Vitest

Vitest is the runner most new projects use. Its headline trick is that it runs your tests through Vite — the same bundler your app already uses — so your test files see TypeScript, JSX, path aliases, and CSS imports exactly the way your source does, with no separate transform pipeline to configure. It’s also just fast: it transforms only the files a test touches and runs them in worker threads. As of July 2026 the stable line is Vitest 4 (4.1.x); v3 is still maintained and v5 is in beta.

Its API is intentionally Jest-compatibledescribe, it/test, expect, beforeEach, vi for mocks. If you’ve written Jest tests they read identically, and migrating is mostly renaming jest.fn() to vi.fn(). Install it and add a script:

npm i -D vitest
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run"
  }
}

Bare vitest starts watch mode — it stays running and re-executes only the tests affected by each file you save. vitest run does a single pass and exits, which is the form CI uses.

Grouping, matchers, and setup

describe blocks group related cases and give failures a readable path. Matchers are where you say what “correct” means, and picking the right one matters — toBe checks identity (Object.is), toEqual checks deep structural equality.

import { describe, expect, it } from "vitest";
import { parsePrice } from "./price.js";

describe("parsePrice", () => {
  it("parses a plain number", () => {
    expect(parsePrice("12.50")).toBe(12.5);
  });

  it("strips currency symbols", () => {
    expect(parsePrice("$1,299.00")).toBe(1299);
  });

  it("returns null for garbage", () => {
    expect(parsePrice("banana")).toBeNull();
  });

  it("throws on an empty string", () => {
    expect(() => parsePrice("")).toThrow(/empty/);
  });
});

A few matchers you’ll use constantly: toBeTruthy / toBeFalsy, toContain (array membership or substring), toHaveLength, toMatchObject (a subset of an object’s keys), toBeCloseTo (floating point), and toThrow. For promises there’s await expect(fn()).resolves.toBe(...) and .rejects.toThrow(...).

When several tests need the same starting state, lifecycle hooks build and tear it down so no test leaks into the next:

import { afterEach, beforeEach, expect, it } from "vitest";

let cart;

beforeEach(() => {
  cart = new Cart();          // fresh state before each test
});

afterEach(() => {
  cart = null;                // clean up so tests stay independent
});

it("starts empty", () => {
  expect(cart.total).toBe(0);
});

Arrange, Act, Assert

Almost every good test has the same three-beat shape, and naming the beats keeps tests readable. Arrange the inputs and world, Act by calling the thing under test exactly once, Assert on what came back.

givenArrangeconst cart = new Cart()cart.add(book)whenActcart.checkout()exactly one callthenAssertexpect(order.total).toBe(19.99)
Arrange-Act-Assert: one test, three stages. Set up the world, perform a single action, then check the result. Keeping the 'act' to one call is what makes a failure point at one cause.
it("charges the cart total on checkout", () => {
  // Arrange
  const cart = new Cart();
  cart.add({ title: "Dune", price: 19.99 });

  // Act
  const order = cart.checkout();

  // Assert
  expect(order.total).toBe(19.99);
  expect(order.items).toHaveLength(1);
});

The blank lines are doing real work — they let a reader see the shape at a glance. When a test has three “act” steps and ten assertions spread through them, that’s usually three tests wearing a trench coat.

Test doubles: spies, mocks, and fakes

Real code talks to things you don’t want in a unit test: the network, a database, the clock, a payment API. The solution is a test double — a stand-in that looks like the real dependency but is under your control. Vitest exposes these through the vi object.

The most basic double is a spy: a fake function that does nothing but remember how it was called. You then assert on that record.

code under testnotify(user)sendEmail(…)spyvi.fn()returns a stub valuecalls: [ [“[email protected]”, “Welcome”] ]the recorded call logreal email APInever called(no network)
A spy stands in for a real dependency. The code under test can't tell the difference, but every call is recorded — arguments, count, order — so the test can assert the interaction happened correctly, without a real network or database.
import { expect, it, vi } from "vitest";

it("emails the user on signup", () => {
  const sendEmail = vi.fn();                  // a spy
  signUp({ email: "[email protected]" }, { sendEmail });

  expect(sendEmail).toHaveBeenCalledOnce();
  expect(sendEmail).toHaveBeenCalledWith("[email protected]", "Welcome");
});

vi.fn() returns a function you can pass anywhere a callback is expected. It records every call on its .mock property, and the matchers toHaveBeenCalled, toHaveBeenCalledTimes(n), toHaveBeenCalledWith(...), and toHaveBeenLastCalledWith(...) read that record. You can also give it a canned return value with vi.fn().mockReturnValue(x) or .mockResolvedValue(x) for a fake async result.

vi.spyOn wraps an existing method so you can watch it (and optionally replace it) while keeping the original available to restore:

const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
doThing();
expect(logSpy).toHaveBeenCalledWith("done");
logSpy.mockRestore();   // put the real console.log back

Mocking whole modules

When the dependency is an imported module — a database client, a fetch wrapper — vi.mock replaces the entire module for that test file. Vitest hoists these calls to the top of the file before imports run, so the mock is in place before anything imports the real thing.

import { expect, it, vi } from "vitest";
import { getUser } from "./api.js";
import { renderProfile } from "./profile.js";

vi.mock("./api.js");        // auto-mock: every export becomes a vi.fn()

it("renders the fetched name", async () => {
  vi.mocked(getUser).mockResolvedValue({ name: "Grace" });

  const html = await renderProfile(42);

  expect(getUser).toHaveBeenCalledWith(42);
  expect(html).toContain("Grace");
});

Faking time

Code that waits — debounces, retries with backoff, cache expiry, anything on setTimeout — is miserable to test for real because a test shouldn’t actually sleep for five seconds. Fake timers replace the clock with one you advance by hand. Under the hood Vitest uses @sinonjs/fake-timers, and unlike some older setups it fakes Date by default too, so Date.now() moves with your fake clock.

Advancing a fake clock instead of waiting
1/6
Variables
clock="fake"
Swap the global timers for a controllable fake clock. setTimeout no longer touches real time.

The key methods: vi.advanceTimersByTime(ms) runs everything due within that span, vi.runAllTimers() drains the queue completely, and vi.setSystemTime(date) pins “now” to a fixed moment so a test that formats today’s date is deterministic. Always pair vi.useFakeTimers() with vi.useRealTimers() in cleanup.

Snapshot tests

A snapshot test serializes a value and compares it to a stored copy from last run. The first run records the snapshot; later runs fail if the output drifts. They’re handy for large structured output — a rendered component tree, a config object — where writing out every field by hand would be tedious.

it("builds the config", () => {
  expect(buildConfig({ env: "prod" })).toMatchSnapshot();
});

Used well, snapshots catch unintended changes for free. Used badly, they become noise everyone updates blindly with --update without reading the diff — at which point they assert nothing. Keep snapshots small and reviewable, and never accept a snapshot change you don’t understand.

Coverage: a map, not a score

Coverage measures which lines, branches, and functions your tests actually executed. Vitest ships it built in — enable the v8 provider (fast, uses the engine’s own instrumentation) or Istanbul (slower, more precise branch accounting):

npm i -D @vitest/coverage-v8
npx vitest run --coverage
 % Coverage report from v8
----------|---------|----------|---------|---------
 File     | % Stmts | % Branch | % Funcs | % Lines
----------|---------|----------|---------|---------
 cart.js  |   94.1  |   85.7   |  100    |  94.1
 price.js |   100   |   100    |  100    |  100
----------|---------|----------|---------|---------

Testing components the way a user sees them

Once you’re testing UI, a fork appears: do you test the component’s internals (state, props, which method got called) or its behavior (what shows on screen, what happens when clicked)? The library that won this argument, Testing Library, has a one-line philosophy: the more your tests resemble how the software is used, the more confidence they give you. So it deliberately makes it hard to reach inside a component and easy to interact with it the way a person would.

In practice that means you query the DOM the way a user (or a screen reader) finds things — by role and accessible name, by label text, by visible text — and almost never by CSS class or test id.

closer to how users find thingsgetByRole(‘button’, { name: /save/i })role + accessible name — how assistive tech sees the page. Prefer this.getByLabelText · getByPlaceholderTextform fields, found by their labelgetByText · getByDisplayValuevisible content — good for non-interactive elementsgetByTestId(‘…’)last resort — invisible to users; use only when nothing else identifies the node
Testing Library's query priority. Prefer the ways a real user or assistive tech locates an element — its role and accessible name first. Fall back to test IDs only when nothing user-visible identifies the element.

Here’s a component test. Testing Library renders into a real DOM (Vitest supplies one via jsdom or happy-dom), and the companion package @testing-library/user-event simulates real interactions — a click that also fires focus and pointer events, typing that fires a keydown per character — far closer to a browser than dispatching a raw event.

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, it } from "vitest";
import { LoginForm } from "./LoginForm.jsx";

it("shows an error when the password is empty", async () => {
  const user = userEvent.setup();
  render(<LoginForm />);

  // find the field by its label, type into it
  await user.type(screen.getByLabelText(/email/i), "[email protected]");

  // click the button by its accessible name
  await user.click(screen.getByRole("button", { name: /log in/i }));

  // assert on what the user now sees
  expect(screen.getByRole("alert")).toHaveTextContent(/password is required/i);
});

Two details that trip people up. First, userEvent methods are async — every interaction returns a promise, so await them, and call userEvent.setup() once at the top of the test. Second, notice what this test doesn’t mention: no component state, no prop named error, no internal method. It only names things a user can perceive. That’s the payoff — you can rewrite the component’s internals completely, and as long as the visible behavior holds, the test stays green. A test coupled to internals fails on every refactor and teaches the team to fear tests.

End-to-end with Playwright

Unit and component tests fake the browser. At some point you need the real one — a genuine Chromium, Firefox, and WebKit rendering your actual deployed app, running your actual JavaScript, over a real (if local) network. That’s Playwright, Microsoft’s E2E framework and the current default for browser automation. As of July 2026 it’s at v1.61, driving Chromium 149, Firefox 151, and WebKit 26.5 from one API.

your testgoto(‘/’)getByRole( ‘link’).click()getByLabel(‘Email’) .fill(…)expect(…) .toBeVisible()drivesreal Chromium · Firefox · WebKitauto-wait before each actionretries until: attached · visible · stable · enabled · receives eventsthen acts — no sleep(), no manual timeoutrendered page, real DOM, real networkthe app exactly as a user would load it
Playwright drives a real browser through a user flow. Each step auto-waits: before clicking, it retries until the element exists, is visible, stable, and enabled — so you write the intent, not the timing.

The feature that makes Playwright pleasant is auto-waiting. The eternal curse of browser tests is timing: you click a button that isn’t rendered yet, or read text before the fetch resolves, and the test fails intermittently — a “flake.” Older tools made you sprinkle sleep(500) everywhere and pray. Playwright instead checks a set of actionability conditions before every action: is the element attached to the DOM, visible, stable (not animating), enabled, and able to receive events? It retries until they’re all true or a timeout hits. You express what to do; Playwright handles when.

import { expect, test } from "@playwright/test";

test("a visitor can subscribe to the newsletter", async ({ page }) => {
  await page.goto("/");

  // locators: lazy, re-queried on use, and the anchor for auto-waiting
  await page.getByRole("link", { name: "Newsletter" }).click();
  await page.getByLabel("Email").fill("[email protected]");
  await page.getByRole("button", { name: "Subscribe" }).click();

  // web-first assertion: retries until the text appears (or times out)
  await expect(page.getByText("Check your inbox")).toBeVisible();
});

Two concepts carry the example. A locator — from page.getByRole, getByLabel, getByText, and friends, the same accessibility-first queries Testing Library taught you — is a lazy description of an element, not a snapshot. It’s re-resolved each time you use it, so it stays valid even as the page changes underneath. And a web-first assertion, await expect(locator).toBeVisible(), auto-retries — it keeps re-checking for a moment rather than failing the instant the condition is false. That retry is what kills flakiness: you’re asserting “this becomes true,” not “this is true right now.”

Fixtures, cross-browser, and the trace viewer

The { page } destructured in the test is a fixture — Playwright’s dependency-injection system hands each test a fresh, isolated browser page so tests never bleed into each other. You can define your own fixtures for things like an already-logged-in page, so a suite doesn’t repeat the login flow fifty times.

One config runs the whole suite across every engine:

// playwright.config.js
export default {
  projects: [
    { name: "chromium", use: { browserName: "chromium" } },
    { name: "firefox", use: { browserName: "firefox" } },
    { name: "webkit", use: { browserName: "webkit" } },
  ],
};

That WebKit line matters more than it looks: it’s the closest you’ll get to testing Safari on a Linux CI box, and Safari is where cross-browser bugs love to hide.

When an E2E test does fail — especially the once-a-week flake on CI you can’t reproduce locally — the trace viewer is the payoff. Turn on tracing and Playwright records a full timeline of the run: a DOM snapshot before and after every action, network calls, console logs, and screenshots. You open the trace and scrub through the failure frame by frame, seeing exactly what the page looked like when the click landed on nothing.

npx playwright test --trace on
npx playwright show-trace trace.zip

What makes a test worth keeping

Tooling aside, the difference between a suite people trust and one they route around comes down to a few properties. Aim for every test to be:

  • Deterministic — same input, same result, every run. The number-one enemy is hidden nondeterminism: real timers, Math.random, Date.now, actual network calls, test-order dependence. Fake them. A test that fails one run in fifty is worse than no test, because it trains everyone to ignore red.
  • Isolated — no test depends on another having run first, and none leaves state behind. Fresh fixtures in beforeEach, cleanup in afterEach. Isolated tests can run in any order and in parallel.
  • Fast — the whole point of the pyramid. A unit suite should run in seconds so you run it constantly. Slowness comes from real I/O and oversized E2E tiers; both are fixable.
  • Focused — one behavior per test, named for that behavior. "returns null for garbage" beats "test parsePrice 3". When it fails, the name alone should tell you what broke.

And a word on what to test. Test behavior and contracts — the inputs and outputs other code depends on — not private implementation details. Test the branches that carry real risk: money, auth, data loss, the empty and error states everyone forgets. Skip tests that merely restate the code (expect(2).toBe(2)), tests of third-party libraries you don’t own, and tests so coupled to internals that they break on every refactor. A test you delete because it’s always wrong was never protecting you.

Running tests in CI

None of this is enforcement until it runs somewhere the author can’t skip it. On every pull request, CI runs the whole suite fresh, and a failure blocks the merge — the same idea as the lint gate in Code Quality, applied to behavior.

# .github/workflows/test.yml
name: test
on: [pull_request]
jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npx vitest run --coverage   # single pass, not watch

  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npx playwright install --with-deps   # download the browsers
      - run: npx playwright test

A few CI-specific notes. Use vitest run, never bare vitest, or the job hangs forever in watch mode. Playwright needs its browser binaries installed in the runner (playwright install --with-deps), which is the step people forget. Run the fast unit job and the slow E2E job in parallel so total wall time is the slower of the two, not their sum. And upload the Playwright trace and HTML report as a build artifact on failure — future-you debugging a CI-only flake will be grateful for the recording.

Summary

  • A test runs code and asserts on the result. Everything else — organization, mocking, browser drivers — is scaffolding around that core.
  • The testing pyramid is a budget: many fast, precise unit tests at the base; some integration tests; a few slow, high-confidence E2E tests at the top. Inverting it gives you a slow, flaky suite people ignore.
  • Vitest (v4 as of mid-2026) runs unit and component tests through Vite for speed, with a Jest-compatible describe/it/expect API, lifecycle hooks, vi.fn/vi.spyOn/vi.mock doubles, fake timers, snapshots, and built-in coverage.
  • Structure tests as Arrange-Act-Assert with a single “act,” and pick matchers deliberately — toEqual for structure, toBe for identity. Mock only the genuinely unmockable; every mock is a claim that can silently go stale.
  • Coverage is a map of blind spots, not a grade. High coverage with weak assertions tests nothing.
  • Testing Library tests components by behavior, not internals — query by accessible role and text the way a user does, drive them with async userEvent, and your tests survive refactors.
  • Playwright (v1.61) drives real Chromium, Firefox, and WebKit. Locators plus auto-waiting and auto-retrying web-first assertions kill flakiness; fixtures isolate tests; the trace viewer makes failures debuggable.
  • Good tests are deterministic, isolated, fast, and focused. Enforce them in CI with vitest run and playwright test on every pull request, and keep the traces when something fails.