vitest
Vitest is a test runner built on top of Vite. It reuses your app's Vite config, plugins, and transform pipeline, so TypeScript, JSX, and ESM work in tests without a separate Babel or ts-jest setup. The API is deliberately Jest-shaped: describe/it/expect, snapshots, mocking and spies via vi, and coverage through v8 or istanbul. On top of that it adds type-level testing with expect-type, benchmarking via Tinybench, a projects config for monorepos, and a browser mode that runs component tests in real browsers. Watch mode re-runs only affected tests, closer to HMR than a full re-run.
The default test runner for new TypeScript and Vite projects in 2026; the Jest-compatible API makes most migrations mechanical. Stay on Jest only if old Node versions or deep Jest internals pin you there.
Use it if
- Your project already builds with Vite: tests share the same config, plugins, and aliases with zero duplicate setup
- You are migrating from Jest and want a compatible expect/mock/snapshot API that is ESM-first instead of CJS-first
- You need TypeScript and JSX in tests out of the box, without wiring Babel or ts-jest
- You want type-level tests, benchmarks, or in-browser component tests inside the same runner
- You are on an older toolchain: Vitest 4 requires Vite 6.4+ and Node 22.12+, which rules out plenty of LTS-pinned CI environments
- You have a large Jest suite that touches Jest internals (custom runners, custom transforms, timer edge cases): compatibility is close but not 1:1, and migration debugging costs real days
- You are testing a tiny dependency-free Node library: node:test ships with Node and costs nothing to install
- Your tests rely on CJS-style module mocking habits: vi.mock under ESM has hoisting and module-graph rules that regularly surprise Jest veterans
Setup reality
npm install -D vitest and npx vitest just works if you already use Vite. The annoying parts: Node 22.12+ and Vite 6.4+ are hard requirements for v4, so older CI images fail immediately; DOM testing needs an explicit environment setting plus a separate jsdom or happy-dom install; globals like describe are off by default until you set globals: true and add vitest/globals to tsconfig types; coverage lives in a separate @vitest/coverage-v8 package. Monorepos need the projects config to run one Vitest across packages.
Patterns
Write a basic test suiteunit-test
import { describe, expect, it } from 'vitest'
describe('math', () => {
it('adds', () => {
expect(1 + 1).toBe(2)
})
})Imports are explicit by default; set globals: true in config if you want Jest-style implicit describe/it/expect.
Test async success and failureasync-test
import { expect, it } from 'vitest'
it('resolves and rejects', async () => {
await expect(fetchUser(1)).resolves.toMatchObject({ id: 1 })
await expect(fetchUser(-1)).rejects.toThrow('not found')
})Always await resolves/rejects assertions; without await a failing promise can pass silently.
Mock a callback with vi.fnmock-function
import { expect, it, vi } from 'vitest'
it('calls the callback once', () => {
const cb = vi.fn().mockReturnValue(42)
run(cb)
expect(cb).toHaveBeenCalledTimes(1)
expect(cb).toHaveBeenCalledWith('start')
})Mock state persists between tests unless you enable mockReset in config or call vi.restoreAllMocks in afterEach.
Mock an imported modulemock-module
import { expect, it, vi } from 'vitest'
import { getUser } from './api'
vi.mock('./api', () => ({
getUser: vi.fn().mockResolvedValue({ id: 1 }),
}))
it('uses the mocked module', async () => {
expect(await getUser(1)).toEqual({ id: 1 })
})vi.mock calls are hoisted to the top of the file; variables defined nearby are not visible inside the factory unless wrapped in vi.hoisted.
Spy on an object method and restore itspy-on-method
import { afterEach, expect, it, vi } from 'vitest'
afterEach(() => {
vi.restoreAllMocks()
})
it('logs an error on boot failure', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
boot()
expect(spy).toHaveBeenCalled()
})Restore spies after each test or the patched method leaks into every test that runs later in the file.
Control time with fake timersfake-timers
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('fires after the debounce window', () => {
const fn = vi.fn()
const run = debounce(fn, 1000)
run()
vi.advanceTimersByTime(1000)
expect(fn).toHaveBeenCalledOnce()
})Fake timers do not fake process.nextTick or queueMicrotask by default, and forgetting useRealTimers breaks later async tests.
Snapshot and inline snapshotsnapshot-test
import { expect, it } from 'vitest'
it('matches snapshots', () => {
expect(buildRoutes(config)).toMatchSnapshot()
expect(routeCount(config)).toMatchInlineSnapshot('3')
})Update stale snapshots with vitest -u; inline snapshots are rewritten inside the test file itself.
Configure environment and setup filesconfigure-runner
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./test/setup.ts'],
},
})jsdom is not bundled; install jsdom or happy-dom yourself or every DOM test fails at startup.
Collect coverage with thresholdsrun-coverage
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
coverage: {
provider: 'v8',
thresholds: { lines: 80 },
},
},
})
// then: npx vitest run --coverageCoverage requires the separate @vitest/coverage-v8 (or -istanbul) package; Vitest offers to install it on first run.
Assert types with expectTypeOftype-test
import { expectTypeOf, test } from 'vitest'
test('getUser types', () => {
expectTypeOf(getUser).parameter(0).toBeNumber()
expectTypeOf(getUser).returns.resolves.toMatchTypeOf<{ id: number }>()
})Type tests only execute with vitest --typecheck; in a normal run they are skipped without warning.
Run tests in a suite concurrentlyconcurrent-tests
import { describe, it } from 'vitest'
describe.concurrent('independent api calls', () => {
it('fetches a', async ({ expect }) => {
expect(await getA()).toBeTruthy()
})
it('fetches b', async ({ expect }) => {
expect(await getB()).toBeTruthy()
})
})In concurrent tests use the expect from the test context so snapshots and assertions attach to the right test.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| jest | npm | Legacy CJS codebases or teams pinned below Node 22 where the older runner still fits |
| @playwright/test | npm | End-to-end browser testing rather than unit and component tests |
| mocha | npm | A minimal, assertion-agnostic runner for plain Node libraries |