Configuring Vitest in a DevContainer

Vitest gives fast unit-test feedback, and running it inside the devcontainer keeps tests on the same toolchain as the build. This page configures Vitest in a container — resolving the same TypeScript path aliases, watching efficiently, and reusing the cached dependency store.

The reason this matters is that a test runner sits directly on top of your module-resolution and file-system assumptions, and those are exactly the two things that shift when you move from a host machine into a container. On the host, a test might pass because your editor's language server and Node happen to agree on where @app/utils lives; inside the container, Vitest resolves that same import through its own bundler pipeline, and if it has not been told about your tsconfig.json paths it will simply fail to find the module. Configuring Vitest deliberately in the container removes that class of "works on my machine" surprise by making the test runner obey the same alias map, the same pinned Node version, and the same dependency store that the production build already uses.

Reach for this setup the moment your project has more than a handful of test files, uses TypeScript path aliases, or is developed by a team where reproducibility matters. The mental model to hold is a stack: Vitest is the runner, the vite-tsconfig-paths plugin is the translation layer that teaches it your alias map, watch mode is the feedback loop, and the cached package-manager store is what keeps installs from dominating the loop. Get each layer right once in the devcontainer configuration, and every contributor who opens the workspace inherits identical test behaviour without re-deriving it.

Prerequisites

You need a Node/TypeScript devcontainer with Vitest.

  • A Node Feature pinned and the package manager set.
  • Vitest as a dev dependency.
  • tsconfig path aliases mirrored in the Vitest config.

These prerequisites are less about installing packages and more about agreement between layers. The Node Feature should pin a specific version rather than a floating major, because Vitest runs on the same Node runtime that executes your source, and a version drift between the container and CI is enough to make an async timing test flake or a newer syntax feature fail to parse. Setting the package manager explicitly — via Corepack pinning pnpm or npm — matters for the same reason the store cache does later: the runner should never be the thing that decides which lockfile format or hoisting layout your tests see.

The detail people most often get wrong is assuming that because @app/* imports resolve in the editor and in tsc, they will resolve in Vitest too. They will not. TypeScript's paths are a compile-time hint that the type-checker honours, but Vitest resolves modules at runtime through Vite's resolver, which knows nothing about tsconfig.json until you wire it in. Treat "aliases mirrored in the Vitest config" as a hard prerequisite, not a nicety — it is the single most common reason a freshly containerised test suite goes red on the first run.

Vitest prerequisitesYou need Vitest pinned, aliases resolved, watch working, and passing tests.Vitest deppinnedAlias resolvematch tsconfigWatchefficient in containerVerifytests pass

Step-by-Step Implementation

  1. Install Vitest and resolve tsconfig path aliases.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({ plugins: [tsconfigPaths()] });

The tsconfigPaths() plugin reads your tsconfig.json, extracts the compilerOptions.paths map, and registers a matching resolver inside Vite's module pipeline so that an import like @app/services/user is rewritten to the real on-disk path before Vitest tries to load it. It is written as a plugin rather than a manual resolve.alias block because the plugin stays in sync with tsconfig.json automatically — add a new alias to the compiler options and the tests pick it up on the next run, with no second place to maintain. The failure mode it prevents is the silent divergence where someone edits tsconfig.json paths, the type-checker and build both update, but hand-written Vitest aliases quietly point at a stale directory and tests either fail to resolve or, worse, import the wrong file.

  1. Run tests inside the container.
corepack pnpm vitest run

Running through corepack pnpm rather than a globally installed vitest binary guarantees you execute the exact Vitest version recorded in the lockfile, resolved by the package manager the project pinned. The run subcommand is deliberate: it executes the suite once and exits with a status code, which is the shape CI needs and the shape you want when you are confirming a clean baseline rather than iterating. Doing this inside the container — not on the host — is the whole point, because it forces the tests through the container's Node version, its installed native modules, and its file layout, so a green result here means the same green result in the pipeline. The failure mode avoided is the classic one where a developer runs tests on the host with a different Node or a stray global dependency, sees them pass, and only discovers the divergence when CI rejects the branch.

  1. Use watch mode for feedback (polling if file events are flaky on a mount).
pnpm vitest --watch

Watch mode keeps a resident process that re-runs only the tests affected by a change, which is what turns Vitest into a tight edit-save-see loop rather than a batch job. The caveat noted here — polling if file events are flaky on a mount — is the container-specific wrinkle: when your source lives on a bind mount from the host, the kernel's native inotify events do not always propagate reliably across the virtualization boundary, so a save on the host may never wake the watcher. Switching Vitest to poll the filesystem (through its Vite server.watch options, or the CHOKIDAR_USEPOLLING environment variable) trades a small amount of steady CPU for changes that are actually detected. The failure this prevents is the maddening one where you edit a file, watch mode reports nothing, and you wrongly conclude your test is passing when in fact it never re-ran.

  1. Verify aliases resolve and tests pass.
pnpm vitest run   # @app/* imports resolve like the build

This final run is a verification gate, not a redundant repeat of step two: you are confirming two independent things at once. First, that the @app/* imports actually resolve through the plugin — a suite that imports an aliased module and passes proves the resolver is wired correctly, whereas a Cannot find module '@app/...' error tells you the plugin is missing or tsconfig.json moved. Second, that the tests themselves pass on the container's toolchain. Running it as a one-shot after any change to vitest.config.ts, tsconfig.json, or the Node Feature version catches configuration regressions immediately, before they reach CI. The comment on the line is the assertion you are testing: aliases should resolve identically to the way the build resolves them, so a test never exercises a different module graph than the shipped code.

Vitest setupVitest with the tsconfig-paths plugin resolves aliases and gives fast in-container feedback.Vitest (dev dep)pinned test runnertsconfigPaths pluginaliases resolveWatch modefast feedbackCached storequick installs

Common Pitfalls

Vitest issues are unresolved aliases or flaky watch on a bind mount.

A less obvious source of trouble sits with the cached dependency store that keeps installs fast. When that store lives on a named volume, its files are owned by whichever UID first wrote them, and if the container later runs as a different user — say your image switches from root during build to a non-root node user at runtime — Vitest's install step can hit permission errors reading or extracting packages before a single test even starts. The fix is to make the store's ownership match the runtime user, either by chowning the volume mount to the non-root UID in a post-create step or by ensuring the same user writes it throughout. Treat a sudden EACCES on a .pnpm or npm cache path as an ownership problem, not a corrupt store, and resist the urge to rm -rf the volume, which only masks the mismatch.

The topic-specific pitfall worth expanding is the "tests differ from CI" row: it almost always traces back to something unpinned. Vitest itself moves quickly, and a caret range in package.json means a fresh install months apart can pull a newer minor with changed defaults — different snapshot serialization, a stricter environment, or altered timer behaviour. Pin Vitest to an exact version and pin the Node runtime through the devcontainer Feature so both the container and the CI runner instantiate the same combination. If a test passes locally but fails in the pipeline, compare the resolved Vitest and Node versions first; that single check resolves the majority of "green here, red there" reports before you start bisecting the test itself.

Vitest triageA triage path from failing aliases or watch to fast in-container tests.Do @app/* imports resolve in tests?NOAdd the tsconfig-paths pluginDoes watch pick up changes?YESStore cached for fast installsFast in-container tests

SymptomRoot CauseRemediation
@app/* fails only in testsVitest doesn't read tsconfig pathsAdd vite-tsconfig-paths plugin
Watch mode misses changesBind-mount file events flakyEnable polling in watch
Tests differ from CIUnpinned Vitest/runtimePin Vitest and the Node version
Slow installs before testsStore not cachedCache the pnpm/npm store on a volume

Conclusion

Run Vitest inside the container so tests use the same toolchain as the build, resolve TypeScript path aliases with the tsconfig-paths plugin so imports match, and enable polling if a bind mount makes file events flaky. Fast, faithful test feedback in the environment developers actually use.

The strategic payoff is that your tests stop being a separate universe from your build. Once Vitest resolves modules through the same alias map, runs on the same pinned Node, and installs from the same cached store, the test suite becomes a faithful probe of the exact artifact you ship rather than an approximation of it. That is what makes a green run trustworthy: it is not just asserting that your logic is correct, it is implicitly asserting that the module graph, the runtime, and the dependency versions are the ones production will see. The tsconfig-paths plugin is the small piece that keeps this honest as the codebase grows, because new aliases flow into the tests automatically instead of being a maintenance task someone forgets.

This ties directly into the broader pin-and-cache and reproducibility themes that run through a well-built devcontainer. Pinning Vitest and the Node Feature is the same discipline as pinning any other tool: you are freezing the inputs so results are deterministic across machines and across time. Caching the store on a volume is the same trade the rest of the environment makes: pay the install cost once, amortise it over every run. Configured this way, the container is not just where the app builds; it is the single source of truth for how the app is tested, which is exactly the property you want when a failing test is supposed to mean something.

Configure and gainConfiguring aliases and watch gives fast tests that resolve like the build.Configuretsconfig-paths pluginWatch (polling)Pinned VitestSo testsResolve like buildGive fast feedbackMatch CI

FAQ

Why do my Vitest imports fail on path aliases? Because Vitest doesn't automatically read tsconfig.json paths. Add the vite-tsconfig-paths plugin to vitest.config.ts so the test runner resolves @app/* the same way the type-checker and build do, and the imports work in tests. The underlying reason is that paths is a TypeScript compile-time construct that tsc and your editor honour, but Vitest loads modules at runtime through Vite's resolver, which has no knowledge of it until the plugin registers the mapping. If you would rather not add the plugin, you can hand-maintain an equivalent resolve.alias block in the config, but that means updating two files every time an alias changes, and the drift between them is itself a common cause of failing tests.

Why does watch mode miss my changes in a container? Native file-system events can be unreliable across a bind mount. Enable polling in Vitest's watch configuration so it detects changes by polling the filesystem, which is more reliable in a mounted workspace at the cost of slightly more CPU. The reliability gap comes from inotify events not always crossing the boundary between the host filesystem and the container, especially on virtualized Docker backends, so the watcher simply never learns a file changed. Polling sidesteps the event system entirely by re-scanning modification times on an interval; keep the interval modest so the CPU cost stays negligible, and scope the watch to your source directories rather than the whole workspace so it does not churn over node_modules.

How do I keep Vitest results consistent with CI? Pin Vitest as a dev dependency and pin the Node version via the Feature, so the container and CI run identical versions. Run tests inside the container in both places (locally and via devcontainer exec in CI) for the same toolchain. The most reliable pattern is to have CI build or pull the very same devcontainer image the developers use, then invoke vitest run inside it, so there is only ever one definition of the test environment. When results still diverge, print the resolved Vitest and Node versions at the top of the CI log; a mismatch there explains most discrepancies faster than re-reading the failing assertion.

Should I run tests in a separate container from my dev container? For most projects, no — running Vitest in the same container your code is developed in is the entire point, because it guarantees the tests exercise the same toolchain, aliases, and dependencies. Spin up a dedicated test container only when you need isolation the dev container cannot provide, such as a pristine database service or a locked-down network, and even then keep the Node version and package-manager store aligned with the dev image so the runtime under test does not silently change.

How do I speed up a slow Vitest suite in a container? Start by confirming the package-manager store is cached on a volume so installs are not repaid on every run, then let Vitest's default worker pool parallelise across the container's available CPUs. If the container is CPU-constrained, cap the thread count so oversubscription does not slow things down, and prefer running only the affected tests in watch mode during development. Watch out for heavy global setup files, which run per worker and can dominate the wall-clock time of an otherwise fast suite.