Testing with vitest¶
ts_test compiles TypeScript test files and runs them with vitest inside the
Bazel sandbox. The full attribute table is in the
ts_test reference.
Setup¶
# BUILD.bazel
load("@rules_typescript//ts:defs.bzl", "ts_compile", "ts_test")
ts_compile(
name = "math",
srcs = ["math.ts"],
visibility = ["//visibility:private"],
)
ts_test(
name = "math_test",
srcs = ["math.test.ts"],
deps = [":math", "@npm//:vitest"],
)
No node_modules target is needed. ts_test builds a per-target
node_modules tree from every dep that provides NpmPackageInfo, plus their
transitive npm deps. A ts_compile dep contributes none of its own npm
packages, so deps lists every npm package the run needs — imported by the
tests and by the production code under test. bazel run //:gazelle writes that
list.
Test sources are checked for undeclared imports like any other ts_compile
sources, so an import that only some dep's own deps provide fails the build with
the label to add. See
Deps have to be direct.
The tree places every resolution the closure made, keyed apart wherever one name resolved more than once, so deps that disagree about a package version or peer set each get what they resolved. See the layout.
Pass node_modules explicitly when deps is a select() (a macro cannot
iterate one) or when the tree you need is not the one the deps describe:
load("@rules_typescript//npm:defs.bzl", "node_modules")
node_modules(
name = "node_modules",
deps = ["@npm//:vitest", "@npm//:happy-dom"],
)
ts_test(
name = "math_test",
srcs = ["math.test.ts"],
deps = [":math", "@npm//:vitest"],
node_modules = ":node_modules",
)
Controlling the Test Environment¶
A vitest config is always generated and always passed with --config, so vitest
never picks up a stray config from the runfiles tree. Everything you set merges
into it; see
the generated vitest config
for the precedence rules.
DOM Tests and Polyfills¶
ts_test(
name = "component_test",
srcs = ["Button.test.tsx"],
deps = [
":button",
"@npm//:react",
"@npm//:happy-dom",
"@npm//:testing-library_react",
"@npm//:vitest",
],
environment = "happy-dom",
setup_files = ["setupTests.ts"],
)
environment takes any value vitest accepts — node, jsdom, happy-dom,
edge-runtime, or a custom environment package — and the matching package has to
be in deps. Scoped npm names take their label form: @testing-library/react
is @npm//:testing-library_react. setup_files entries run before every test
file, which is where matchMedia, ResizeObserver and PointerEvent belong;
TypeScript entries are compiled with the same deps as the tests.
global_setup is the same mechanism for test.globalSetup, which runs once
around the whole run.
A DOM environment needs no sandbox flags. The generated config sets
resolve.preserveSymlinks, without which vitest's web transform resolves every
runfiles symlink to its target and walks out of the sandbox
(Failed to load url … Does the file exist?).
An Existing vitest Config¶
ts_test(
name = "component_test",
srcs = ["Button.test.tsx"],
deps = [":button", "@npm//:react", "@npm//:vitest"],
config = "vitest.config.ts",
data = ["test/fixtures.json", "test/msw-handlers.ts"],
)
Anything the config imports relatively belongs in data; it is not a build
input otherwise. A config that default-exports an array is read as a list of
vitest projects, and each project in it gets the Bazel and attribute layers too.
That array becomes test.projects, which needs vitest 3.2 or later; see
A config file. Every other config shape
runs on any vitest 3 or 4.
Small adjustments need no file — config also takes a dict:
Other Attributes¶
ts_test(
name = "math_test",
srcs = ["math.test.ts"],
deps = [":math", "@npm//:vitest"],
globals = True, # global describe/it/expect
reporters = ["default", "junit"],
coverage_thresholds = {"lines": "80"},
)
The Merged Config¶
That writes out the merged config the runner passed to vitest.
CSS Modules¶
A *.module.css anywhere in the dep closure adds a plugin to the Bazel layer
that answers the import with the export map css_module wrote beside the
stylesheet, so a test sees the class name a bundler emits:
An assertion on a rendered class attribute reads the same map:
A *.module.css with no css_module target behind it has no map and no
.d.ts; the import falls back to a proxy returning the property name, so it
loads and the test runs.
Coverage¶
Works on every ts_test with nothing to opt into, provided
@vitest/coverage-v8 is in the node_modules tree. coverage = True
additionally instruments plain bazel test runs.
coverage_thresholds reaches test.coverage.thresholds in the generated
config and applies only when coverage runs. A run that misses a threshold fails,
after the assertions themselves have passed:
Which files are reported is --instrumentation_filter's answer; Bazel derives a
default from the targets on the command line, so a library in another package is
absent until a wider filter names it. coverage_provider picks between "v8"
(vitest's default) and "istanbul", and a test whose pool runs in a second
runtime needs "istanbul". See
ts_test § Coverage for both.
Cloudflare Workers¶
A Worker's tests can run inside workerd, so SELF.fetch() dispatches to the
real fetch handler over the real runtime. @cloudflare/vitest-pool-workers
supplies the pool; //tests/workers is the worked example:
ts_compile(
name = "worker",
srcs = ["src/index.ts"],
lib = [
"esnext",
"webworker",
],
)
ts_test(
name = "worker_test",
size = "medium",
srcs = ["src/worker.test.ts"],
config = "vitest.workers.config.mjs",
coverage_provider = "istanbul",
data = ["wrangler.jsonc"],
lib = [
"esnext",
"webworker",
],
types = ["@cloudflare/vitest-pool-workers/types"],
deps = [
":worker",
"@npm_workers//:cloudflare_vitest-pool-workers",
"@npm_workers//:vitest",
"@npm_workers//:vitest_coverage-istanbul",
],
)
import { SELF } from 'cloudflare:test';
import { describe, expect, it } from 'vitest';
describe('worker', () => {
it('answers /health', async () => {
const res = await SELF.fetch('https://example.com/health');
expect(res.status).toBe(200);
});
});
lib names webworker on the worker target and on the test target: the
Request/Response globals a Worker is written against are in no set target
implies.
The vitest Config¶
import { cloudflareTest } from '@cloudflare/vitest-pool-workers';
export default {
resolve: { preserveSymlinks: false },
plugins: [
cloudflareTest({
remoteBindings: false,
wrangler: { configPath: 'wrangler.jsonc' },
}),
],
};
cloudflareTest() belongs in plugins, not in test.pool. Two of the
package's exports are candidates: cloudflarePool() is a pool initializer that
boots workerd and nothing else; cloudflareTest() is a Vite plugin that installs
that pool and owns the cloudflare:test specifier — resolveId maps it to a
virtual id, load returns the runtime's bytes. The pool forwards
cloudflare:test to Vite and externalises every other cloudflare:* specifier
to workerd, so with no plugin registered nothing resolves it and vitest falls
back to Node package resolution, which fails.
resolve.preserveSymlinks: false is the other line to get right. ts_test's
Bazel layer turns it on for the sandbox reason above. The pool resolves modules
for workerd through a second path, where a lexical path is a second module
identity for the same file, and the user layer wins. Leaving it out fails as
TypeError: Cannot read properties of undefined (reading 'config') from inside
the pool runner.
The wrangler configPath is relative because ts_test roots Vite at the
package, which is where data = ["wrangler.jsonc"] stages the file and where the
compiled worker its main names is staged too.
coverage_provider and types¶
coverage_provider = "istanbul". v8 coverage is counters read back out of
Node's inspector, and workerd has none; istanbul instruments before the code
crosses into the runtime, so bazel coverage reports real per-line data for code
running inside workerd.
types = ["@cloudflare/vitest-pool-workers/types"] is an exports subpath whose
only condition is types, where the pool puts the ambient declaration for
cloudflare:test. Nothing imports it, and a tsconfig types entry cannot reach
it under a ruleset with no node_modules, so it is resolved from the package
manifest into the program's files.
Deploy Dry Run¶
ts_worker_dry_run_test checks that the Worker still deploys: a
wrangler deploy --dry-run with no credentials and no network, in the same
package:
node_modules(
name = "wrangler_node_modules",
deps = ["@npm_workers//:wrangler"],
)
ts_worker_dry_run_test(
name = "deploy_dry_run_test",
size = "medium",
config = "wrangler.jsonc",
node_modules = ":wrangler_node_modules",
deps = [":worker"],
)
wrangler needs a tree of its own: the pool's node_modules is built from the
test's deps, and wrangler is not one of them. Full reference:
ts_worker_dry_run.
This is the rule that belongs in CI. Uploading the Worker is a separate bazel
run target that never fires from bazel build or bazel test:
ts_worker_deploy.
Sharding¶
ts_test distributes test files across shards using TEST_SHARD_INDEX and
TEST_TOTAL_SHARDS. Set shard_count on the target and pass
--noincompatible_check_sharding_support: the runner never touches
TEST_SHARD_STATUS_FILE, which is how Bazel expects a test runner to advertise
sharding support, so without that flag a sharded run fails before any test
starts.
Snapshots¶
toMatchSnapshot() works, and the .snap files stay where a plain vitest run
keeps them: <package>/__snapshots__/<source>.snap, beside your .ts and not in
bazel-out. Adopting ts_test renames nothing.
Reading them takes the snapshots attr, which is what puts the files inside the
sandbox.
ts_test(
name = "widget_test",
srcs = ["widget.test.ts"],
snapshots = glob(["__snapshots__/*.snap"]),
deps = [":widget", "@npm//:vitest"],
)
Writing: run the updater that every ts_test declares next to itself.
It writes into your checkout. Commit the result. --sandbox_writable_path is no
longer part of this, and neither is a second hand-written target.
ts_test runs vitest in read-only snapshot mode, so a snapshot the test cannot
read is a failure — where an unlisted one would otherwise look like a new
snapshot, get written into the sandbox, and let the test pass on what it had just
written.
Watch Mode¶
Use ibazel to re-run tests on every change:
go install github.com/bazelbuild/bazel-watcher/cmd/ibazel@latest
ibazel test //path/to:my_test
ibazel test //...
ibazel watches the build graph, so only affected targets are rebuilt and re-tested.
To see what the launcher resolved — node binary, vitest entry, node_modules
tree, shard split:
Build Feedback¶
Add test --show_result=20 to .bazelrc to make it permanent.