Skip to content

How to set up Vitest test reporting in CI

To report Vitest results to Qualflare, add the json reporter so Vitest writes a results file, run it with vitest run so CI never gets stuck in watch mode, then upload that file with the Qualflare CLI — Vitest’s JSON output matches Jest’s, so the same parser reads it. This guide also covers what is genuinely different about Vitest versus Jest — its pool model, vi.mock() hoisting, and watch-mode defaults — and where a flaky test actually comes from in that model.

  1. 1

    Configure Vitest's JSON reporter

    Vitest does not write a results file by default. Add the json reporter — reporters: ["default", "json"] and outputFile in vitest.config.ts, or --reporter=json --outputFile=vitest-results.json on the CLI. Keep "default" in the list alongside it so you don’t lose terminal output.

  2. 2

    Run in run mode, not watch mode

    Vitest watches by default outside CI, and it does try to auto-detect CI (or a non-interactive terminal) to fall back to run mode — but don’t rely on that detection in every environment. Use vitest run explicitly in CI scripts so a misdetected shell can never leave the job watching for changes that never arrive.

  3. 3

    Install and authenticate the Qualflare CLI in CI

    Add the Qualflare CLI to your pipeline and authenticate it once with an access token stored as a CI secret. No test code changes are required — the CLI is an extra step after your tests run.

  4. 4

    Upload results with the Jest-aware parser

    Add one command after the test step: qf <project> collect vitest-results.json --format jest. Vitest’s JSON reporter output matches Jest’s --json shape, so the same Jest-aware parser reads it — there is no separate Vitest format flag. The CLI attaches your Git branch and commit automatically.

  5. 5

    Let history accumulate and review flaky scores

    Flakiness is only visible across many runs, not one. After a handful of builds, Qualflare has enough pass/fail history per test to separate genuinely flaky tests — including the worker-pool and timer-related causes specific to Vitest — from real regressions.

The commands

One-off, from the CLI:

# Vitest's JSON reporter — keep "default" too so you don't lose terminal output
npx vitest run --reporter=default --reporter=json --outputFile=./vitest-results.json

Or make it permanent in config:

// vitest.config.ts — the same reporters, made permanent
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    reporters: ['default', 'json'],
    outputFile: './vitest-results.json',
  },
});

Then upload the file with the Qualflare CLI. Authenticate once with an access token stored as a CI secret — see the CLI docs:

# Vitest's JSON output matches Jest's --json shape, so the
# Qualflare CLI's Jest-aware parser reads it directly — no separate format
qf my-project collect vitest-results.json --format jest

In CI, that is one extra step after your test run (GitHub Actions shown — GitLab CI, CircleCI, and Jenkins work the same way):

# .github/workflows/tests.yml
- name: Run Vitest
  run: npx vitest run --reporter=default --reporter=json --outputFile=./vitest-results.json

- name: Upload results to Qualflare
  if: always() # upload even when tests fail — that's the point
  run: qf my-project collect vitest-results.json --format jest

What Vitest actually does differently from Jest

Vitest is not "Jest with different command names." It shares Jest’s describe/it/expect API, but the runner underneath is built differently, and that shows up in real ways:

  • Transform pipeline. Vitest runs your files through Vite’s own transform (esbuild by default), so ESM and TypeScript work with zero extra config. Jest transforms through its own pipeline, which usually means ts-jest or a babel config for the same source.
  • Watch mode by default. Running bare vitest starts watch mode. Vitest tries to detect CI or a non-interactive terminal and fall back to run mode automatically — but pass vitest run (or --run) explicitly in CI scripts rather than trusting that detection in every environment and container image.
  • Pool model. Vitest runs test files in a pool: forks (default, one child process per file), threads (worker_threads, faster but shares a process's memory), or vmThreads/vmForks (sandboxed VM contexts). Each test file is isolated by default (isolate: true) under forks and threads — the isolate setting has no effect under vmThreads/vmForks, which are always sandboxed regardless.
  • Mock hoisting. vi.mock() is hoisted to the top of the file by static analysis, same idea as Jest — but it only works on modules imported with import (not require), and outer-scope variables are not visible inside the factory.
// vitest.config.ts — the pool your test files run in
export default defineConfig({
  test: {
    pool: 'forks',   // default. Also: 'threads' | 'vmThreads' | 'vmForks'
    isolate: true,    // default. Each test file gets a fresh worker/process
    // Note: isolate has no effect under vmThreads/vmForks — those pools are
    // always sandboxed per file regardless of this setting.
  },
});

The mock-hoisting gotcha is the one that actually breaks migrated tests: code that referenced a module-scope variable inside a Jest mock factory throws a "cannot access before initialization" error in Vitest. vi.hoisted() is the fix — it runs before the mock factory and its return value is visible inside it:

// vi.mock() is hoisted above imports via static analysis — but unlike
// Jest, Vitest can't see variables from module scope at that point.
// vi.hoisted() runs first and its return value IS visible inside the factory.
import { getUser } from './api';

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

vi.mock('./api', () => ({ getUser: mockGetUser }));

mockGetUser.mockResolvedValue({ id: 1, name: 'Ada' });

Flakiness causes specific to Vitest's model

Most non-determinism causes are shared with Jest (leaked state, real timers, races) — see the Jest & Vitest flaky tests guide for those. Three causes are specific to how Vitest actually runs tests:

  • Worker-pool state leakage. With the threads pool, test files in the same worker share a process. A module-level singleton or global that one file mutates and never resets can bleed into the next file that lands on the same worker — a failure that depends on how test files get scheduled onto workers, not on your code changing. Disabling isolate for speed makes this worse; keep it on unless your suite is provably side-effect-free.
  • Snapshot gotchas. Vitest does not write new or changed snapshots in CI (when process.env.CI is truthy) — mismatched, missing, and obsolete snapshots all fail the run instead of silently updating. A snapshot that was never committed after a local change becomes an intermittent-looking CI failure. Concurrent tests (it.concurrent) also need to use the expect from each test's local context for snapshots, or you get a "Snapshot cannot be used outside of test" error.
  • Timer mocking that outlives the test. vi.useFakeTimers() and vi.setSystemTime() are not reset automatically between tests — unlike mocks, which clearMocks/restoreMocks reset for you. A test that installs a fake clock and forgets to restore it leaves the next test in the file running against frozen time, which reads as a flaky failure somewhere else entirely.
afterEach(() => {
  // vi.useFakeTimers() / vi.setSystemTime() do NOT reset automatically —
  // clearMocks/restoreMocks in your config only affect mock functions.
  // Leave a fake clock installed and it leaks into the next test in the file.
  vi.useRealTimers();
});

Migrating from Jest? See Jest test reporting for the Jest-only setup. New to the terms? See flaky test, non-determinism, and test retry in the glossary. Weighing tools? Browse all framework reporting guides.

Frequently asked questions

Do I need ts-jest or a Babel config to use TypeScript with Vitest?

No. Vitest runs on Vite’s own transform pipeline (esbuild by default), so ESM and TypeScript are handled natively with zero extra config — there is no ts-jest or babel-jest equivalent step to set up. This is one of the more real differences from Jest, which typically needs ts-jest or a babel config for the same files.

Why did my CI job hang after switching from Jest to Vitest?

Vitest launches in watch mode by default, and if your CI environment fails Vitest’s own CI/TTY detection, the process never exits. Jest does not have this failure mode since it always runs once by default. Fix it by calling vitest run (or passing --run) explicitly in your CI script instead of relying on auto-detection.

Can the worker pool I choose cause flaky tests?

Yes. Vitest isolates each test file in its own worker or process by default (isolate: true), but with the threads pool those workers share a process, so global or module state that leaks between test files in the same worker can cause intermittent failures. Disabling isolation (--no-isolate) for speed makes this worse unless your suite has no side effects.

Does vi.mock() work exactly like jest.mock()?

Mostly, but not exactly. Both are hoisted to the top of the file via static analysis, but vi.mock() only works with modules imported via import — not require — and you cannot reference outer-scope variables inside the factory. Use vi.hoisted() to declare values that need to exist before the mock factory runs.

Does Qualflare detect flaky Vitest tests?

Yes — by scoring each test’s pass/fail history across runs, which is the accurate way to catch flakiness that in-run retries (the retry config option, or test(name, { retry: n }, fn)) can mask. Upload Vitest’s JSON results on every CI run with the Qualflare CLI and Qualflare builds that history automatically, the same way it does for Jest.

Get AI analysis on your Vitest runs

Upload your Vitest JSON results and let Qualflare score flaky tests from their history — free to start.

Start free with Qualflare