A real project is rarely tested in one environment. Server logic wants clean Node, components want a DOM, and some code is worth running in a real browser. Vitest covers this via test.projects - an array of configurations within one run, where each project has its own environment, its own include and its own settings. The former word workspace for this was removed in version four: project configuration is now kept right in the projects field of the test section.
Splitting by projects solves a common pain - one config for heterogeneous code. Instead of running server tests in jsdom needlessly and leaving component tests without a DOM, you set up two projects: one with environment 'node' and its own include on server files, another with 'jsdom' on components. Vitest runs both in one call and merges the report, and each kind of code rides in its own environment.
A separate topic is pools, the mechanism in which tests physically execute. By default in version four this is forks: each worker is a separate process, which gives real isolation, including for native modules and global state. The alternative, threads on worker threads, is usually faster thanks to lower overhead but isolates more weakly: shared state and finicky native addons behave less predictably there.
The choice between them is a trade of speed for isolation. Threads are taken when tests are pure and independent and speed on a large suite matters. Forks are kept when native modules, global singletons or tests that cannot run in shared memory are in play. The default is not accidental: isolation matters more than a couple of percent of speed until proven otherwise.
Pool settings in version four became flat. They used to be hidden in poolOptions with nesting by pool type; now key options like isolate and maxWorkers are set directly in the test section, without the extra level. isolate: false disables recreating the environment between files and speeds up the run, but removes part of the isolation - it is enabled consciously, when you know for sure the tests do not soil the shared world.
Browser Mode is running tests in a real browser instead of jsdom emulation. You enable it in the browser section: enabled, a provider and a list of instances with concrete browsers. In version four the provider is set by a function from a separate package - import { playwright } from '@vitest/browser-playwright', and you pass provider: playwright(). Each instance describes a browser, for example chromium, and they all run as projects.
The point of Browser Mode is access to the platform's real APIs. jsdom emulates the DOM but is not a browser: real layout, geometry, input events, clipboard, focus behave approximately in it. When a test depends on exactly such behavior - measuring sizes, hover, a real click on an overlapped element - it is run in a browser, where the API is not an emulation but the engine itself. The price is speed and infrastructure, so not everything goes to the browser, only what jsdom does not honestly reproduce.
The typical failures here are about a mismatch of environment to task. Running everything in jsdom and being surprised a size-measuring test lies - jsdom does not compute them for real. Dragging the whole suite into Browser Mode for a couple of DOM-dependent tests - slow and expensive. And keeping heterogeneous code in one project instead of test.projects means forcing a DOM on server tests and taking it from component ones; better to lay them out by projects and environments.
// vitest.config.ts - one run, different environments
export default defineConfig({
test: {
projects: [
{ test: { name: 'node', environment: 'node', include: ['src/server/**/*.test.ts'] } },
{ test: { name: 'dom', environment: 'jsdom', include: ['src/ui/**/*.test.tsx'] } },
],
},
})npm i -D @vitest/browser-playwright
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
},
},
})