frontend / testing / 02_vitest_vs_jest.md

Vitest vs Jest — Runners and Setup

6 min read source

Vitest vs Jest — Runners and Setup

TL;DR

Vitest is the modern default for Vite-based projects — same config, same transformers, fast, ESM-native, Jest-compatible API. Jest is the incumbent — broader plugin ecosystem, what existing codebases use. New projects on Vite → Vitest. Existing projects on Jest with no pain points → no need to migrate. The actual writing of tests is nearly identical; the differences are in startup, ESM/TS handling, and configuration ergonomics.

Interview Q&A

Q: Why does Vitest exist?

A: Jest pre-dates ESM. Its transformer (Babel) re-parses your code through its own pipeline, separate from your app’s Vite/esbuild build. Result: slow startup, ESM friction, two configs to maintain.

Vitest reuses your app’s Vite config — same TS handling, same path aliases, same plugins. Tests run on the same toolchain as the app. Cold starts in hundreds of milliseconds; watch mode is near-instant.

Q: API differences?

A: Almost none. Vitest’s API is Jest-compatible:

// Identical in both
import { describe, it, expect, beforeEach, vi /* or jest */ } from "vitest";

describe("counter", () => {
  beforeEach(() => { /* setup */ });
  it("starts at 0", () => {
    expect(counter()).toBe(0);
  });
});

Differences:

  • vi instead of jest for the mocking API (vi.fn(), vi.mock(), vi.spyOn()).
  • vitest.config.ts instead of jest.config.js.
  • A few less-common matchers differ in naming.

A Jest test file usually runs unchanged in Vitest with globals: true enabled in config (which gives you describe/it/expect as globals like Jest).

Q: Minimal Vitest setup for a Vite + React + TS project?

A: Already have vite.config.ts. Add Vitest to it:

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",                  // or "happy-dom" (faster)
    setupFiles: "./test/setup.ts",
    globals: true,                         // enables describe/it/expect as globals
    coverage: {
      provider: "v8",
      reporter: ["text", "html", "lcov"],
    },
  },
});
// test/setup.ts
import "@testing-library/jest-dom/vitest";       // matchers like toBeInTheDocument
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";

afterEach(() => cleanup());                       // tear down DOM between tests
// package.json
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest --coverage"
  }
}

That’s it. vitest (interactive watch) and vitest run (one-shot for CI) are your two main commands.

Q: jsdom vs happy-dom?

A: Both are JS-based DOM implementations. happy-dom is faster (~2-3×) but younger and slightly less complete. jsdom is the de-facto standard, slower but battle-tested.

jsdom happy-dom
Speed baseline ~2-3x faster
Maturity high medium
Spec compliance high high but lags newer features
Default in most setups Vitest’s recommendation for new projects

Start with happy-dom; switch to jsdom if you hit a missing feature. Both miss things — see “stubbing browser APIs” below.

Q: How do you stub missing browser APIs?

A: Both jsdom and happy-dom lack IntersectionObserver, ResizeObserver, matchMedia, and a few others. Add stubs in setup:

// test/setup.ts
import { vi } from "vitest";

global.IntersectionObserver = vi.fn().mockImplementation(() => ({
  observe: vi.fn(),
  unobserve: vi.fn(),
  disconnect: vi.fn(),
}));

Object.defineProperty(window, "matchMedia", {
  writable: true,
  value: vi.fn().mockImplementation((query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
  })),
});

For more specialized cases, use libraries like @testing-library/jest-dom, jest-canvas-mock, etc.

Q: Mocking — vi.mock and vi.fn?

A:

// Mock a module
import { fetchUser } from "@/api/users";
vi.mock("@/api/users");

beforeEach(() => {
  vi.mocked(fetchUser).mockResolvedValue({ id: 1, name: "Ada" });
});

// Spy without replacing
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
// ...
spy.mockRestore();

// Stand-alone fn
const callback = vi.fn();
callback("hello");
expect(callback).toHaveBeenCalledWith("hello");

vi.mock("module") is hoisted to the top of the file (like Jest’s jest.mock). The mock applies to all imports of the module in this test file.

Don’t vi.mock your own data-fetching layer if you can use MSW — see 05_msw_network_mocking.md. Mocking modules ties tests to implementation.

Q: vi.useFakeTimers() — what’s it for?

A: Control time deterministically:

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

it("debounces", () => {
  const fn = vi.fn();
  const debounced = debounce(fn, 300);
  debounced();
  expect(fn).not.toHaveBeenCalled();
  vi.advanceTimersByTime(300);
  expect(fn).toHaveBeenCalledOnce();
});

Fake timers replace setTimeout, setInterval, setImmediate, Date.now(), process.nextTick. vi.advanceTimersByTime(ms) fires any pending timers within that window synchronously.

For Promise-based async, also await vi.runAllTimersAsync() — flushes microtasks too.

Q: Snapshot testing in Vitest?

A: Same as Jest:

expect(component).toMatchSnapshot();              // file-based
expect(component).toMatchInlineSnapshot();        // inline in the test file

Inline snapshots are easier to review in PRs. See 08_snapshot_testing.md for when to use snapshots (sparingly).

Q: Coverage in Vitest?

A: Configured in vite.config.ts test.coverage:

coverage: {
  provider: "v8",                    // or "istanbul"
  reporter: ["text", "html", "lcov"],
  exclude: ["node_modules/", "test/", "**/*.d.ts"],
  thresholds: {
    lines: 80,
    functions: 80,
    branches: 75,
    statements: 80,
  },
}

v8 is faster but less accurate around source maps; istanbul is the traditional choice. Gate CI on diff coverage (diff-cover) rather than total — see strategy file.

Q: Running specific tests?

A:

vitest                              # watch mode
vitest run                          # single run (CI)
vitest run path/to/file.test.ts     # specific file
vitest run --reporter=verbose       # detailed output
vitest --ui                         # interactive UI
vitest run -t "my test name"        # by test name substring

.only and .skip work as expected. .todo lets you stub tests you plan to write.

Q: Migrating from Jest — how painful?

A: Usually a day or two for a medium codebase:

  1. Install vitest, @vitest/coverage-v8, jsdom (or happy-dom).
  2. Add test block to vite.config.ts.
  3. Replace jest.config.js content with Vitest equivalents.
  4. Find/replace jest.fn()vi.fn(), jest.mockvi.mock, etc. (Vitest provides globalsAlias for compatibility, but explicit vi is cleaner.)
  5. Fix anything that depended on Jest-specific runtime quirks.

ESM-native projects often gain tests that couldn’t run under Jest’s CJS transformer. CJS-heavy projects with deep jest.mock reliance may need a migration plan rather than a flip.

Gotchas / edge cases

  • vi.mock is hoisted — calls to it run before imports. Side effects in mocks fire early.
  • vi.useFakeTimers() doesn’t affect Promise microtasks by default — use vi.runAllTimersAsync() to flush microtasks.
  • @testing-library/jest-dom matchers require import "@testing-library/jest-dom/vitest" in setup (the /vitest subpath).
  • global.fetch isn’t polyfilled by default; Node 18+ has it natively. For older or test isolation, MSW or undici.
  • Module-level constants captured at import time don’t see vi.useFakeTimers() — fakes apply to subsequent calls, not the module’s initial evaluation.
  • @vitest/coverage-v8 doesn’t always show 100% on if/else branches that compile to short forms — Istanbul is more granular if you need it.

What a senior is expected to say

  • “Vitest for Vite/ESM projects — reuses your app’s config, fast cold start, Jest-compatible API. Jest still fine for established projects with no pain.”
  • “happy-dom by default for speed; jsdom if happy-dom lacks something you need. Stub IntersectionObserver/ResizeObserver/matchMedia in setup — neither implements them.”
  • “Mock at the network with MSW, not at modules with vi.mock. Module mocks tie tests to implementation.”
  • vi.useFakeTimers() for time-dependent logic; pair with runAllTimersAsync for Promise-based code.”
  • “Coverage is configured per-project; gate CI on diff coverage instead of total.”

Cross-references

Further reading