Resource library

QA How-To

Vitest setup: A QA Guide (2026)

Build a reliable Vitest setup QA teams can use in 2026, with TypeScript, environments, setup files, mocks, timers, coverage, debugging, and CI examples.

21 min read | 3,629 words

TL;DR

A production-quality Vitest setup uses a typed config, explicit test imports, strict TypeScript checking, the narrowest runtime environment, lightweight setup files, disciplined mock cleanup, deterministic timers, and a non-watch CI command. Add V8 coverage and global setup only when they solve a defined need.

Key Takeaways

  • Use an explicit vitest.config.ts and separate watch, CI, type-check, and coverage scripts.
  • Select node for server logic and jsdom only for tests that require browser-like DOM APIs.
  • Keep setup files deterministic and lightweight because they run before each test file in a worker.
  • Reset mocks, fake timers, environment stubs, and mutated globals so tests remain order-independent.
  • Use globalSetup only for suite-level orchestration, and pass serializable values through provide and inject.
  • Treat coverage as risk evidence, not a substitute for meaningful assertions and boundary tests.

Vitest setup qa teams can maintain starts with four decisions: which files are tests, which environment they run in, which state is initialized before them, and which command CI executes. A small explicit configuration prevents watch-mode surprises, global type conflicts, stale mocks, and tests that pass only in one developer's editor.

Vitest is closely integrated with Vite, but it is still a test runner with its own lifecycle, mocking, environment, and coverage behavior. This guide creates a TypeScript suite from scratch, then strengthens it with setup files, DOM selection, module mocks, fake timers, global setup, debugging, and CI quality gates.

TL;DR

Concern Recommended default Change when
Test API explicit imports from vitest Team deliberately enables and types globals
Environment node A test needs browser-like DOM APIs
Setup small setupFiles module Work must run once outside workers
Mock hygiene clear or restore automatically, reset special stubs explicitly A test intentionally preserves state
CI command vitest run Never use interactive watch mode in CI
Coverage V8 provider with focused include rules Another provider solves a documented constraint

Start with vitest.config.ts, tsconfig.vitest.json, and one runnable test. Prove isolation before adding shared setup or broad mocks.

1. Vitest setup QA fundamentals

Vitest discovers test files, transforms imports and TypeScript through Vite-compatible tooling, provides test and mock APIs, schedules cases, and reports results. It can run in watch mode for local feedback or one-shot mode for CI.

Its transformation is not a complete TypeScript semantic check. A test can execute even when the TypeScript compiler would reject a relationship elsewhere in the project. That is why a mature setup has separate commands for tsc --noEmit and vitest run.

The runner and the environment are also different layers. Vitest executes test orchestration in Node-based workers. The configured environment controls globals and simulated platform behavior available to the test. node is appropriate for utilities, API clients, domain logic, and most backend modules. jsdom supplies a browser-like document for DOM-oriented unit tests, but it is not a real browser and cannot validate full rendering, layout, or browser engine behavior.

Vitest's default test functions can be imported directly:

import { describe, expect, it } from 'vitest';

describe('health', () => {
  it('runs with explicit APIs', () => {
    expect({ status: 'ok' }).toEqual({ status: 'ok' });
  });
});

Explicit imports make the dependency visible and avoid accidental Jest or other global type declarations. Global mode is available, but should be a deliberate repository convention supported by both runtime config and TypeScript types.

Before continuing, review TypeScript config for tests QA if module resolution or runner types are unclear.

2. Install Vitest and create reliable scripts

In an existing npm project, install Vitest as a development dependency:

npm install --save-dev vitest

For V8 coverage, install its matching integration package from the same dependency set:

npm install --save-dev @vitest/coverage-v8

If DOM tests require jsdom, install it explicitly:

npm install --save-dev jsdom

Use the lockfile to keep related versions compatible. Do not copy an isolated package version from an old guide. Let the repository dependency policy choose and lock a coherent current set.

Add separate scripts:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "typecheck:tests": "tsc -p tsconfig.vitest.json --noEmit"
  }
}

npm test enters the local watch workflow when Vitest detects an interactive environment. npm run test:run is explicit one-shot execution for CI. A dedicated coverage command prevents every fast local run from paying coverage overhead.

Create a minimal structure:

src/
  shipping.ts
  shipping.test.ts
tests/
  setup.ts
vitest.config.ts
tsconfig.vitest.json

Colocating unit tests with source makes ownership visible. A separate tests tree can hold integration or shared support code. Use one convention consistently enough that include patterns and review expectations remain clear.

Run npx vitest --version during diagnosis to confirm the project-local binary. A globally installed executable can create configuration and API mismatches.

3. Write an explicit vitest.config.ts

Vitest can read configuration from a dedicated file. Import defineConfig from vitest/config for typed test options:

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    include: [
      'src/**/*.test.ts',
      'tests/**/*.test.ts'
    ],
    exclude: [
      '**/node_modules/**',
      '**/dist/**',
      '**/e2e/**'
    ],
    environment: 'node',
    globals: false,
    setupFiles: ['./tests/setup.ts'],
    clearMocks: true,
    restoreMocks: true,
    testTimeout: 5000,
    hookTimeout: 10000
  }
});

Use timeouts as containment, not as synchronization. A five-second default is an example for fast unit tests, not a universal standard. Integration work may need a distinct project or config with evidence-based limits.

clearMocks clears call history before each test. restoreMocks restores spies to their original implementations before each test. These protect common cases, but they do not automatically reverse every environment or global stub, and they do not make mutable modules pure.

If the repository already has vite.config.ts, Vitest can consume Vite settings. A separate Vitest config can also be merged with Vite configuration when necessary. Keep aliases and transforms aligned, but avoid pulling production-only plugins into tests without a reason.

Do not broaden include to every *.ts file. Discovery should be predictable and exclude end-to-end tests owned by Playwright or another runner. A green run with zero discovered tests must not be mistaken for coverage of the repository, so inspect collected file counts in CI.

Use the official configuration key names rather than inventing wrapper settings. When upgrading, resolve warnings from Vitest instead of retaining deprecated aliases indefinitely.

4. Align Vitest with TypeScript

A Vite-transformed TypeScript suite commonly uses ES modules and bundler resolution:

// tsconfig.vitest.json
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "types": ["node"],
    "noEmit": true
  },
  "include": [
    "src/**/*.ts",
    "tests/**/*.ts",
    "vitest.config.ts"
  ]
}

With explicit test imports, types does not need vitest/globals. If the team sets globals: true, add "vitest/globals" so the compiler recognizes global describe, it, expect, and hooks. Runtime globals and type globals must agree.

For DOM tests, add DOM and DOM.Iterable libraries in the appropriate test project:

{
  "compilerOptions": {
    "lib": ["ES2022", "DOM", "DOM.Iterable"]
  }
}

Those libraries only provide type declarations. The Vitest environment must still be jsdom for runtime document behavior.

Path aliases belong in TypeScript and in the Vite resolution layer where needed. A compiler-only paths mapping can satisfy the editor while Vitest fails to load the module. Prefer existing Vite aliases or a small explicit top-level resolve.alias in configuration.

Run both commands locally and in CI:

npm run typecheck:tests
npm run test:run

Do not hide type checking behind the Vitest command. Their failure messages and purposes are different. Keeping them separate also makes a CI job identify whether a change violates types or behavior.

Strict TypeScript is especially useful around mocks. vi.mocked can preserve function signatures, while an untyped cast can let impossible return values enter the test.

5. Write a first deterministic unit test

Create a pure function with clear boundary behavior:

// src/shipping.ts
export type ShippingBand = 'standard' | 'express';

export function shippingCost(
  subtotal: number,
  band: ShippingBand
): number {
  if (!Number.isFinite(subtotal) || subtotal < 0) {
    throw new RangeError('subtotal must be a nonnegative finite number');
  }

  if (subtotal >= 100) {
    return 0;
  }
  return band === 'express' ? 15 : 7;
}

Test representative partitions and a boundary:

// src/shipping.test.ts
import { describe, expect, it } from 'vitest';
import { shippingCost } from './shipping';

describe('shippingCost', () => {
  it.each([
    { subtotal: 0, band: 'standard' as const, expected: 7 },
    { subtotal: 99.99, band: 'express' as const, expected: 15 },
    { subtotal: 100, band: 'express' as const, expected: 0 }
  ])(
    'returns $expected for $subtotal with $band shipping',
    ({ subtotal, band, expected }) => {
      expect(shippingCost(subtotal, band)).toBe(expected);
    }
  );

  it('rejects a negative subtotal', () => {
    expect(() => shippingCost(-0.01, 'standard')).toThrow(RangeError);
  });
});

This test is deterministic because it has no clock, network, random source, environment dependency, or shared state. The table is small and readable. Parameterization reduces duplication without hiding the business boundaries.

Use toBe for primitive exact values and toEqual for structural value comparison. Prefer focused matchers that explain the mismatch. An assertion such as expect(result).toBeTruthy() is weak if the contract requires a specific amount.

Test titles should describe behavior, not implementation. "uses ternary operator" would make a refactor look like a requirement. The current titles identify inputs and outcomes.

Do not chase one assertion per test as a rigid rule. Several assertions about one returned object can describe one behavior. Split when failures represent independent scenarios, require different setup, or produce unclear reports.

A reliable suite begins with testable design. Pure domain functions need little setup, while adapters isolate clocks, files, and networks behind narrow interfaces.

6. Design setupFiles without hidden state

A setup file runs before each test file in the same worker process. Use it for lightweight global configuration, custom matchers, polyfills, or hooks that genuinely apply across that config.

// tests/setup.ts
import { afterEach, vi } from 'vitest';

afterEach(() => {
  vi.unstubAllEnvs();
  vi.unstubAllGlobals();
  vi.useRealTimers();
});

This setup ensures tests that call vi.stubEnv, vi.stubGlobal, or vi.useFakeTimers do not leak those changes. The config already clears and restores ordinary mocks. Keeping cleanup centralized is reasonable only when the rule applies to every test.

Setup files should be deterministic and fast. They run before each test file, not once for the entire suite. Do not start one shared server there unless its lifecycle and repeated execution are intentionally designed. If module isolation is disabled, imported modules can remain cached while setup code executes again, which makes unguarded global mutation especially risky.

Exports from setup files are not consumed as fixtures. Import reusable helpers normally from support modules. Use the setup file to register behavior, not as a hidden service locator.

If a setup module installs custom matchers, also add TypeScript module augmentation in an included declaration file. Runtime registration without types produces editor errors. Types without runtime registration compile but fail when the matcher is called.

More setup is not more reliability. Every implicit hook affects every case and makes an isolated test harder to understand. Prefer local hooks for domain-specific state, and reserve global setup for true suite invariants.

To understand why cleanup must remain observable, see promises and error handling in tests QA.

7. Choose node or jsdom per test boundary

The node environment is fast and honest for server-side modules. It provides Node globals but no browser document. Use jsdom for code that genuinely operates on DOM APIs.

Set a config-wide environment:

export default defineConfig({
  test: {
    environment: 'jsdom'
  }
});

Or override one file with a control comment:

// @vitest-environment jsdom
import { expect, it } from 'vitest';

it('renders a status region', () => {
  document.body.innerHTML = '<div role="status">Ready</div>';

  const status = document.querySelector('[role="status"]');
  expect(status?.textContent).toBe('Ready');
});

Install jsdom if the environment requires it. Clean the DOM between tests when your component library does not do so. Testing Library integrations commonly provide their own cleanup conventions, which should be configured according to that library rather than duplicated blindly.

Understand the boundary. jsdom models many web standards but does not use Chromium, Firefox, or WebKit engines. It is unsuitable for layout, visual rendering, browser permission flows, real navigation fidelity, or end-to-end confidence. Use Playwright for those concerns.

Requirement Vitest node Vitest jsdom Real browser test
Pure business logic Best fit Unnecessary Unnecessary
DOM event logic No DOM Good unit fit Useful for integration confidence
CSS layout or screenshot Not available Not faithful Required
Browser storage wrapper Can mock adapter Useful simulation Required for full workflow
Cross-browser behavior No No Required

Use the narrowest environment that can exercise the contract. A global jsdom default can accidentally let server modules rely on DOM names that production Node does not provide.

8. Mock functions and modules without losing the contract

Use vi.fn for injected collaborators and vi.spyOn when observing a real object's method. Keep mocks typed and assert both output and meaningful interaction when the interaction is part of the contract.

// src/order-service.ts
export type Inventory = {
  reserve(sku: string, quantity: number): Promise<boolean>;
};

export async function placeOrder(
  inventory: Inventory,
  sku: string
): Promise<'confirmed' | 'unavailable'> {
  const reserved = await inventory.reserve(sku, 1);
  return reserved ? 'confirmed' : 'unavailable';
}
// src/order-service.test.ts
import { expect, it, vi } from 'vitest';
import { placeOrder, type Inventory } from './order-service';

it('confirms an order after inventory is reserved', async () => {
  const reserve = vi.fn<Inventory['reserve']>().mockResolvedValue(true);
  const inventory: Inventory = { reserve };

  await expect(placeOrder(inventory, 'BOOK-1')).resolves.toBe('confirmed');
  expect(reserve).toHaveBeenCalledWith('BOOK-1', 1);
  expect(reserve).toHaveBeenCalledTimes(1);
});

Dependency injection is often clearer than module mocking. The interface documents the seam, and the test controls one function.

When module mocking is required, remember that vi.mock calls are hoisted. The factory must not depend on normal top-level variables that are unavailable at hoist time. Import the mocked function, declare the module mock, and use vi.mocked for typed control:

import { beforeEach, expect, it, vi } from 'vitest';
import { getFeatureFlags } from './feature-client';
import { canCheckout } from './checkout';

vi.mock('./feature-client', () => ({
  getFeatureFlags: vi.fn()
}));

beforeEach(() => {
  vi.mocked(getFeatureFlags).mockReset();
});

it('allows checkout when the flag is enabled', async () => {
  vi.mocked(getFeatureFlags).mockResolvedValue({ checkout: true });
  await expect(canCheckout()).resolves.toBe(true);
});

Mock the narrow external boundary, not every internal function. Overmocking can make a test pass while real modules no longer integrate. Add an integration test for important adapter wiring.

9. Control time, randomness, and environment state

Tests that depend on the current clock or real delays are hard to reproduce. Vitest fake timers let a case own time:

import { afterEach, expect, it, vi } from 'vitest';

function expiresAt(minutes: number): Date {
  return new Date(Date.now() + minutes * 60_000);
}

afterEach(() => {
  vi.useRealTimers();
});

it('sets an expiry thirty minutes ahead', () => {
  vi.useFakeTimers();
  vi.setSystemTime(new Date('2026-07-13T10:00:00Z'));

  expect(expiresAt(30).toISOString())
    .toBe('2026-07-13T10:30:00.000Z');
});

Call vi.useRealTimers in cleanup even if the assertion fails. The shared setup file in this guide provides that safeguard, while the local hook keeps the test file self-contained.

For timer-driven async work, use Vitest's timer advancement APIs and await the async forms when the callbacks create promises. Do not combine fake timers with uncontrolled real network operations. A faked clock does not make the network deterministic.

Stub environment variables with vi.stubEnv:

import { expect, it, vi } from 'vitest';

function apiUrl(): string {
  const value = process.env.API_URL;
  if (!value) {
    throw new Error('API_URL is required');
  }
  return value;
}

it('reads the configured API URL', () => {
  vi.stubEnv('API_URL', 'https://api.example.test');
  expect(apiUrl()).toBe('https://api.example.test');
});

vi.unstubAllEnvs in setup restores changes. Be careful with modules that read environment values at import time, because changing process.env after import will not rebuild their exported constants. Prefer functions or explicit config injection, or reset modules deliberately when the import-time behavior itself is under test.

Control random values by injecting an ID generator or mocking the narrow random source. Record seeds in property-based tests. Never solve nondeterminism with a broad retry.

10. Use globalSetup for suite-level orchestration

setupFiles and globalSetup have different lifecycles. Setup files run in test workers before files. Global setup runs in a separate global scope before workers and can perform one-time orchestration plus teardown.

A global setup can provide serializable context to tests:

// tests/global-setup.ts
import { randomUUID } from 'node:crypto';
import type { TestProject } from 'vitest/node';

export default function setup(project: TestProject): void {
  project.provide('runId', randomUUID());
}

declare module 'vitest' {
  export interface ProvidedContext {
    runId: string;
  }
}

Register it:

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globalSetup: ['./tests/global-setup.ts']
  }
});

Read the value in a test:

import { expect, inject, it } from 'vitest';

it('receives a suite run identifier', () => {
  expect(inject('runId')).toMatch(
    /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-/
  );
});

The provided value must be serializable. Globals created in the global setup scope are not directly visible inside test workers, which is why provide and inject exist.

For an external server, global setup can return a teardown function or export setup and teardown functions. Wait for readiness before returning from setup, allocate a conflict-safe port, and close the process in teardown. Capture output without leaking secrets.

Do not use global setup for test data that must be isolated by case. One shared user or database record makes parallel tests order-dependent. Prefer per-test fixtures or helpers for mutable resources.

In watch mode, understand when setup and teardown run, especially if external resources survive reruns. Keep operations idempotent and document local recovery if a process is interrupted.

11. Add coverage as risk feedback

Enable V8 coverage in the config after installing @vitest/coverage-v8:

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
      include: ['src/**/*.ts'],
      exclude: ['src/**/*.d.ts'],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 80,
        statements: 80
      }
    }
  }
});

The threshold values are illustrative, not universal targets. Select gates based on baseline, product risk, and team policy. A high line percentage can still miss a critical negative path, while a lower percentage can include excellent contract tests around the riskiest module.

include prevents untouched source from disappearing from the denominator. Exclude generated code and declarations deliberately, not merely to raise a score. Review the uncovered branch list for error paths, feature flags, authorization, and boundary values.

Run:

npm run test:coverage

Publish coverage artifacts according to CI retention and security rules. HTML reports contain source excerpts, so do not expose them from private repositories without access control.

Coverage instrumentation can alter performance and stack details. Diagnose timing-sensitive failures without assuming the covered run behaves identically. Unit tests should not depend on tiny real-time windows in either mode.

Use mutation testing or deliberate fault injection for stronger confidence in important assertions. If changing a comparison from >= to > leaves tests green, line coverage alone did not protect the boundary.

Coverage is a map of executed code, not proof of correct observation.

12. Vitest setup QA execution in CI

A CI job should use the lockfile, run type checking, execute one-shot tests, and optionally collect coverage in a visible step.

name: vitest

on:
  pull_request:
  push:
    branches: [main]

jobs:
  unit:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run typecheck:tests
      - run: npm run test:coverage

The Node version is an illustrative pinned choice. Use a supported version compatible with the project's current Vitest and Vite packages.

Do not run bare interactive vitest in CI. vitest run has an explicit one-shot contract and returns a nonzero status on failures. Set a job timeout as final containment, while individual tests and hooks keep smaller meaningful budgets.

Control concurrency when tests consume shared external systems. Reducing workers can confirm an isolation defect, but it is not the final fix if the suite is expected to run in parallel. Unique data, mock isolation, and owned resources are the solution.

Make CI use the same configuration file developers use. Environment-specific differences should be explicit values, not a separate config copy that drifts. Secrets belong in CI secret storage and must not appear in snapshots or error dumps.

Check test discovery. A changed include pattern can reduce the suite to zero or omit a package while the command remains green. A launch smoke check can assert required test folders or inspect runner output.

Cache package downloads when useful, but let npm ci enforce the lockfile. Do not rely on a developer's cached transformed modules to reproduce CI.

13. Debug slow, flaky, or missing tests

For a missing test, check include, exclude, filename suffix, and the configuration Vitest selected. Run one path explicitly and confirm the local binary version. A file opened in the editor is not necessarily part of the Vitest or TypeScript project.

For a flaky test, classify the uncontrolled dependency:

  • Clock or timer state was not restored.
  • A mock implementation or call history leaked.
  • An environment variable or global was mutated.
  • A module cached configuration at import time.
  • Tests wrote the same file or record.
  • A real service returned variable timing or data.
  • The test asserted implementation order under concurrency.

Run the smallest file repeatedly and with different ordering or worker settings. Fix ownership rather than adding a retry.

For a slow test, determine whether time is spent in transform, setup files, environment creation, module imports, hooks, or the test itself. A heavy setup file taxes every file. A global jsdom environment taxes tests that need only Node. Large barrel imports can load unrelated modules and side effects.

Use focused commands:

npx vitest run src/shipping.test.ts
npx vitest run -t "rejects a negative subtotal"
npx vitest --version

When a mock seems ignored, check the import path and hoisting rules. When a module retained old state, inspect module cache behavior rather than resetting every module globally by default.

When fake timers hang a promise, identify whether the code schedules timers, microtasks, or real I/O. Advance the correct clock API and await asynchronous timer advancement where applicable. Restore real timers during teardown.

A good diagnostic change should be removed or converted into a stable assertion after the cause is known.

14. A practical suite review checklist

Review a Vitest repository against observable contracts:

  • The project uses a locked, local Vitest dependency.
  • Watch and one-shot commands are separate.
  • vitest.config.ts has intentional include and exclude patterns.
  • The default environment matches most tests.
  • DOM tests declare or inherit a real simulated DOM environment.
  • Test APIs are imported explicitly, or globals are enabled and typed consistently.
  • Setup files are lightweight, deterministic, and safe when repeated.
  • Mocks, spies, fake timers, environment stubs, and globals are restored.
  • External JSON is validated rather than trusted through a cast.
  • Global setup passes serializable context through provide and inject.
  • Coverage includes relevant source and uses risk-based thresholds.
  • TypeScript and Vitest run as separate gates.
  • CI uses vitest run, honors the lockfile, and checks discovery.
  • Playwright or other end-to-end files are excluded from Vitest.

Prove the controls work. Create a test that mutates an environment variable and verify the next test sees the original. Make a promise reject and confirm the awaited matcher fails correctly. Change the system time and confirm cleanup restores it. Temporarily break a type and confirm only the type-check gate catches it.

A stable suite makes failures local. The test that changes state also restores it. The config names the environment. The setup file owns only universal behavior. The CI command has no interactive ambiguity.

For browser workflow coverage beyond jsdom, use a real-browser strategy such as the Playwright fixtures and lifecycle guide.

Interview Questions and Answers

Q: What is the difference between vitest and vitest run?

The default command supports the local watch workflow, depending on the environment and invocation. vitest run executes the collected suite once and exits, which is the correct explicit CI behavior. Separate scripts prevent an interactive process from waiting in automation.

Q: Does Vitest type-check TypeScript tests?

Vitest transforms TypeScript so tests can execute, but that is not a replacement for full compiler checking. Run tsc -p ... --noEmit separately. A sound pipeline requires both type and behavioral gates.

Q: What is the difference between setupFiles and globalSetup?

Setup files execute in test worker processes before each test file. Global setup runs outside the workers before the suite and can return teardown. Values from global setup reach tests through serializable provide and inject context, not shared globals.

Q: When should the environment be node or jsdom?

Use node for pure logic, server modules, and API adapters. Use jsdom when the unit under test needs DOM APIs. Use a real browser runner for layout, browser engines, navigation fidelity, permissions, and end-to-end workflows.

Q: How do clearMocks, resetMocks, and restoreMocks differ conceptually?

Clearing removes recorded calls while preserving implementations. Resetting also returns mocks to a reset implementation state. Restoring returns spies or replaced properties to their originals where supported. Choose the minimum behavior that guarantees isolation and understand any explicit per-test setup it requires.

Q: Why are vi.mock factories affected by hoisting?

Vitest moves static module mock declarations so mocking occurs before imports execute. A factory therefore cannot safely rely on ordinary top-level values initialized later. Use self-contained factories, supported hoisted state, or dependency injection.

Q: How do you prevent fake timers from leaking?

Call vi.useRealTimers in afterEach or a universal setup hook. Also restore system time and any globals or environment values changed by the test. Cleanup must run even when an assertion fails.

Q: What does coverage fail to prove?

Coverage proves that instrumentation observed execution of lines, functions, or branches. It does not prove assertions were meaningful, boundaries were correct, or failures were tested. Risk-focused scenarios and fault injection complement percentages.

Common Mistakes

  • Running watch mode in CI instead of vitest run.
  • Assuming Vitest transformation performs full TypeScript checking.
  • Enabling global test APIs without adding matching TypeScript types, or adding types without enabling globals.
  • Using jsdom for every test even when most code is server-side.
  • Starting expensive shared services in setupFiles.
  • Letting mocks, fake timers, environment variables, or globals leak between tests.
  • Calling vi.mock with a factory that depends on unavailable top-level state.
  • Mocking internal implementation details instead of an external boundary.
  • Trusting response JSON through an as cast.
  • Measuring coverage only on files imported by tests.
  • Using retries to hide shared-state or timing defects.
  • Mixing Vitest unit files with Playwright end-to-end discovery patterns.
  • Sharing mutable global setup data among parallel tests.
  • Treating jsdom results as cross-browser evidence.

A strong setup keeps defaults narrow and state ownership local. Add global behavior only when every test truly needs it.

Conclusion

Vitest setup qa teams can trust is explicit about discovery, environment, setup lifecycle, state restoration, and CI execution. Pair a typed config with strict TypeScript checking, small deterministic tests, and cleanup that reverses every stub or clock change.

Build the minimal shipping example, run it with vitest run, then add one setup concern at a time. When node versus jsdom, setupFiles versus globalSetup, and mocks versus real adapters are deliberate choices, the suite stays fast without sacrificing diagnostic quality.

Interview Questions and Answers

How would you set up Vitest for a TypeScript project?

I would install Vitest as a dev dependency, add separate watch and run scripts, create a typed config with intentional discovery and environment settings, and use a strict no-emit tsconfig. CI would run TypeScript checking and vitest run as separate gates.

Why prefer explicit Vitest imports?

They make each file's runner dependency clear and avoid global declaration collisions with Jest or other tools. Autocomplete and compilation work without a global types entry. Globals remain a valid deliberate team convention when runtime and compiler settings match.

Compare setupFiles and globalSetup.

setupFiles run before each test file in workers and can register hooks in that scope. globalSetup runs outside workers before the suite and supports suite-level teardown. Tests receive global setup data through serializable provide and inject context.

How do you choose a Vitest environment?

I choose node for logic and backend adapters, and jsdom only for units that need DOM APIs. I do not treat jsdom as real browser evidence. Layout, browser engines, and end-to-end workflows go to Playwright or another browser runner.

How do you keep Vitest tests isolated?

I restore spies, clear required call history, return fake timers to real time, undo environment and global stubs, and avoid module-level mutable state. External resources get unique identifiers and explicit cleanup. I also run order and concurrency checks when flakiness appears.

What is the main risk of module mocking?

A broad module mock can test an invented integration rather than the production wiring. Static vi.mock declarations are also hoisted, so factories have initialization constraints. I prefer dependency injection or a narrow adapter seam and retain integration coverage for important paths.

How should coverage be configured?

Include the relevant source set, exclude only justified generated or declaration files, and set thresholds from risk and baseline rather than a copied number. Review uncovered branches and negative paths. Coverage complements meaningful assertions but never replaces them.

Why can a Vitest test pass while TypeScript has errors?

Vitest's transform can remove type syntax and execute valid JavaScript without full semantic checking of the project. The TypeScript compiler analyzes the selected program and can find errors execution never touches. Both commands belong in the quality gate.

Frequently Asked Questions

What is the minimum Vitest setup for TypeScript?

Install Vitest, create an explicit test script, import test APIs from vitest, and add a typed vitest.config.ts when discovery or environments need configuration. Run a separate strict tsc noEmit command because Vitest transformation is not full type checking.

Should Vitest use globals or explicit imports?

Explicit imports are a strong default because dependencies remain visible and global type conflicts are less likely. If a team chooses globals, set globals true and include vitest/globals in the correct TypeScript project.

When does a Vitest setup file run?

A setupFiles module runs before each test file in the same test process. It is suited to lightweight hooks, polyfills, and matcher registration, not unguarded one-time service startup.

Do I need jsdom for Vitest?

Only if the tested code requires browser-like DOM APIs. Pure functions and server modules should normally stay in the node environment, while full browser behavior belongs in a real-browser test.

How do I reset Vitest mocks between tests?

Use clearMocks or restoreMocks in configuration as appropriate, and add explicit cleanup for environment stubs, globals, fake timers, or special module state. Understand whether the next test needs call clearing, implementation reset, or restoration of the original function.

How do I run Vitest in CI?

Use a lockfile-based install followed by a separate TypeScript check and vitest run or vitest run --coverage. Avoid interactive watch mode and set a finite job timeout.

What is the recommended Vitest coverage provider?

The V8 provider is a practical default for many Node-based Vitest suites. Configure source inclusion and risk-based thresholds deliberately, and keep the matching @vitest/coverage-v8 package aligned through the lockfile.

Related Guides