Vitest Testing skill for Codex

Hoisted mocks, named projects, real-browser tests. Codex configures Vitest the current way: projects per environment, provider factories for browser mode, coverage that still counts untested files, and mocks that respect hoisting. It knows the Vitest 4 and 5 breaking changes, runs vitest run instead of an endless watcher, and migrates Jest suites without losing mock behavior.

  • Testing & QA
  • $vitest-testing
  • 205 lines
  • 2 files
  • ~1,950 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- vitest-testing

Installs to ~/.agents/skills/vitest-testing/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $vitest-testing, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Sets up and writes Vitest 4/5 suites - vitest.config with projects, node vs jsdom vs browser mode via @vitest/browser-playwright, vi.mock hoisting and vi.hoisted, spies and fake timers, v8 coverage thresholds, sharding and Jest migration. Use when a Vite, Nuxt, SvelteKit or TS library repo runs Vitest, when vi.mock does not apply, when moving from Jest or from the old workspace file, or when component tests need a real browser.

Use it when

  • The repo is Vite-based or already uses Vitest
  • Migrating from Jest or from a vitest.workspace file
  • Component tests need a real browser instead of jsdom
  • Coverage numbers changed after a Vitest upgrade

Use something else when

How to check the result

vitest run passes for every project, shuffled order still passes, and the coverage report lists untested files under coverage.include.

The mistake it prevents

Referencing a top-level variable inside a vi.mock factory, which is hoisted above that variable and throws; move it into vi.hoisted.

~/.agents/skills/vitest-testing/SKILL.md

--- name: vitest-testing description: Sets up and writes Vitest 4/5 suites - vitest.config with projects, node vs jsdom vs browser mode via @vitest/browser-playwright, vi.mock hoisting… license: MIT ---

Done well: one vitest.config.ts with named projects for each environment, mocks that are hoisted and typed, component tests in a real browser where DOM fidelity matters, and a coverage gate that measures the files you meant to measure.

Inspect first

  1. Read AGENTS.md, then package.json scripts and the Vitest version (npx vitest --version). Vitest 4 moved to projects, top-level pool options and provider factories; Vitest 5 requires Node 22.12+ and Vite 6.4+.
  2. Locate config: vitest.config.*, or a test block inside vite.config.*. A separate vitest.workspace.* file means a pre-v4 setup that must move into projects.
  3. Note the environment per area: pure logic (node), DOM simulation (jsdom or happy-dom), or browser.enabled.
  4. Check tsconfig.json types for vitest/globals if tests use describe without imports.

Baseline config with projects

// vitest.config.ts
import { defineConfig } from "vitest/config";
import { playwright } from "@vitest/browser-playwright";

export default defineConfig({
  test: {
    restoreMocks: true,
    coverage: {
      provider: "v8",
      include: ["src/**/*.{ts,tsx}"],        // v4+: required to report untested files
      exclude: ["src/**/*.stories.tsx", "src/**/*.d.ts"],
      thresholds: { lines: 80, branches: 75 },
    },
    projects: [
      {
        extends: true,
        test: { name: "unit", include: ["src/**/*.test.ts"], environment: "node" },
      },
      {
        extends: true,
        test: {
          name: "browser",
          include: ["src/**/*.browser.test.tsx"],
          browser: {
            enabled: true,
            provider: playwright(),
            headless: true,
            instances: [{ browser: "chromium" }],
          },
        },
      },
    ],
  },
});
  • extends: true makes a project inherit root options (default in Vitest 5).
  • Run one project: npx vitest run --project unit.
  • Browser mode downloads browsers with npx playwright install chromium; that needs network access, which may require approval in the Codex sandbox.

Environment choice

Test subjectEnvironment
Pure functions, server code, CLInode (default, fastest)
Components with simple DOM, no layout or real eventsjsdom (most compatible) or happy-dom (faster, less complete)
Layout, focus, pointer events, CSS, canvas, real fetch streaming, Web APIs jsdom lacksbrowser mode

Mocking

vi.mock is hoisted to the top of the file. Values it needs must come from vi.hoisted. In Vitest 5, vi.mock, vi.unmock and vi.hoisted must be top-level statements.

import { describe, it, expect, vi } from "vitest";
import { registerUser } from "./register";
import { sendEmail } from "./mailer";

const { sendMock } = vi.hoisted(() => ({ sendMock: vi.fn() }));

vi.mock(import("./mailer"), () => ({ sendEmail: sendMock }));

it("sends one welcome email", async () => {
  sendMock.mockResolvedValueOnce({ id: "msg_1" });
  await registerUser({ email: "[email protected]" });
  expect(sendEmail).toHaveBeenCalledOnce();
});

Partial mock with types preserved:

vi.mock(import("./config"), async (importOriginal) => {
  const actual = await importOriginal();
  return { ...actual, flags: { ...actual.flags, newCheckout: true } };
});
  • Passing import("./mod") instead of a string gives the factory typed importOriginal.
  • vi.mocked(fn) narrows a mocked import for TypeScript.
  • vi.spyOn(obj, "method") for methods on objects you hold; restoreMocks: true undoes spies before each test. Since v4, vi.restoreAllMocks() restores only spies, not automocks.
  • vi.stubEnv("API_URL", "http://test") and vi.stubGlobal("fetch", fakeFetch); enable unstubEnvs / unstubGlobals in config so they reset automatically.
  • Mocks invoked with new construct instances since v4; give class mocks a function or class implementation, not an arrow function.

Fake timers

beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

it("polls every 5 seconds until ready", async () => {
  const done = waitUntilReady(client);
  await vi.advanceTimersByTimeAsync(15_000);
  await expect(done).resolves.toBe("ready");
});
  • Use the Async variants when code awaits between timers.
  • vi.setSystemTime(new Date("2026-03-01T00:00:00Z")) for date logic.
  • For conditions that settle on their own, await vi.waitFor(() => expect(x).toBe(1)) or await expect.poll(() => store.count).toBe(3) beats arbitrary sleeps.

Browser mode tests

// Counter.browser.test.tsx
import { expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { Counter } from "./Counter";

test("increments on click", async () => {
  const screen = await render(<Counter initial={1} />);
  await screen.getByRole("button", { name: "Increment" }).click();
  await expect.element(screen.getByText("Count: 2")).toBeVisible();
});
  • Import page and userEvent from vitest/browser (older code used @vitest/browser/context).
  • expect.element(...) retries until the assertion holds; plain expect does not.
  • Vitest 5 locators match text exactly by default and toHaveTextContent needs an exact match; use toMatchTextContent for partial or regex matches.
  • render from vitest-browser-react can be awaited safely across versions; for vitest-browser-vue and vitest-browser-svelte it must be awaited in Vitest 5.

Running

npx vitest                          # watch mode, local only
npx vitest run                      # single run, use in CI and in Codex
npx vitest run src/cart -t "applies coupon"
npx vitest related src/cart.ts --run
npx vitest run --changed origin/main
npx vitest run --coverage
npx vitest run --shard=1/4 --reporter=blob   # then: npx vitest --merge-reports

Always use vitest run (or --run) in non-interactive contexts; bare vitest starts a watcher that never exits.

Migrating from Jest

  • Replace jest.fn/spyOn/mock with vi.*; jest.requireActual becomes importOriginal
  • Move values referenced by mock factories into vi.hoisted
  • jest.useFakeTimers() -> vi.useFakeTimers(); check Async timer variants
  • Set globals: true temporarily if many files lack imports; add imports over time
  • Map moduleNameMapper aliases to resolve.alias (or vite-tsconfig-paths)
  • Convert testEnvironment per file to projects or // @vitest-environment jsdom
  • Re-record snapshots only after diffing them; serializers differ slightly

Upgrading to Vitest 4 or 5

  • workspace -> projects; delete vitest.workspace.*.
  • poolOptions removed: maxThreads/maxForks -> maxWorkers; single worker is maxWorkers: 1, isolate: false.
  • coverage.all removed: set coverage.include or untested files vanish from the report and the percentage jumps.
  • basic reporter removed; use ['default', { summary: false }].
  • Vitest 5: clearMocks defaults to true; -t matches names joined with >; unawaited resolves/rejects fail the test; expect.poll rejects on timeout.

Gotchas

  • vi.mock path must match the specifier the module under test resolves to, after aliases.
  • environment: "jsdom" at root slows every pure test; scope it with projects.
  • Coverage that suddenly rises after an upgrade usually means untested files dropped out.
  • Mocking fetch with vi.stubGlobal per test is fine for one call; for whole API surfaces use MSW ($api-mocking-msw).

Verification

  1. npx vitest run for the touched project, then all projects.
  2. Re-run changed files with --sequence.shuffle to expose order dependence.
  3. npx vitest run --coverage and confirm include covers untested files.
  4. Break a mocked behavior on purpose and confirm the test fails.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Vitest Testing"
  short_description: "Hoisted mocks, named projects, real-browser tests"
  default_prompt: "$vitest-testing Set up Vitest with separate unit and browser projects for this React app"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem