# Vitest Testing

> Write fast unit and integration tests with Vitest — vitest.config.ts setup, vi.fn and vi.mock module mocking, fake timers, snapshots, V8 coverage with thresholds, workspaces for monorepos, and in-source testing.

- Skill: `pramoddutta/vitest-testing` (Agent Skill)
- Install (CLI): `npx skillmds@latest add pramoddutta/vitest-testing`
- Raw SKILL.md: https://api.skillmd.com/api/skills/pramoddutta/vitest-testing/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- License: MIT
- Author: PramodDutta (https://skillmd.com/u/pramoddutta)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/pramoddutta/vitest-testing

---


# Vitest Testing

This skill makes an AI agent write and configure Vitest test suites: a correct `vitest.config.ts`, module mocking with `vi.mock` and the `vi.hoisted` escape hatch, spies and fake timers, inline snapshots, V8 coverage gates, and `projects` config for monorepos. Trigger it on any Vite-based project, any repo with `vitest` in `devDependencies`, or when migrating from Jest.

## Core Principles

1. **Vitest reuses your Vite config — do not duplicate resolution logic.** Aliases, plugins, and transforms from `vite.config.ts` apply to tests automatically. A separate Babel/transform setup is a Jest habit; drop it.
2. **`vi.mock` is hoisted; factory variables are not.** The mock factory runs before imports, so referencing top-level variables inside it throws. Use `vi.hoisted()` when the factory needs shared handles.
3. **Prefer `vi.fn` injected via parameters over `vi.mock` of whole modules.** Module mocking is a sledgehammer; dependency injection keeps tests honest and refactor-safe.
4. **Inline snapshots over file snapshots for small values.** `toMatchInlineSnapshot` puts the expectation in the test where reviewers see it; file snapshots get blindly `--update`d.
5. **Coverage thresholds live in config and fail the run.** A coverage report nobody gates on is wallpaper. Gate `lines`, `functions`, and `branches` — branch coverage is where the bugs hide.
6. **Use the default `node` environment unless you render DOM.** `jsdom`/`happy-dom` cost startup time per file; set them per-file with a docblock, not globally.

## Setup

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

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

export default defineConfig({
  test: {
    globals: false, // explicit imports; keeps files greppable and TS-clean
    environment: 'node',
    include: ['src/**/*.test.ts'],
    setupFiles: ['./test/setup.ts'],
    restoreMocks: true, // undo spy implementations between tests
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov', 'html'],
      include: ['src/**'],
      exclude: ['src/**/*.test.ts', 'src/types/**', 'src/main.ts'],
      thresholds: {
        lines: 85,
        functions: 85,
        branches: 75,
        statements: 85,
      },
    },
  },
});
```

```json
// package.json scripts
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage"
  }
}
```

Watch mode is the default `vitest` command and only reruns tests affected by the changed module graph — keep it running while developing.

## Patterns

### vi.fn, Spies, and Dependency Injection

```typescript
// src/notifier.ts
export type SendEmail = (to: string, subject: string) => Promise<void>;

export async function notifyOnFailure(
  jobName: string,
  failures: number,
  sendEmail: SendEmail,
): Promise<boolean> {
  if (failures === 0) return false;
  await sendEmail('oncall@example.com', `${jobName} failed ${failures} times`);
  return true;
}
```

```typescript
// src/notifier.test.ts
import { describe, expect, it, vi } from 'vitest';
import { notifyOnFailure } from './notifier';

describe('notifyOnFailure', () => {
  it('emails oncall with the failure count in the subject', async () => {
    const sendEmail = vi.fn().mockResolvedValue(undefined);

    const sent = await notifyOnFailure('nightly-sync', 3, sendEmail);

    expect(sent).toBe(true);
    expect(sendEmail).toHaveBeenCalledExactlyOnceWith(
      'oncall@example.com',
      'nightly-sync failed 3 times',
    );
  });

  it('stays silent when there are no failures', async () => {
    const sendEmail = vi.fn();
    await expect(notifyOnFailure('nightly-sync', 0, sendEmail)).resolves.toBe(false);
    expect(sendEmail).not.toHaveBeenCalled();
  });
});
```

### vi.mock with vi.hoisted (the hoisting trap, solved)

```typescript
import { beforeEach, expect, it, vi } from 'vitest';
import { getInvoice } from './invoice-service';

// Factory is hoisted above imports — capture handles via vi.hoisted
const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }));

vi.mock('./billing-client', () => ({
  fetchInvoice: fetchMock,
}));

beforeEach(() => {
  fetchMock.mockReset();
});

it('retries once on a 503 from the billing client', async () => {
  fetchMock
    .mockRejectedValueOnce(new Error('503 Service Unavailable'))
    .mockResolvedValueOnce({ id: 'inv_42', total: 1999 });

  const invoice = await getInvoice('inv_42');

  expect(invoice.total).toBe(1999);
  expect(fetchMock).toHaveBeenCalledTimes(2);
});
```

Partial mocks keep the rest of a module real:

```typescript
vi.mock('./config', async (importOriginal) => {
  const actual = await importOriginal<typeof import('./config')>();
  return { ...actual, isFeatureEnabled: vi.fn().mockReturnValue(true) };
});
```

### Fake Timers

```typescript
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { debounce } from './debounce';

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

it('fires once after the trailing edge of 300ms', () => {
  const fn = vi.fn();
  const debounced = debounce(fn, 300);

  debounced();
  debounced();
  vi.advanceTimersByTime(299);
  expect(fn).not.toHaveBeenCalled();

  vi.advanceTimersByTime(1);
  expect(fn).toHaveBeenCalledTimes(1);
});
```

### Snapshots and Error Assertions

```typescript
import { expect, it } from 'vitest';
import { formatReport, parseDuration } from './report';

it('formats a compact summary line', () => {
  expect(formatReport({ passed: 12, failed: 1, skipped: 2 })).toMatchInlineSnapshot(
    `"12 passed | 1 failed | 2 skipped"`,
  );
});

it('throws a typed error on malformed durations', () => {
  expect(() => parseDuration('5parsecs')).toThrowErrorMatchingInlineSnapshot(
    `[RangeError: unknown duration unit "parsecs"]`,
  );
});
```

### Monorepo Projects and In-Source Tests

```typescript
// vitest.config.ts at the monorepo root
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    projects: [
      { test: { name: 'shared', root: './packages/shared', environment: 'node' } },
      { test: { name: 'web', root: './packages/web', environment: 'jsdom' } },
    ],
  },
});
```

```bash
vitest run --project shared   # one package
vitest run                    # everything, parallelized
```

In-source tests for small internal utilities (stripped from production builds by `define: { 'import.meta.vitest': 'undefined' }`):

```typescript
// src/slug.ts
export function slugify(input: string): string {
  return input.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}

if (import.meta.vitest) {
  const { expect, it } = import.meta.vitest;
  it('collapses punctuation runs into single hyphens', () => {
    expect(slugify('  Hello, World! ')).toBe('hello-world');
  });
}
```

## Best Practices

- Set `restoreMocks: true` globally instead of sprinkling `vi.restoreAllMocks()` in every `afterEach`.
- Use `vitest related src/pricing.ts` in pre-commit hooks to run only tests touching changed files.
- Assert promise rejections with `await expect(p).rejects.toThrow(...)` — a bare `expect(p).rejects` without await can pass before settlement.
- Pin the environment per file when only some tests need DOM: `// @vitest-environment jsdom` at the top of the file.
- Prefer `test.each` for input tables over copy-pasted tests; each row reports as its own case.
- When migrating from Jest: `vi` replaces `jest`, `vi.mock` factories must return the module shape explicitly (no automock), and `jest.requireActual` becomes `importOriginal`.

## Anti-Patterns

- **Referencing top-level variables inside a `vi.mock` factory.** Hoisting makes them `undefined` at factory time — the error message mentions hoisting, believe it. Use `vi.hoisted`.
- **`globals: true` plus missing TS types.** If you enable globals, add `"types": ["vitest/globals"]` to tsconfig, or imports break silently in editors.
- **Giant `.toMatchSnapshot()` on full API responses.** Hundred-line snapshots get rubber-stamp updated. Snapshot small, stable slices; assert dynamic fields with matchers.
- **`vi.mock` of the module under test.** You end up testing your own mock. Mock dependencies, never the subject.
- **Forgetting `vi.useRealTimers()` cleanup** — fake timers leak into later tests and hang anything that genuinely waits.
- **Re-implementing Vite aliases inside `test.alias`** when they already exist in `vite.config.ts`; drift between the two breaks resolution in tests only.

## When to Trigger This Skill

- The project is Vite-based or has `vitest` in `devDependencies`.
- The user asks to add unit tests, mock a module, fake timers, or snapshot output in a TS/JS repo without Jest.
- Setting up coverage gates or monorepo test projects with package-specific environments.
- Migrating a Jest suite to Vitest (jest → vi API mapping, mock factory differences).
- Tests fail with hoisting errors, environment mismatches, or leaking mocks — the classic Vitest misconfigurations.

