open atlas
↑ Back to track
CI/CD pipelines CICD · 02 · 02

Vitest/Jest unit + Playwright e2e in CI

Unit tests (Vitest/Jest) must be fast, isolated, and deterministic — run vitest run, not watch, and mock the boundaries. Playwright e2e drives a real headless browser with auto-waiting locators, sharded across runners, and uploads a trace on failure.

CICD Middle ◷ 17 min
Level
FoundationsJuniorMiddleSenior

The pipeline goes red, but not because the code is wrong. A teammate’s local clock was a few hours off, an expires assertion flipped, and the unit suite started failing on a Tuesday afternoon — then passed again on its own. Two jobs over, a Playwright e2e test “randomly” times out roughly one run in five: someone added a page.waitForTimeout(2000) after a click, betting the modal would open within two seconds. On a loaded CI runner it sometimes doesn’t. The tests aren’t catching bugs anymore; they’re generating noise, and the team has started clicking “re-run” on reflex.

Unit tests: fast, isolated, deterministic

After this lesson you’ll be able to spot the two most common causes of flaky tests — a real clock and leaked shared state — and know exactly which tool fixes each.

A unit test exercises one piece of logic in memory, with no network, no real clock, and no shared state between tests. That triad — fast, isolated, deterministic — is what lets you run thousands of them on every push and trust the result. Vitest and Jest share almost the same authoring API: you group with describe, declare cases with test (or it), and assert with expect. The difference is the engine underneath. Jest is the long-standing standard with its own transformer and runner. Vitest is Vite-native — it reuses your project’s Vite/esbuild transform, so ESM and TypeScript run with near-zero extra config and the suite starts fast. Its API is deliberately Jest-compatible, which makes migration mostly a matter of swapping the runner.

import { describe, test, expect, vi, beforeEach } from "vitest";
import { priceCart } from "../src/cart";

describe("priceCart", () => {
  beforeEach(() => {
    vi.useFakeTimers();              // freeze "now" — no clock dependence
    vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
  });

  test("applies a 10% discount over $100", () => {
    expect(priceCart([{ price: 60 }, { price: 60 }])).toBe(108);
  });

  test("leaves small carts untouched", () => {
    expect(priceCart([{ price: 20 }])).toBe(20);
  });
});

The two enemies of a trustworthy unit suite are time and shared state. The clock bug from the hook is the classic case: an assertion that depends on the real Date.now() passes on your machine and fails on a runner in another timezone, or at midnight. Freeze it with vi.useFakeTimers() (Jest: jest.useFakeTimers()) so the test owns time instead of the host. Shared state is the other: if test A mutates a module singleton and test B reads it, the suite passes in one order and fails in another — and CI rarely runs the order you tested locally. Reset between cases (beforeEach, fresh fixtures) and never let one test’s leftovers become another’s input.

Mock the boundaries, not the logic

You mock to draw a hard line around the code under test: the network, the database, the filesystem, the clock, third-party SDKs. Everything across that boundary is replaced with a controllable stand-in so the test is deterministic and fast. The mistake juniors make is mocking inward — stubbing the very function they meant to verify, so the test passes no matter what the code does. Mock the boundary, exercise the logic.

import { test, expect, vi } from "vitest";
import { getUser } from "../src/user";

test("getUser maps the API payload", async () => {
  global.fetch = vi.fn().mockResolvedValue({
    ok: true,
    json: async () => ({ id: 7, full_name: "Ada" }),
  } as Response);

  const user = await getUser(7);

  expect(user).toEqual({ id: 7, name: "Ada" }); // our mapping logic, not fetch
  expect(fetch).toHaveBeenCalledWith("/api/users/7");
});

In CI you run the suite once, not in watch mode. Both runners default to a single run on a non-interactive terminal, but be explicit: vitest run (or jest --ci) performs one pass and exits with a non-zero code on failure, which is what gates the merge. Add --coverage to emit a report you can threshold or upload. The whole step is seconds of wall-clock and needs no browser, so it should be the first gate in the pipeline — fast feedback, cheap to run on every push.

- name: Unit tests
  run: |
    npm ci
    npx vitest run --coverage   # one pass; non-zero exit fails the job
Why this works

Retry policy should differ by test type. A unit test is deterministic by construction, so a flaky unit test is a real bug — in the test or the code — and retrying it just hides the defect until it bites in production. Configure zero retries for units. End-to-end tests cross a real browser, a real network, and timing you don’t fully control, so a bounded retry (e.g. retries: process.env.CI ? 2 : 0) is a reasonable hedge against genuine infrastructure flake — paired with a trace so you can tell a real failure from noise. Never paper over a flaky unit test with a retry.

Playwright e2e: a real browser, with auto-waiting

Why does the standard Playwright advice say “never use waitForTimeout”? Because a fixed sleep is a bet — and loaded CI runners lose that bet one run in five. Auto-waiting locators replace the bet with a condition.

End-to-end tests prove the assembled system works the way a user experiences it: a real browser loads your app, clicks, types, and reads what renders. Playwright is both the browser driver and the test runner. Its central feature is auto-waiting locators. A locator like page.getByRole("button", ...) is not a snapshot of the DOM at call time — it is a lazy query that Playwright re-evaluates and waits on until the element is attached, visible, and actionable before it acts, up to a timeout. That is precisely what kills the waitForTimeout(2000) flakiness from the hook: instead of betting on a fixed sleep, the locator waits for the actual condition and proceeds the instant it’s true — faster on a fast run, patient on a slow one.

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

test("user can log in", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill("ada@example.com");
  await page.getByLabel("Password").fill("correct-horse");
  await page.getByRole("button", { name: "Sign in" }).click();

  // auto-waits for the dashboard heading — no manual sleep
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
});

In CI, Playwright runs headless by default — no display server — and you must install the browser binaries plus their OS libraries first with npx playwright install --with-deps. For speed, shard the suite across runners with --shard=index/total, so a GitHub Actions matrix of three jobs each runs a third of the tests in parallel. The debugging payoff lives in artifacts on failure: configure trace: "on-first-retry" and Playwright records a full trace — DOM snapshots, network, console, and a timeline — only for tests that had to retry, which you upload so a red CI run is debuggable from your laptop instead of a guessing game.

- name: E2E tests
  run: |
    npm ci
    npx playwright install --with-deps        # browsers + OS libs, headless
    npx playwright test --shard=${{ matrix.shard }}/3
- name: Upload trace on failure
  if: failure()
  uses: actions/upload-artifact@v4
  with:
    name: playwright-trace-${{ matrix.shard }}
    path: playwright-report/

Unit vs e2e: when each runs and what it gives you

The two layers answer different questions and cost different amounts, so they sit at different points in the pipeline. The table makes the contrast concrete.

DimensionUnit (Vitest / Jest)E2e (Playwright)
What it drivesPure logic in memory; boundaries mockedA real headless browser against the running app
SpeedMilliseconds each; whole suite in secondsSeconds each; minutes — shard to parallelise
When in CIFirst gate, every push (vitest run)After units pass; install —with-deps
RetriesZero — a flaky unit is a real bugBounded on CI only (e.g. 2)
Artifacts on failureStack trace + coverage reportTrace, video, screenshot — replayable timeline
Quiz

A Playwright test times out roughly one run in five, always after a click that opens a modal. What is the most likely cause and fix?

Quiz

A unit test asserting token expiry passes locally but fails intermittently in CI. What is the root cause class and the right fix?

Pick the best fit

Your e2e suite takes 14 minutes on one runner and is becoming the slow gate. How do you cut wall-clock time without losing coverage?

Recall before you leave
  1. 01
    Why do auto-waiting locators reduce e2e flakiness compared with manual sleeps, and how does that change what you write?
  2. 02
    Walk through the e2e CI step: what must run before the tests, how do you make it faster, and what do you keep on failure?
Recap

Tests only earn their place in CI when they’re trustworthy, and the two layers earn trust differently. Unit tests (Vitest or Jest, near-identical describe/test/expect API) must be fast, isolated, and deterministic — exercise pure logic in memory, mock the boundaries (clock, network, DB) rather than the logic under test, freeze time with fake timers, and reset shared state between cases. Run them once as the first, cheapest gate with vitest run (or jest —ci) plus —coverage, and give units zero retries because a flaky unit is a real bug, not noise to retry away. Vitest’s edge over Jest is being Vite-native: ESM and TypeScript run with near-zero config and the suite starts fast. End-to-end tests (Playwright, both driver and runner) prove the assembled system from a user’s seat in a real headless browser; their core defence against flake is auto-waiting locators that wait for the actual element condition instead of a fixed waitForTimeout sleep. In CI, install browsers with —with-deps, shard across runners for parallel speed, bound retries to CI only, and upload a trace (plus video/screenshot) on failure so a red run is replayable off your machine. Cheap deterministic gate first; expensive integrated gate second; artifacts to make failures debuggable. Now when you see a timeout failing one run in five, you’ll know the first question to ask: is there a waitForTimeout hiding in there — and if so, which locator condition should replace it?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.