تخطَّ إلى المحتوى

Vitest

Vitest is a Vite-native unit testing framework that reuses your Vite configuration for testing. It is dramatically faster than Jest for TypeScript and ESM projects, supports the same test APIs as Jest, and adds features like workspace mode, browser testing, and a built-in UI.

Installation

# npm
npm install --save-dev vitest

# pnpm
pnpm add -D vitest

# Bun
bun add -d vitest

# With UI (interactive test runner)
npm install --save-dev @vitest/ui

# With coverage provider
npm install --save-dev @vitest/coverage-v8
# or
npm install --save-dev @vitest/coverage-istanbul

# With browser mode
npm install --save-dev @vitest/browser

Add Scripts to package.json

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest run --coverage",
    "test:watch": "vitest --watch"
  }
}

Configuration

Vitest can be configured inside vite.config.ts or a separate vitest.config.ts:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  test: {
    // Test environment
    environment: 'node', // 'node' | 'jsdom' | 'happy-dom' | 'edge-runtime'

    // Setup files run before each test file
    setupFiles: ['./tests/setup.ts'],

    // Global APIs (no need to import test, expect, etc.)
    globals: true,

    // Include/exclude patterns
    include: ['**/*.{test,spec}.{js,ts,jsx,tsx}'],
    exclude: ['node_modules', 'dist', 'e2e/**'],

    // Coverage
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      reportsDirectory: './coverage',
      include: ['src/**'],
      exclude: ['src/**/*.d.ts', 'src/**/*.stories.ts'],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 75,
        statements: 80,
      },
    },

    // Timeout per test (ms)
    testTimeout: 10000,

    // Reporter
    reporter: ['verbose'],

    // Inline snapshots in the test file
    snapshotOptions: {
      snapshotFormat: {
        printBasicPrototype: false,
      },
    },

    // Pool and concurrency
    pool: 'forks',  // 'threads' | 'forks' | 'vmThreads'
    poolOptions: {
      forks: {
        maxForks: 4,
      },
    },

    // Retry flaky tests
    retry: 2,

    // Pass-through to Node for coverage
    passWithNoTests: false,
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
});

Setup File

// tests/setup.ts
import '@testing-library/jest-dom'; // extends expect with DOM matchers
import { afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';

// Clean up after each test
afterEach(() => {
  cleanup();
  vi.restoreAllMocks();
});

// Global mocks
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation((query) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
  })),
});

Core Commands

CommandDescription
vitestRun tests in watch mode
vitest runRun tests once and exit
vitest watchExplicit watch mode
vitest --uiOpen interactive UI
vitest run --coverageRun with coverage report
vitest run --reporter=verboseVerbose output
vitest run --reporter=jsonJSON output
vitest run --reporter=junitJUnit XML output
vitest run path/to/test.tsRun specific file
vitest run -t "test name"Run matching test names
vitest related src/foo.tsRun tests related to a file
vitest benchRun benchmark tests
vitest typecheckRun TypeScript type checking
vitest --project=my-libRun workspace project
vitest run --shard=1/3Run a shard (CI parallelization)

Advanced Usage

Test, Describe, and Expect API

import { test, describe, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';

describe('User authentication', () => {
  beforeAll(async () => {
    // Runs once before all tests in this describe
    await db.connect();
  });

  afterAll(async () => {
    await db.disconnect();
  });

  beforeEach(() => {
    // Runs before each test
  });

  afterEach(() => {
    // Runs after each test
  });

  test('logs in with valid credentials', async () => {
    const user = await login('alice', 'password123');
    expect(user).toBeDefined();
    expect(user.name).toBe('Alice');
  });

  test.skip('skipped test', () => {
    // Not run
  });

  test.todo('implement later');

  test.each([
    [1, 1, 2],
    [2, 3, 5],
    [10, 5, 15],
  ])('adds %i + %i = %i', (a, b, expected) => {
    expect(a + b).toBe(expected);
  });
});

Mocking with vi

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

// Mock a module
vi.mock('./db', () => ({
  query: vi.fn().mockResolvedValue([{ id: 1, name: 'Alice' }]),
}));

// Mock a module with factory
vi.mock('axios', async (importActual) => {
  const actual = await importActual<typeof import('axios')>();
  return {
    ...actual,
    default: {
      get: vi.fn().mockResolvedValue({ data: { ok: true } }),
    },
  };
});

test('mocks function', () => {
  const fn = vi.fn();
  fn.mockReturnValue(42);
  expect(fn()).toBe(42);
  expect(fn).toHaveBeenCalledTimes(1);
});

test('spies on method', () => {
  const obj = { getValue: () => 'original' };
  const spy = vi.spyOn(obj, 'getValue').mockReturnValue('mocked');
  expect(obj.getValue()).toBe('mocked');
  spy.mockRestore(); // restore original
});

test('mocks timers', () => {
  vi.useFakeTimers();
  const fn = vi.fn();
  setTimeout(fn, 1000);
  vi.advanceTimersByTime(1000);
  expect(fn).toHaveBeenCalledOnce();
  vi.useRealTimers();
});

// Stub environment variables
test('reads env var', () => {
  vi.stubEnv('API_URL', 'https://test.example.com');
  expect(process.env.API_URL).toBe('https://test.example.com');
  vi.unstubAllEnvs();
});

Snapshot Testing

import { test, expect } from 'vitest';

test('renders correctly', () => {
  const html = render(<Button label="Click me" />);
  expect(html).toMatchSnapshot();
});

// Inline snapshot
test('inline snapshot', () => {
  const user = { name: 'Alice', age: 30 };
  expect(user).toMatchInlineSnapshot(`
    {
      "age": 30,
      "name": "Alice",
    }
  `);
});

// Update snapshots
// vitest run -u  (or vitest run --update-snapshots)

Browser Mode (Component Testing)

// vitest.config.ts
export default defineConfig({
  test: {
    browser: {
      enabled: true,
      name: 'chromium',
      provider: 'playwright',
      headless: true,
    },
  },
});
// Button.test.tsx — runs in real browser
import { render, screen } from '@testing-library/react';
import { test, expect } from 'vitest';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';

test('calls onClick handler', async () => {
  const handleClick = vi.fn();
  render(<Button onClick={handleClick}>Click me</Button>);
  await userEvent.click(screen.getByRole('button'));
  expect(handleClick).toHaveBeenCalledOnce();
});

Workspace Mode (Monorepo)

// vitest.workspace.ts
import { defineWorkspace } from 'vitest/config';

export default defineWorkspace([
  'packages/*/vitest.config.ts',
  {
    test: {
      name: 'unit',
      include: ['packages/**/*.unit.test.ts'],
      environment: 'node',
    },
  },
  {
    test: {
      name: 'browser',
      include: ['packages/**/*.browser.test.tsx'],
      browser: {
        enabled: true,
        name: 'chromium',
      },
    },
  },
]);

CI Sharding for Parallelism

# GitHub Actions — split tests across 3 machines
strategy:
  matrix:
    shard: [1, 2, 3]
steps:
  - run: pnpm test:run --shard=${{ matrix.shard }}/3

Common Workflows

Testing React Components

npm install --save-dev @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
// vitest.config.ts — set jsdom environment
test: { environment: 'jsdom', setupFiles: ['./tests/setup.ts'] }
// tests/setup.ts
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { test, expect } from 'vitest';
import { Counter } from './Counter';

test('increments counter on click', async () => {
  render(<Counter />);
  const button = screen.getByRole('button', { name: /increment/i });
  await userEvent.click(button);
  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

Generating a Coverage Report

# Run with HTML coverage report
vitest run --coverage

# Open HTML report
open coverage/index.html

# Check thresholds (fails CI if below)
# Configure in vitest.config.ts: coverage.thresholds

Tips and Best Practices

Use globals: true for familiar Jest API — Enable globals to use test, expect, describe, vi without importing them in every file. Add "types": ["vitest/globals"] to tsconfig.json for type support.

Prefer vi.mock() at the top level — Module mocks are hoisted to the top of the file by Vitest (just like Jest). Don’t put vi.mock() inside beforeEach — it won’t work as expected.

Use vi.restoreAllMocks() in afterEach — Call this in your setup file to automatically restore spies after each test. Prevents mock state from leaking between tests.

Use test.each for data-driven tests — Instead of duplicating tests with different inputs, use test.each with a table or array. It produces clearer output and is easier to extend.

Pool selection matters — Use pool: 'forks' (default) for most cases. Use pool: 'threads' if you need shared memory between workers. Use pool: 'vmThreads' to isolate global state between test files.

Set testTimeout for async tests — The default timeout is 5 seconds. For tests involving network calls or slow async operations, increase it per-test with test('name', async () => { ... }, { timeout: 30000 }).

Use vitest --ui for debugging — The browser-based UI shows test output, coverage, and lets you re-run individual tests interactively. Much better than scanning terminal output for failures.

Use sharding in CI for large test suites — If your test suite takes more than 3 minutes in CI, split it across machines with --shard=N/TOTAL. Combine with a coverage merge step for accurate overall coverage numbers.