Vitest is a Vite-native test runner: assertions, mocks, snapshots and coverage in one package, with no separate assertion library needed. Its key difference from other runners is that it runs tests through the same Vite pipeline as the application: the same transform, the same plugins, the same aliases. So Vitest understands ESM, TypeScript, JSX and the project's virtual modules out of the box - no separate transform system for tests has to be built. Its API is close to Jest (expect, describe, test), so there is almost nothing to relearn.
The runner itself installs as a single dev dependency, but two more are needed right next to it. Coverage support in Vitest is optional and lives in a separate package - @vitest/coverage-v8 for the default provider, and without it the script with the --coverage flag will not run. The vite-tsconfig-paths plugin resolves paths from tsconfig, and the config below relies on it. It helps to add three scripts to package.json. The vitest command with no arguments starts watch in the interactive terminal and reruns only the affected tests, relying on Vite's dependency graph - change a module and only the tests that touch it rerun. The vitest run command performs exactly one pass and exits. In CI always use the explicit run script: watch there simply hangs and blocks the pipeline.
The first config lives in vitest.config.ts or in the test section of a shared Vite config, and defineConfig from vitest/config types it. Start with the minimum: the environment, the file glob and a couple of mock rules. The default environment is node, and for server code that is enough; a DOM environment (jsdom or the lighter happy-dom) is wired in only where it is actually needed, and better not globally but at the level of a single project or file.
Every field of the test section reads as a decision, not a ritual. environment sets the execution environment. include is which files count as tests. globals: false keeps describe and expect explicit imports, which is easier to type and read; enabling globals saves an import line at the cost of implicitness. clearMocks resets call history between tests, restoreMocks returns originals to replaced methods - both are about isolation, which has its own chapter. coverage picks the provider (v8 by default) and the report bounds.
It helps to lay out the main options and what each one buys you once, so you do not keep it in your head or copy someone else's config blindly. Below is a short map of the fields you return to most often.
| Option | Why | Do not confuse |
|---|---|---|
| environment | Environment: node / jsdom / happy-dom | Browser Mode is configured separately |
| setupFiles | Code before each test file | It must be idempotent |
| globals | Global describe/expect | Explicit imports are easier to type |
| resolve.alias | Shared Vite aliases | TS paths need a plugin |
| pool | threads / forks / vmThreads | Change it for isolation and native modules |
The Vite-native approach pays off precisely because tests see exactly the code that ships to production: the same transform, not an approximate copy of it. Aliases, tsconfig paths (via the vite-tsconfig-paths plugin), JSX and virtual modules do not have to be described in a second configuration and kept in sync with the first. Less configuration means fewer divergences between how the code is built for the app and how it is built for tests.
Hence the rule: keep the config minimal. Every global setting affects the whole suite and can quietly tie tests to an implicit environment - then they are green on your machine and red in CI or on a colleague's. Add an option only when you can explain the specific problem it solves, and where possible set narrow behavior closer to the test rather than globally for everyone.
The typical failure is setting jsdom globally just in case. Server tests do not need browser emulation: it starts slower and reflects the real runtime worse - the one where the code actually works with the file system, the network and the process. Keep node as the default, and turn jsdom or happy-dom on selectively where a real DOM is under test - later this becomes a separate Test Project with its own environment.
npm i -D vitest @vitest/coverage-v8 vite-tsconfig-paths
# package.json
# "scripts": {
# "test": "vitest",
# "test:run": "vitest run",
# "test:coverage": "vitest run --coverage"
# }import { defineConfig } from 'vitest/config'
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: 'node',
include: ['src/**/*.test.{ts,tsx}'],
globals: false,
clearMocks: true,
restoreMocks: true,
coverage: { provider: 'v8' },
},
})