Skip to content
daggerPublic

About

A Dagger toolchain for vitest - a test framework for Vite.js

Resources

Code of conduct

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

@dagger.io/vitest

Vitest reporter with OpenTelemetry support for auto-instrumentation with Dagger.

Installation

With Dagger

Requires Dagger v1.0.0-beta.15 or later.

dagger install github.com/dagger/vitest

# run every Vitest project's tests (no additional setup needed)
dagger check

Checks and selection

Projects, their test files and each file's tests are collections. dagger check lists one check per test file, addressed as vitest/projects/test-files/tests/test, and runs each file in its own vitest invocation:

# list what would run, one line per test file
dagger check -l --all

# every project's tests
dagger check --vitest

# the test check by name (the same thing, alongside other modules' test checks)
dagger check --check test

# one project
dagger check --vitest-project=apps/web

# some test files of one project
dagger check vitest/projects/test-files/tests/test \
  --vitest-project=apps/web \
  --vitest-test-file=src/a.test.ts --vitest-test-file=src/b.test.ts

# a single test, by the full name `vitest list` prints after the file path
dagger check --vitest-test='the sum > adds two numbers'

# the keys of each collection
dagger list vitest-projects -a
dagger list vitest-test-files -a --vitest-project=apps/web
dagger list vitest-tests -a --vitest-test-file=src/a.test.ts
Flag Selects
--vitest every check of this module
--check test checks named test, in every installed module
--vitest-project=PATH a project, by workspace-root-relative root (repeatable)
--vitest-projects every project
--vitest-test-file=PATH a test file, by project-relative path (repeatable)
--vitest-test-files every test file
--vitest-test=NAME a test, by full name (repeatable)
--vitest-tests every test

The key flags are named after the item types, VitestProject, VitestTestFile and VitestTest; dagger check --help lists the flags in effect.

When none of a file's tests is selected out, the whole file runs, so tests the listing did not report still run. Otherwise only the selected tests run, through Vitest's --testNamePattern: their full names are escaped and anchored, so a name holding regexp characters matches only itself.

Test files are passed to Vitest by path, and because Vitest matches file arguments as substrings (so src/a.test.ts would also run src/a.test.tsx or lib/src/a.test.ts), every other discovered test file with the same file name is removed from the container first. This holds whatever include/exclude the Vitest config or its inline projects set.

A project's test files can also be run together, in one vitest invocation, through testFiles(ws) — see below. With nothing selected out that is a plain vitest, so Vitest's own config decides what runs; dagger check does not take that path, so a test file that only the config's include adds is not checked.

If the Vitest config defines projects you don't want to run in a container, such as browser or e2e projects, name the ones to run with Vitest's --project flag:

[modules.vitest.settings]
flags = ["--project", "unit*"]

Running from a subdirectory

Discovery is anchored at the directory you run Dagger from, so cd selects projects without any flag:

  • at a project root, that project and the projects below it;
  • inside a project's subdirectory, that project, plus any project below the directory;
  • in a directory that belongs to no project, the projects below it.
# a monorepo holding apps/web and apps/admin
cd apps/web/src && dagger check     # runs apps/web only
cd apps && dagger list vitest-projects -a   # -> apps/admin, apps/web

Project keys are workspace-root-relative whatever the directory, . for a project at the root.

Discovery

A project is any directory holding a vitest.config.* or vite.config.* file, found with Workspace.findRoots (node_modules excluded). A project inside another one is a project of its own. Note that a whole run of the outer project is a plain vitest there, which also picks up the inner project's files unless the outer config excludes them.

Test files are keyed by their path relative to the project root. They are found by one ripgrep search per project with Vitest's default include, **/*.{test,spec}.?(c|m)[jt]s?(x), pruning node_modules, .git and the subtrees of nested projects. Listing test files runs no container. Static discovery does not read your Vitest config, so:

  • A custom include or exclude in the config is not seen. Files your config adds still run whenever the whole project runs (no test file selected out), but cannot be selected on their own; files your config excludes are listed and, when selected alone, make Vitest report that it found no test files.
  • A project where no file matches the default pattern has no test files to check, so dagger check does not run it. Call the project's test function from the API to run it.
  • Empty files are not listed.

Tests are keyed by the full name vitest list prints after the file path, suites included, e.g. the sum > adds two numbers. Unlike test files these come from Vitest itself — one vitest list <file> run per file, which needs the project installed — because names can be computed and test.each generates them. Nothing lists them until you ask: dagger check with no test selected never runs vitest list. A config defining Vitest projects lists each file once per project that includes it, under one set of keys.

test.skip and test.todo are left out of vitest list, so they are not keys and cannot be selected on their own. They are reported as skipped when their file runs.

vitest list arrives in Vitest 2.1. On an older Vitest it is read as a file filter, so a project has no tests to select and list(ws) reports nothing; select test files instead.

Installing dependencies

Each project is installed from its install root: the nearest enclosing workspace root (a directory with pnpm-workspace.yaml, or a package.json with "workspaces"), else the nearest directory with a lockfile, else the nearest package.json. That directory is mounted, dependencies are installed there, and Vitest runs with the project directory as its working directory, so workspace: and catalog: dependencies resolve.

  • The package manager comes from the install root's package.json "packageManager" field, else its lockfile (pnpm-lock.yaml or pnpm-workspace.yaml, yarn.lock, bun.lock/bun.lockb, otherwise npm), unless the packageManager setting names one. pnpm and yarn run through corepack, which is installed when the image lacks it (as node:25 images do) and honours the field's version.
  • The install sees only the files it reads (every package.json, lockfiles, pnpm-workspace.yaml, .npmrc, .yarnrc*, .yarn/{releases,plugins,patches}, patches/, the directories of file:/link:/portal: dependencies and injected workspace packages, which the install copies, and the files packages name as bin, so their commands get linked); the rest of the source (minus .gitignored files) is added afterwards, so editing source does not re-run the install. npm, pnpm, yarn and bun caches and COREPACK_HOME are on cache volumes (the pnpm store is passed with --store-dir, which pnpm 12 requires). A postinstall that needs other source files fails at this step; pass installFlags = ["--ignore-scripts"] if the tests don't need it.
  • Browsers: Puppeteer's Chrome download on install is left on (Vitest's browser mode can use it) and its directory, ~/.cache/puppeteer, is on a cache volume, so it happens once. Playwright downloads nothing on install; for browser-mode tests with the Playwright provider, use a base image that ships its browsers (e.g. baseImageAddress = "mcr.microsoft.com/playwright:<version>") or install them in your build script; ~/.cache/ms-playwright is on a cache volume too, so that download also happens once. Cypress's binary download and git hook installers (husky, simple-git-hooks) are switched off.
  • Vitest runs from the project's own node_modules/.bin/vitest (looked up from the project to the install root, or through yarn under Plug'n'Play). A project with a package.json but no vitest installed fails with a message saying so; only a project with no package.json at all runs npx vitest.
  • The OpenTelemetry reporter is loaded with NODE_OPTIONS=--import; nothing is added to your dependencies. It loads what its exporter needs up front, so tests that delete globals such as ReadableStream or fetch don't break the export.

A failure names the project and the step, with the end of its output, e.g. Vitest project apps/web: install failed (pnpm install --store-dir /root/.pnpm-store, exit 1): ... or Vitest project apps/web: vitest failed (exit 1): ....

Settings

Settings live in the workspace dagger.toml, or can be set with dagger settings vitest <name> <value> and removed with dagger settings -u vitest <name>:

[modules.vitest.settings]
# The package manager: npm, pnpm, yarn or bun. Default: "" (detect).
packageManager = "pnpm"
# Flags appended to the install command. Default: [].
installFlags = ["--ignore-scripts"]
# Environment variables for the install and the tests, as KEY=VALUE. Default: [].
environment = ["TZ=UTC", "NODE_ENV=test"]
# Run the package manager's build script before the tests. Default: false.
build = true
# Flags to pass to every vitest run, and to `list`. Default: [].
flags = ["--reporter=verbose"]
# The base image. Default: "node:25-alpine".
baseImageAddress = "node:25-alpine"
# Extra workspace files mounted at their paths (see below). Default: [].
includeExtraFiles = []

Using it from another module

projects(ws) returns the project collection. Collections expose keys, get(key:), subset(keys:) and batch; each project's testFiles(ws) is the test-file collection, and each test file's tests(ws) is its test collection. A check called through a dependency comes back as a Check that has not run yet, so wrap it:

let run(check: Check!): Void {
  if (check.pass == false) {
    raise check.error.message ?? "check failed"
  }
  null
}

testWeb(ws: Workspace!): Void @check {
  let projects = vitest.projects(ws)
  # every project, as a plain function
  projects.batch.test(ws)
  # one project's selected test files, in one vitest invocation
  let files = projects.get(key: "apps/web").testFiles(ws)
  files.subset(keys: ["src/a.test.ts"]).batch.test(ws)
  # a single test file
  let file = files.get(key: "src/a.test.ts")
  file.test(ws)
  # selected tests of that file, and a single test -- these are checks
  let tests = file.tests(ws)
  run(tests.subset(keys: ["the sum > adds two numbers"]).batch.test(ws))
  run(tests.get(key: "the sum > adds two numbers").test(ws))
}

vitest.project(ws, path) looks a project up by its root, relative to the workspace cwd. A project also has list(ws) (vitest list output, failing when Vitest reports collection errors) and source(ws). From the CLI:

dagger call vitest project --path=apps/web list

To run tests, use dagger check (in CI too): dagger call on a check function does not fail the command when the check fails.

Files outside the project

Only the project's install root is mounted into the test container. When a test reads a file that lives outside it — typically a fixture shared with code elsewhere in a monorepo — list workspace-root patterns in includeExtraFiles. They are mounted at their workspace-relative paths, so relative imports that escape the install root resolve as they do on disk:

[modules.vitest.settings]
includeExtraFiles = ["testdata/**", "schema/*.json"]

or dagger settings vitest includeExtraFiles '["testdata/**", "schema/*.json"]'.

Tests that call Dagger

Tests are run with access to the engine running them, so a test using a Dagger SDK reaches that engine rather than provisioning one of its own:

import { connection, dag } from "@dagger.io/dagger"

test("builds", async () => {
  await connection(async () => {
    expect(await dag.container().from("alpine").withExec(["echo", "hi"]).stdout()).toBe("hi\n")
  })
})

Each connection opens its own session rather than joining the one running Vitest, and the workspace's .git is mounted, so a test sees the same workspace it would on your machine. That is what lets a generated module client work:

import { dag } from "@dagger.io/my-module"

test("the module answers", async () => {
  await connection(async () => {
    expect(await dag.myModule().greeting("vitest")).toBe("Hello, vitest!")
  })
})

A client serves its module by workspace address before its first query, which needs a workspace, which is found by walking up to a .git directory. Joining Vitest's own session instead would resolve that address against this module and fail.

Such tests are usually slower than the 5s Vitest allows by default, so raise testTimeout in your Vitest config.

As a library

If you prefer to directly install the vitest library, run:

npm install --save-dev @dagger.io/vitest

Then set the import in your NODE_OPTIONS when executing your tests:

NODE_OPTIONS="$NODE_OPTIONS --import @dagger.io/vitest/register" npx vitest run

That's it! The reporter will automatically create OpenTelemetry spans for:

  • Test files (modules)
  • Test suites (describe blocks)
  • Individual tests (it/test blocks)

Span Attributes

Test spans include dagger.io/ui.boundary plus OpenTelemetry test semantic convention attributes: test.case.name, test.case.result.status, and test.suite.name.

Suite spans include dagger.io/ui.boundary, test.suite.name, and test.suite.run.status.

Console output captured by Vitest is also emitted as OpenTelemetry log records associated with the test span, using stdio.stream to distinguish stdout and stderr.

Span Hierarchy

test-file.ts (module span)
  └─ describe block (suite span)
      ├─ test 1 (test span)
        ├─ SELECT * FROM users (inside test span)
        └─ Container.withExec(...) (inside test span)
      └─ test 2 (test span)

License

Apache-2.0

About

A Dagger toolchain for vitest - a test framework for Vite.js

Resources

Code of conduct

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages