A

Vitest - Modern Testing for Vite Projects

testingvitestviteunit-testingtypescriptesm

Vitest - Modern Testing for Vite Projects

Vitest is a blazing-fast unit testing framework built on top of Vite. It provides a Jest-compatible API while leveraging Vite's native ES modules support and hot module replacement for instant test feedback. If you're using Vite for your build (which includes Vue, React, Svelte, and Astro projects), Vitest is the natural choice for testing.

Why Vitest?

Native ESM support. Unlike Jest, which requires transformers and configuration gymnastics for ES modules, Vitest handles ESM natively. Your tests use the same module resolution as your application.

Vite integration. Vitest reuses your existing vite.config.ts—same aliases, plugins, and transforms. No duplicate configuration.

Jest-compatible API. If you know Jest, you know Vitest. The migration path is straightforward, and most Jest tests run with minimal changes.

Watch mode that actually works. Vitest only re-runs tests affected by your changes, with sub-second feedback during development.

TypeScript out of the box. No ts-jest, no Babel configuration. Just write TypeScript tests.

Installation and Setup

Install Vitest as a dev dependency:

pnpm add -D vitest

Add test scripts to your package.json:

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

Configuration

Create vitest.config.ts in your project root. For most projects, you can extend your existing Vite config:

// vitest.config.ts
import { defineConfig, mergeConfig } from 'vitest/config';
import viteConfig from './vite.config';

export default mergeConfig(
  viteConfig,
  defineConfig({
    test: {
      globals: true,
      environment: 'jsdom',
      setupFiles: ['./src/test/setup.ts'],
      include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
      coverage: {
        provider: 'v8',
        reporter: ['text', 'json', 'html'],
      },
    },
  })
);

Or create a standalone config if you don't need Vite's plugins:

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

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
  },
});

Enabling Global APIs

By default, you must import test functions explicitly:

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

With globals: true, these are available without imports (like Jest). You'll also need to tell TypeScript about the globals:

// tsconfig.json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

Test Environments

Vitest supports multiple environments for different testing needs:

Environment Use Case Install
node Backend code, utilities, no DOM Built-in
jsdom Browser-like DOM (most frontend tests) pnpm add -D jsdom
happy-dom Faster jsdom alternative pnpm add -D happy-dom
edge-runtime Edge/serverless functions pnpm add -D @edge-runtime/vm

Set the environment globally in config or per-file:

// At the top of a specific test file
// @vitest-environment jsdom

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

Writing Tests

Basic Structure

Vitest uses the familiar describe/it/expect pattern:

// src/utils/string.test.ts
import { describe, it, expect } from 'vitest';
import { capitalize, slugify } from './string';

describe('string utilities', () => {
  describe('capitalize', () => {
    it('capitalizes the first letter', () => {
      expect(capitalize('hello')).toBe('Hello');
    });

    it('handles empty strings', () => {
      expect(capitalize('')).toBe('');
    });

    it('preserves already capitalized strings', () => {
      expect(capitalize('Hello')).toBe('Hello');
    });
  });

  describe('slugify', () => {
    it('converts spaces to hyphens', () => {
      expect(slugify('hello world')).toBe('hello-world');
    });

    it('lowercases and removes special characters', () => {
      expect(slugify('Hello, World!')).toBe('hello-world');
    });
  });
});

Common Matchers

// Equality
expect(value).toBe(5);                    // Strict equality (===)
expect(obj).toEqual({ a: 1 });            // Deep equality
expect(obj).toStrictEqual({ a: 1 });      // Deep equality + same type

// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();

// Numbers
expect(value).toBeGreaterThan(3);
expect(value).toBeLessThanOrEqual(10);
expect(0.1 + 0.2).toBeCloseTo(0.3);       // Floating point comparison

// Strings
expect(str).toMatch(/pattern/);
expect(str).toContain('substring');

// Arrays
expect(arr).toContain(item);
expect(arr).toHaveLength(3);
expect(arr).toEqual(expect.arrayContaining([1, 2]));

// Objects
expect(obj).toHaveProperty('key');
expect(obj).toHaveProperty('nested.key', 'value');
expect(obj).toMatchObject({ partial: 'match' });

// Exceptions
expect(() => badFunction()).toThrow();
expect(() => badFunction()).toThrow('specific message');
expect(() => badFunction()).toThrow(CustomError);

// Promises
await expect(promise).resolves.toBe('value');
await expect(promise).rejects.toThrow('error');

Async Testing

Vitest handles async code naturally:

// Async/await (preferred)
it('fetches user data', async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe('John');
});

// Returning promises
it('fetches user data', () => {
  return fetchUser(1).then(user => {
    expect(user.name).toBe('John');
  });
});

// Testing rejections
it('throws for invalid user', async () => {
  await expect(fetchUser(-1)).rejects.toThrow('User not found');
});

Setup and Teardown

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

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

  afterAll(async () => {
    // Runs once after all tests
    await db.disconnect();
  });

  beforeEach(async () => {
    // Runs before each test
    await db.seed();
  });

  afterEach(async () => {
    // Runs after each test
    await db.clean();
  });

  it('creates a user', async () => {
    // Test with fresh database state
  });
});

Mocking

Function Mocks with vi.fn()

Create mock functions to track calls and control return values:

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

describe('button click handler', () => {
  it('calls onClick when clicked', () => {
    const onClick = vi.fn();

    render(<Button onClick={onClick}>Click me</Button>);
    fireEvent.click(screen.getByRole('button'));

    expect(onClick).toHaveBeenCalled();
    expect(onClick).toHaveBeenCalledTimes(1);
    expect(onClick).toHaveBeenCalledWith(expect.any(Object)); // Event object
  });
});

Mock functions can return specific values:

const mockFetch = vi.fn();

// Return a value
mockFetch.mockReturnValue('mocked');

// Return different values on successive calls
mockFetch
  .mockReturnValueOnce('first')
  .mockReturnValueOnce('second')
  .mockReturnValue('default');

// Return a resolved promise
mockFetch.mockResolvedValue({ data: 'success' });

// Return a rejected promise
mockFetch.mockRejectedValue(new Error('Network error'));

// Custom implementation
mockFetch.mockImplementation((url) => {
  if (url.includes('/users')) {
    return Promise.resolve({ users: [] });
  }
  return Promise.reject(new Error('Not found'));
});

Module Mocking with vi.mock()

Mock entire modules to isolate code under test:

// src/services/api.ts
export async function fetchUsers() {
  const response = await fetch('/api/users');
  return response.json();
}

// src/services/api.test.ts
import { describe, it, expect, vi } from 'vitest';
import { fetchUsers } from './api';

// Mock at the top of the file
vi.mock('./api', () => ({
  fetchUsers: vi.fn(),
}));

describe('api service', () => {
  it('returns mocked users', async () => {
    // Type assertion needed for mocked function
    const mockFetchUsers = fetchUsers as ReturnType<typeof vi.fn>;
    mockFetchUsers.mockResolvedValue([{ id: 1, name: 'John' }]);

    const users = await fetchUsers();
    expect(users).toHaveLength(1);
    expect(users[0].name).toBe('John');
  });
});

For more control, use the factory function:

// Mock external dependency
vi.mock('axios', () => ({
  default: {
    get: vi.fn(),
    post: vi.fn(),
  },
}));

// In test
import axios from 'axios';

it('makes API call', async () => {
  vi.mocked(axios.get).mockResolvedValue({ data: { id: 1 } });

  const result = await myService.getUser(1);

  expect(axios.get).toHaveBeenCalledWith('/api/users/1');
  expect(result.id).toBe(1);
});

Spying with vi.spyOn()

Spy on methods while keeping original implementation:

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

describe('console logging', () => {
  it('logs error messages', () => {
    const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

    logError('Something went wrong');

    expect(consoleSpy).toHaveBeenCalledWith('Error:', 'Something went wrong');

    consoleSpy.mockRestore(); // Restore original implementation
  });
});

Spy on object methods:

const user = {
  getName() {
    return 'John';
  },
};

const spy = vi.spyOn(user, 'getName');

user.getName();

expect(spy).toHaveBeenCalled();
expect(spy).toHaveReturnedWith('John');

Mocking Timers

Control time-based code with fake timers:

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

describe('debounce', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

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

  it('delays execution', () => {
    const fn = vi.fn();
    const debounced = debounce(fn, 1000);

    debounced();
    expect(fn).not.toHaveBeenCalled();

    vi.advanceTimersByTime(500);
    expect(fn).not.toHaveBeenCalled();

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

  it('resets timer on subsequent calls', () => {
    const fn = vi.fn();
    const debounced = debounce(fn, 1000);

    debounced();
    vi.advanceTimersByTime(500);
    debounced(); // Reset timer
    vi.advanceTimersByTime(500);

    expect(fn).not.toHaveBeenCalled();

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

Other timer utilities:

vi.runAllTimers();           // Run all pending timers
vi.runOnlyPendingTimers();   // Run only currently pending (not new ones)
vi.advanceTimersToNextTimer(); // Advance to next scheduled timer
vi.setSystemTime(new Date('2024-01-01')); // Set current date
vi.getRealSystemTime();      // Get actual system time

Resetting Mocks

afterEach(() => {
  vi.clearAllMocks();    // Clear call history (mock.calls, mock.results)
  vi.resetAllMocks();    // Clear history + reset to initial state
  vi.restoreAllMocks();  // Restore original implementations (for spies)
});

Code Coverage

Install the coverage provider:

pnpm add -D @vitest/coverage-v8

Configure coverage in vitest.config.ts:

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

Run with coverage:

pnpm test:coverage

Coverage output appears in the terminal and in ./coverage/ directory. Open coverage/index.html for an interactive report.

Coverage Thresholds

Enforce minimum coverage in CI:

coverage: {
  thresholds: {
    // Global thresholds
    lines: 80,
    functions: 80,
    branches: 75,
    statements: 80,

    // Per-file thresholds (stricter for critical code)
    'src/utils/**': {
      lines: 95,
      functions: 95,
    },
  },
}

Running Tests

Watch Mode (Default)

pnpm test

Watch mode re-runs tests on file changes. Press keys to interact:

  • a - Run all tests
  • f - Run only failed tests
  • p - Filter by filename pattern
  • t - Filter by test name pattern
  • q - Quit

Single Run

pnpm test run

Filtering Tests

# Run tests matching filename
pnpm test user

# Run tests matching pattern
pnpm test --testNamePattern="creates user"

# Run specific file
pnpm test src/utils/string.test.ts

# Run tests in specific directory
pnpm test src/components/

In-Code Filtering

Skip or focus tests during development:

describe.skip('broken feature', () => {
  // All tests skipped
});

describe.only('feature I am working on', () => {
  // Only this describe block runs
});

it.skip('incomplete test', () => {});
it.only('just this one', () => {});

// Conditional skip
it.skipIf(process.env.CI)('slow test', () => {});
it.runIf(process.env.CI)('CI only test', () => {});

Warning: Don't commit .only calls. Use a lint rule to catch them.

TypeScript Configuration

Vitest handles TypeScript natively, but you should configure a few things:

// tsconfig.json
{
  "compilerOptions": {
    "types": ["vitest/globals"],
    "esModuleInterop": true,
    "strict": true
  },
  "include": ["src/**/*", "src/**/*.test.ts"]
}

Type-Checking in Tests

Vitest doesn't type-check by default (for speed). Run tsc separately or enable type-checking:

// vitest.config.ts
export default defineConfig({
  test: {
    typecheck: {
      enabled: true,
      tsconfig: './tsconfig.json',
    },
  },
});

Or run type-checking as a separate command:

pnpm tsc --noEmit && pnpm test run

Typed Mocks

Use vi.mocked() for proper typing of mocked functions:

import { vi, expect } from 'vitest';
import { fetchUser } from './api';

vi.mock('./api');

it('handles typed mock', async () => {
  vi.mocked(fetchUser).mockResolvedValue({ id: 1, name: 'John' });

  const user = await fetchUser(1);
  expect(user.name).toBe('John'); // TypeScript knows the shape
});

Testing React/Vue/Svelte Components

Install Testing Library for your framework:

# React
pnpm add -D @testing-library/react @testing-library/jest-dom jsdom

# Vue
pnpm add -D @testing-library/vue jsdom

# Svelte
pnpm add -D @testing-library/svelte jsdom

Setup file for React with Testing Library:

// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

afterEach(() => {
  cleanup();
});

Example React component test:

// src/components/Counter.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { Counter } from './Counter';

describe('Counter', () => {
  it('increments count on button click', async () => {
    const user = userEvent.setup();
    render(<Counter initialCount={0} />);

    expect(screen.getByText('Count: 0')).toBeInTheDocument();

    await user.click(screen.getByRole('button', { name: /increment/i }));

    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });

  it('respects initial count', () => {
    render(<Counter initialCount={10} />);
    expect(screen.getByText('Count: 10')).toBeInTheDocument();
  });
});

Gotchas and Tips

ESM and CommonJS Interop

If you import a CommonJS package that Vitest can't transform:

// vitest.config.ts
export default defineConfig({
  test: {
    deps: {
      inline: ['problematic-package'],
    },
  },
});

Mocking ES Modules

ES modules are hoisted, so vi.mock() calls are automatically moved to the top:

// This works despite being after the import
import { foo } from './module';
vi.mock('./module'); // Hoisted above the import

But dynamic mocks need vi.doMock():

it('uses dynamic mock', async () => {
  vi.doMock('./module', () => ({
    foo: vi.fn().mockReturnValue('mocked'),
  }));

  // Must use dynamic import after doMock
  const { foo } = await import('./module');
  expect(foo()).toBe('mocked');
});

Parallel Test Execution

Tests in different files run in parallel by default. For tests that share state:

// vitest.config.ts
export default defineConfig({
  test: {
    // Run all tests sequentially
    sequence: {
      concurrent: false,
    },

    // Or limit parallelism
    maxConcurrency: 5,

    // Or isolate tests in separate threads
    isolate: true,
  },
});

Debugging Tests

Run in headed mode with Node inspector:

# Debug with Node
node --inspect-brk ./node_modules/vitest/vitest.mjs --run

# Or use Vitest UI
pnpm add -D @vitest/ui
pnpm test --ui

VSCode launch configuration:

{
  "type": "node",
  "request": "launch",
  "name": "Debug Vitest",
  "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
  "args": ["--run", "--reporter=verbose"],
  "cwd": "${workspaceRoot}",
  "console": "integratedTerminal"
}

See Also

Last updated: March 23, 2026