Skip to content
beginner Phase 13 · Testing with Vitest and Vue Test Utils

Configuring Vitest

Wire Vitest into the project with globals and environment options.

35m
0 problems
Topic Progress 0%

Why a Jest Test Dies on `@/components/Button.vue` and a Vitest Test Doesn't

Write this test in a fresh Vue project and run it:

import { mount } from '@vue/test-utils'
import Button from '@/components/Button.vue'

it('renders the label', () => {
  expect(mount(Button, { props: { label: 'Save' } }).text()).toBe('Save')
})

Under Jest you get Cannot find module '@/components/Button.vue' or, worse, a raw SFC file that Node cannot parse. The fix is a pile of duplication: babel-jest, @vue/babel-preset-js, moduleNameMapper for the @ alias, vue-jest for single-file components, plus jest.mock configuration — and every alias, plugin, or CSS-module rule you add to vite.config.ts has to be mirrored by hand. That mirror is where test setups rot: the app compiles one way, the tests compile another, and the first divergence shows up as a test that passes in CI and fails when a developer runs it locally.

Vitest removes the mirror because it runs your tests through the same Vite pipeline the app uses. Same resolve.alias, same @vitejs/plugin-vue compiling SFCs, same define, same PostCSS and CSS-module handling. If it builds in the app, it builds in the test — no second resolver to keep in sync.

The setup itself is three packages plus a config block:

npm install -D vitest @vue/test-utils jsdom
npm install -D @vitest/coverage-v8   # only if you want coverage reports
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
  },
  test: {
    environment: 'jsdom',
    globals: true,
    include: ['src/**/__tests__/*.{spec,test}.ts'],
  },
})

The test key is a Vitest extension to Vite's config: TypeScript picks it up through the /// <reference> comment, and Vite itself ignores the unknown key during vite build. You do not need a separate Jest config file ever again.

There is a second-order benefit worth knowing before an interviewer probes: Vitest executes test files as native ESM through Vite's transform server, so there is no Babel round-trip and no jest.config transform list. A test that imports a .vue file, a .ts module, and a raw .json asset all go through the same request-based transform the dev server already serves, which is also why a cold suite starts in roughly the time the dev server takes to boot rather than a full Babel compile per file.

Two structurally different situations exist. When vite.config.ts already holds your aliases and plugins — the normal app — put test there and inherit everything for free. When you deliberately want test configuration isolated from the build (a plugin that must not run under Vitest, or a monorepo package with no Vite config), create vitest.config.ts and merge instead of redeclaring:

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

export default mergeConfig(viteConfig, defineConfig({
  test: { environment: 'node' },
}))

mergeConfig copies aliases and plugins from the Vite config and lets the test block override only what differs. Recreating the alias list by hand in a second file is exactly the duplication you were escaping.

Interviewers ask about this more than you would expect — "why Vitest over Jest for Vue?" has one strong answer: one transform pipeline means the code under test is compiled by the same plugins, aliases, and define values as production, so configuration drift cannot make tests lie. Once this runs, Mounting Components in Tests can assume mount() works in every file, and the config you just wrote is what makes that true.

You arrived here from JavaScript Transition Hooks; nothing in that topic is assumed beyond knowing what a component file is.

`ReferenceError: document is not defined` and the Choice of Test Environment

The config above contains two lines that decide whether component tests run at all. Remove environment: 'jsdom' and your first mount() produces:

ReferenceError: document is not defined
  at Object.<anonymous> src/components/__tests__/Button.spec.ts:5:24

The test file compiled fine — the failure is environmental. Vitest runs each test file in a Node process, and Node has no DOM: no document, no window, no event dispatch, no getComputedStyle. The jsdom environment installs a JavaScript implementation of those APIs before your file runs, which is what mount() needs to build and patch real DOM nodes. jsdom is not a browser: it has no layout engine, no rendering, and no navigation, so anything that measures pixel geometry will mislead you.

Environment What it provides Use it for
jsdom DOM, events, localStorage, no layout component mounting, most Vue tests
happy-dom lighter, faster DOM subset large suites where jsdom startup dominates
node no DOM at all composables, pure functions, API clients

The node option is worth adopting deliberately: a composable test that only touches ref and fetch boots noticeably faster without a DOM, and the restriction is a feature — it proves the logic does not secretly depend on window. You can switch per file with a docblock comment at the top of a single spec:

// @vitest-environment node
import { useCounter } from '@/composables/useCounter'

jsdom also decides which browser APIs your tests can fake. It implements localStorage, sessionStorage, History, and event dispatch, but it deliberately omits several APIs that real UI libraries reach for — window.matchMedia is a common one, and any component using a responsive-directive mixin throws TypeError: window.matchMedia is not a function in tests while working perfectly in the browser. The setupFiles entry discussed below is where you polyfill exactly those gaps:

// src/test/setup.ts
window.matchMedia ??= ((query: string) => ({
  matches: false, media: query, addEventListener() {}, removeEventListener() {},
})) as typeof window.matchMedia

Polyfilling in setup rather than in the component keeps the production code honest: the component still calls the real API, and the test environment supplies the missing piece once for every file.

The second config line, globals: true, makes describe, it, expect, vi, and beforeEach available without importing them in every file. It matches the Jest muscle memory most teams bring over, but it has a real cost: the file no longer shows where its test API comes from, and TypeScript only accepts the bare identifiers when tsconfig.json lists "types": ["vitest/globals"]. Import explicitly instead if you prefer tests where dependencies are visible on line one:

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

Both styles pass the suite; pick one per repository and never mix them, because a file that imports it alongside a global it shadows confusingly and linters flag the inconsistency.

Two more config keys pay for themselves early. setupFiles: ['src/test/setup.ts'] runs once per file before the test body — the standard place for enableAutoUnmount(afterEach) so wrappers never leak between tests, and for stubbing browser APIs you do not want to repeat. And css: false tells Vitest to skip processing component styles: unless a test genuinely asserts on CSS classes defined only in a scoped stylesheet, turning stylesheet parsing off shaves time from every file without changing what you can assert on class names.

The environment decision also sets up the next topic: with jsdom in place, mount() has a DOM to render into, and every query you run against that DOM — find, text, classes — depends on jsdom having created it.

Watch Mode Locally, `vitest run` in CI: Scripts That Don't Hang the Pipeline

A test setup that is awkward to run gets skipped, and skipped tests are worse than no tests. The command surface is small, but each mode exists for a reason.

vitest (usually wired as "test": "vitest") starts watch mode: it discovers files, then keeps the process alive and re-runs only the files affected by each edit. That is the local loop — save a file, see its results in a few hundred milliseconds, without re-running the suite. The trap is CI: watch mode never exits, so a pipeline using npm test sits until the job timeout and reports a failure your tests never caused. The one-shot form is the run flag or the subcommand:

{
  "scripts": {
    "test": "vitest",
    "test:unit": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui"
  }
}
Command Mode Where it belongs
vitest watch, never exits local development
vitest run executes once, exits with a code CI, pre-commit hooks
vitest run --coverage once + report, can fail on thresholds CI quality gate
vitest --ui interactive browser dashboard debugging a failing assertion

Speed in CI comes from filtering, not from skipping tests entirely. Three filters cover the daily cases:

vitest run src/components/__tests__/Cart.spec.ts   # one file
vitest run -t "applies the discount code"          # test names, substring match
vitest run --changed=HEAD~1                        # only files touched since a commit

--changed is the one people miss: it asks Git which source files your last commit or working-tree edit modified and runs only the tests that could be affected, which turns a 400-test suite into a 12-test local check while still being a real run.

The other half of the CI story is making the exit code mean something. Coverage with the V8 provider is the common gate:

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov'],
      thresholds: { lines: 80, branches: 70 },
    },
  },
})

Now a pull request that drops below 80% lines fails the job with a readable report instead of slipping through. Add CI=true when a script might still fall back to watch, plus --reporter=junit --outputFile=junit.xml so the platform can render per-test results, and --passWithNoTests in a monorepo package where the glob legitimately matches nothing on some runs — without it, Vitest exits non-zero on an empty match.

Scale comes from sharding. Vitest splits files across isolated workers, and for a large suite you can split the run across CI machines instead of one long job:

vitest run --shard=1/3   # machine A
vitest run --shard=2/3   # machine B
vitest run --shard=3/3   # machine C

Each shard reports its own exit code and artifacts, and the gate is the AND of the three — wall-clock time drops roughly in proportion to the shard count, at the price of three runners to provision. Before reaching for shards, check the cheap wins first: --reporter=dot for compact output, dropping css processing, and exclude entries so generated files and fixtures are never collected as tests.

Determinism is the requirement people forget: a suite that flakes in watch mode but passes on re-run trains the team to ignore red. Real timers, shared module state, and un-flushed promises are the usual causes, and each gets a specific fix in Testing Async Updates.

With npm test for the loop and npm run test:unit for the gate, the environment is configured. Mounting Components in Tests starts where this leaves off: a mount() call that actually renders.

Quiz

1. Your team's Jest suite fails with "Cannot find module '@/components/Button.vue'" while the Vite dev server resolves that alias fine. What is the root cause?

Question 1 options

2. You remove `environment: 'jsdom'` from the test config and the first `mount()` fails. What error do you see?

Question 2 options

3. What is the practical difference between `vitest` and `vitest run`?

Question 3 options

4. Why put the `test` block inside vite.config.ts instead of creating an independent vitest.config.ts?

Question 4 options

Flashcards

Question

What does the `environment: 'jsdom'` setting actually provide to a Vue test?

Answer

A JavaScript implementation of DOM APIs — `document`, `window`, events, `getBoundingClientRect` — installed before each test file runs, so `mount()` can build and patch real DOM nodes. jsdom has no layout or rendering engine, so geometry assertions are unreliable; use `node` environment for DOM-free logic tests.

Question

Why does `globals: true` exist, and what must you do for TypeScript to accept it?

Answer

It exposes `describe`, `it`, `expect`, and `vi` without per-file imports, matching Jest conventions. TypeScript then needs `"types": ["vitest/globals"]` in tsconfig, otherwise the identifiers are unresolved; the alternative is importing them from `vitest` explicitly in each file.

Question

Which Vitest command belongs in CI and why?

Answer

`vitest run` (or `vitest --run`): it executes once and exits with a status code. Plain `vitest` starts watch mode and never terminates, so a pipeline using it hangs until the job timeout instead of reporting test results.

Question

How does Vitest resolve `@/components/Button.vue` without extra config?

Answer

It executes tests through Vite's own pipeline, so `resolve.alias`, `@vitejs/plugin-vue`, `define`, and CSS handling from vite.config.ts apply unchanged. Jest would need `moduleNameMapper` and an SFC transform to duplicate the same resolution rules.

Question

Where should per-test-file setup like auto-unmounting wrappers live, and what else belongs there?

Answer

In a `setupFiles: ['src/test/setup.ts']` entry, which runs before every test file: call `enableAutoUnmount(afterEach)` there so wrappers are destroyed automatically, alongside stubs for browser APIs you do not want repeated per spec. Set `css: false` too when component styles are irrelevant to assertions.

Revision Notes

Key Takeaways

  • 1. Vitest runs tests through the Vite pipeline, so aliases, plugins, and define values are shared with the app — configuration drift cannot make tests lie the way a mirrored Jest config can
  • 2. jsdom supplies the DOM that `mount()` requires; `ReferenceError: document is not defined` means the environment line is missing, not that the component is broken
  • 3. `globals: true` removes per-file imports but requires `"types": ["vitest/globals"]` in tsconfig, and a repository should pick globals or explicit imports and stick to it
  • 4. `vitest` watches locally while `vitest run` executes once and exits — CI must use the run form or the job hangs until timeout
  • 5. Coverage thresholds, `--changed` filtering, and JUnit reporters turn the exit code into a real quality gate instead of a formality

Interview Tips

  • • "Why Vitest instead of Jest?" — answer with one pipeline: aliases, `@vitejs/plugin-vue`, and CSS rules apply to tests automatically, so there is no second config to keep in sync; mention that transforms run through Vite's on-demand model
  • • "Your test says `document is not defined`. Walk me through the fix." — locate the missing `environment: 'jsdom'`, explain that plain Node has no DOM, and mention the per-file `// @vitest-environment node` override for logic-only specs
  • • "How do you keep tests fast as the suite grows?" — watch mode plus `vitest run --changed` and file/`-t` filters locally, sharded `vitest run` in CI; show you distinguish iteration speed from gate completeness
  • • "How do you enforce a coverage bar?" — `coverage.provider: 'v8'` with `thresholds.lines` in config so the run itself fails; state the number you have used and why a threshold without a reporter nobody reads is decoration

Cheat Sheet

Configuring Vitest

Install:

npm install -D vitest @vue/test-utils jsdom @vitest/coverage-v8

Config (in vite.config.ts):

/// <reference types="vitest" />
export default defineConfig({
  plugins: [vue()],
  resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
  test: {
    environment: 'jsdom',      // DOM for mount(); 'node' for DOM-free logic
    globals: true,             // then add "types": ["vitest/globals"] to tsconfig
    setupFiles: ['src/test/setup.ts'],
    include: ['src/**/__tests__/*.{spec,test}.ts'],
    css: false,
    coverage: { provider: 'v8', thresholds: { lines: 80 } },
  },
})

Command matrix:

Command Behavior Use
vitest watch, never exits local loop
vitest run once, exit code CI, hooks
vitest run <file> / -t "name" filter by file or test name fast iteration
vitest run --changed=HEAD~1 only tests affected by changes pre-push
vitest run --coverage V8 report, threshold failures quality gate

Isolation instead of duplication: vitest.config.ts + mergeConfig(viteConfig, defineConfig({ test: {...} })) — redeclare aliases by hand only when you must.

Continue Learning