ts_test¶
Compiles TypeScript test files with ts_compile's actions and runs them inside
the Bazel sandbox under a runner target: vitest by default, node's own runner,
or one of your own.
Usage¶
load("@rules_typescript//ts:defs.bzl", "ts_test")
ts_test(
name = "math_test",
srcs = ["math.test.ts"],
deps = [":math", "@npm//:vitest"],
)
srcs, deps, tsconfig, node_modules and emit are ts_compile's. The same
actions compile the test files -- the emit as ES modules under the vitest
runner (Runners) -- and the importer chain tsgo checked them
against (The node_modules Chain) is
what they run in: the chain's links and the store files the program reaches
sit in the runfiles at their own paths. runner names the target that runs
the compiled files.
Attributes¶
| Attribute | Type | Default | Description |
|---|---|---|---|
srcs |
label_list |
[] |
The test files, as ts_compile's srcs, with every other file of the package -- a .snap, a fixture -- and a file of another package the tests import by relative path, exported where it lives. The TypeScript ones are in the runfiles at their source paths too; see Files at Run Time |
test_srcs |
label_list |
[] |
Execution roots selected from srcs, before sharding. Empty or omitted uses all executable srcs. A nonempty selection must name files in srcs and select at least one executable input. Supporting files remain compiler and runtime inputs. |
package_scopes |
label_list |
[] |
Package.json metadata kept at its original compiler identity, with runtime placement derived by the shared ts_compile constructor. These are not execution roots. |
emit |
bool |
False |
Vitest runs TypeScript sources directly while tsgo validation remains enabled. Set True for JavaScript-only runners. See source-only programs. |
deps |
label_list |
[] |
ts_compile and @npm// targets the tests import. A dep under the test's tsconfig is checked from its sources (The Test's Program); a ts_compile dep's data srcs are in the runfiles beside its .js, and a dep in the test's package has its sources there too |
tsconfig |
label |
None |
The test program's tsconfig, as on ts_compile: the package's own tsconfig.json or a ts_config target, the file the deps that join the program share; under vitest its paths resolve at run time. See The Test's Program and The Test's tsconfig |
node_modules |
label |
None |
The nearest lockfile importer's node_modules target, as on ts_compile: the chain the tests resolve their npm deps along, at run time too. See Files at Run Time |
runner |
label |
//ts/runners:vitest |
The target that runs the compiled tests: //ts/runners:vitest, //ts/runners:node_test or any target providing TsTestRunnerInfo. See Runners |
env |
string_dict |
{} |
Extra environment variables for the runner |
args |
string_list |
[] |
The runner's command-line flags: node's under the node:test runner, vitest's under the vitest runner; bazel test --test_arg appends to them. See The node:test Runner |
config |
label |
None |
The vitest config file (.ts/.mts/.cts/.js/.mjs/.cjs), merged over the generated config's Bazel layer. Every vitest setting is the file's. See A Config File |
config_srcs |
label_list |
[] |
The modules config imports relatively, and theirs, each at its own path in the runfiles; Gazelle writes it from the config's listing. See A Config File |
config_node_modules |
label_list |
[] |
NodeModulesInfo importers or NpmLinkInfo workspace-member links for the config's npm packages. Their links and store closures are runfiles at their owner paths; Gazelle derives this list without changing data, deps or the test's node_modules |
data |
label_list |
[] |
Extra runfiles: fixtures, anything read at run time. TsInfo targets retain their declared runtime closure and npm bindings without joining the compiler program or test entry roots |
wrangler_config |
label |
None |
The wrangler config a Workers-pool config names through wrangler.configPath. See A Workers Pool |
workers_pool |
label |
None |
The NodeModulesInfo importer or NpmLinkInfo member link declaring the pool imported by the config or its helper. When set, stages the pool for runtime and selects its Wrangler for config preparation. Otherwise preparation uses the first pool on the test's importer chain, including member links in deps. Gazelle derives the explicit owner from the resolved import |
coverage_provider |
string |
"" |
test.coverage.provider: "v8" (vitest's default) or "istanbul". See Coverage |
size, timeout, tags, visibility |
Bazel's test attributes |
config, config_srcs, config_node_modules, workers_pool, coverage_provider and
wrangler_config are the vitest runner's and an analysis error under the
node:test runner.
Gazelle writes test_srcs when an automatically discovered test's closure adds
labels outside its original roots. Growing that helper closure does
not add standalone test entries or move tests between shards. Source identities
select their emitted files when emit = True; helpers remain available on every
shard. Declaration and data files in a selected label do not become entries.
A selected producer label selects every executable file it supplies; use its
individual output labels to select a subset.
An explicit node:test target without test_srcs still executes every executable
srcs entry, including filenames without .test or .spec. Vitest keeps its
existing test/spec selection. For a manual Vitest srcs list containing helpers,
set test_srcs to the tests before using sharding; automatic discovery of that
manual list's execution roots is outside this change.
The vitest runner rejects command-line --changed and --changed=<ref>:
Bazel runfiles have no Git source history, so Vitest can otherwise report
success without running tests. Let Bazel reuse unchanged test targets, or select
source paths with --test_arg=path/to/example.test.ts, including when
emit = True; name filters such as
--test_arg=-t --test_arg='case name' also work. This guard covers CLI flags,
not test.changed in a Vitest config.
Files at Run Time¶
A test's runfiles hold, at their source paths, the compiled program -- its own
.js and every dep's -- the data srcs of every dep, and the sources of its
own package: its srcs and the srcs of every dep in the same Bazel package,
.ts, .tsx, JavaScript and declarations. A test that reads its package's
tree finds it where the checkout has it:
import.meta.url -- and __dirname, in a CommonJS test -- is the runfiles
path on either runner (the vitest layer's module ids, The Generated vitest
Config; node's staged regular modules and
the runner's resolve hook), so ./index.ts
beside the compiled test is the same-package ts_compile's src/index.ts.
A dep's sources from another package are not in the tree: a file a test reads
across a package boundary is a data entry.
//tests/vitest/reads_own_source is the example.
Native Node tests use a declared build output for their runtime layout, including with --noenable_runfiles. First-party module paths, relative data reads and package scopes retain their admitted coordinates; aliases reach one internal authority for each selected npm store. A directory entry and an explicit child entry must select the same input object; conflicting mappings fail before Node starts. The view cannot add a missing node_modules through an opaque workspace alias. Declare it in that directory or publish individual Files. The native child's standard runfiles lookup uses this same view and Bazel's generated repository mapping, so loading a module through runfiles does not create a second instance. Package the launcher's runfiles with its generated config and complete runtime directory, retaining relative links; a manifest can relocate that group. See runtime input lifetime.
A file a test imports across a package boundary is a src: exports_files
where the file lives, the label in srcs under # keep, which a Gazelle run
keeps (Import Resolution). It
compiles into the test's output tree in the shared source
layout: the test package and the src
share one common logical root, and each keeps its relative path below it. A
relative import from a compiled sibling reaches the src at that path, and that
path is the module's id under vitest (The Generated vitest
Config); its .ts is in the runfiles as every
src's is. A CommonJS-shaped program under node:test is tsgo's one emit and
keeps one root (The Module Format).
//tests/vitest/cross_package is the example.
The shipped runners start an explicitly selected foreign test at the same source-relative runfiles path as an imported helper. Runtime package-scope admission and scope staging use that placement too, so a root manifest supplied by a dependency governs both local and foreign test modules. A custom JavaScript-only runner that launches compiler-output Files directly must also satisfy package-scope admission at those output paths.
A package.json declared in package_scopes supplies a runtime projection:
its declared module targets follow their published filenames. An explicit
JSON module in srcs retains its authored contents; when used as a runtime
scope, it must still agree with the declared runtime module mappings. The npm
publication manifest serves dependent compiler overlays and the npm store.
See Compiler inputs and emitted
layout for these roles.
The package's own name -- a self-reference -- resolves through the nearest
runtime package.json's name and exports. The node:test
runner maps source targets to their compiled siblings.
//tests/vitest/own_manifest and //tests/node_test/self_reference are the
examples.
vitest runs from the config's package in the runfiles, the test's own with no
config -- the directory pnpm run test runs from -- so process.cwd() names it
and join(process.cwd(), "fixtures/x.txt") reads the package's file. What
vitest walks to collect the run is a separate tree of selected test entries at
their source paths (The Generated vitest
Config). The
config file and its config_srcs are regular files at their own paths in the
runfiles, and the generated config vitest is handed is in the test's package,
all written by the launcher before the run: a runfiles entry is a symlink, and
Vite bundles a config from its realpath, so through the symlink __dirname
and the walk up for a bare import would start in bazel-out. __dirname,
import.meta.dirname and __filename in the config and in a config_srcs
module are the package's paths, as under plain vitest, and a bare import
walks up to the importer's node_modules.
//tests/vitest/cwd_and_dirname is the example.
The compiled program is what runs -- under vitest as ES modules, whatever the
tsconfig's module (Runners). A setupFiles entry naming a
source runs the compiled sibling staged beside it (Setup Files),
and a
.ts specifier the emit keeps -- relative, or a subpath into a workspace
member -- resolves to the compiled file (.ts Specifiers;
under node:test, the runner's hook:
The node:test Runner).
Both shipped runners preserve each source owner's declared npm bindings at the admitted module and package-scope paths, as binaries do. This includes relocated source TypeScript and an ES twin selected in place of a CommonJS output. Conflicting bindings at one directory or occupied projection destinations fail before test execution; erased imports do not remove declared bindings.
Other bare specifiers resolve through the importer chain by the runner's own route. Under vitest the resolver walks up from the test's runfiles path
through each importer's node_modules to the lockfile's root importer's;
where that walk meets none before the workspace's root, the launcher links the
chain's root in there. Under node:test the hook resolves it from the chain's
directories the launcher names in NODE_PATH, nearest first
(The node:test Runner). A package's own imports
resolve from its realpath in the store to the edges beside its tree, on both
runners (//tests/npm/multi_version:own_edge_node_test,
:own_edge_vitest_test), and an import of a name it does not declare to the
hoist link the runfiles carry for a name the closure holds
(//tests/npm/features/hoist).
The Test's Program¶
The test's program is its srcs and the sources of every dep whose tsconfig
is the test's. The package's tsconfig.json names the compile and the test
alike -- Gazelle writes it on both -- and the checkout's tsc -p checks their
files as one program, so TsgoCheck on the test reads the compile's .ts,
.tsx, JavaScript and declaration srcs beside the test files
(TsInfo.sources) and never the compile's emitted .d.ts, by any path: the
declarations a program reads are the TsInfo.owners records' of its closure,
one record per target, less the records of the deps held as sources, so a dep
under another tsconfig that itself depends on the compile -- a worker between
the package's test and its compile -- brings its own declarations and not the
compile's, and its imports of the compile resolve to the compile's sources in
the test's program. The check waits for no TsgoDeclare of the compile, and a
type error in the package's sources fails the test's check as it fails the
compile's. The edges stay the compile's to declare: an import from a test file
into one of those sources lands on a file the compile's label owns, in deps
already, and an import from one of them is not judged
(Deps have to be direct). A dep under
another tsconfig -- a tsconfig.node.json test over the package's browser
compile, say -- reaches the test through its declarations, as it reaches any
ts_compile. //tests/package_program pins the three.
The test's program emits no declarations: nothing reads a test's .d.ts, so
ts_test declares none, registers no TsgoDeclare and offers no
declarations output group, under either value of --//ts:declarations, and
under oxc its check sets no isolatedDeclarations, the rule of an emit it
does not make. TsgoCheck is the program's one tsgo action, and TsEmit runs
oxc over its source roots using the shared source layout.
Test entries use the producer's exact runtime Files, including staged TypeScript
when a source-mode test consumes a relocated dependency. Snapshot paths retain
their original source locations.
The Test's tsconfig¶
tsconfig is the test program's, as on
ts_compile: the package's
own tsconfig.json, one program with the compile (above),
or a file of the test's own, under which a dep arrives as declarations and a
lib, a types entry or a paths alias those need is in the test program
only when the test's tsconfig has it too. Three entries are the ones a test
usually needs:
lib. A worker test needs["esnext", "webworker"];webworkeris in no settargetimplies.- A
typesentry naming a package or a subpath, such as@cloudflare/vitest-pool-workers/typesfor a pool'scloudflare:testmodule, orvitest/globals. tsgo resolves it through the chainnode_modulesnames, so the package is indeps, which stages the importer's link the walk up meets; see atypesentry that names a package. - A
typesentry naming a declaration file,../worker-configuration.d.tsfor the declaration a wrangler project keeps beside its worker. The target whosesrcshold the file, or whoseoutswrite it, is indeps, and tsaction rebases the entry to the staged file; see atypesentry that names a declaration file.
// workers/proxy/test/tsconfig.json
{
"extends": "../tsconfig.json",
"compilerOptions": { "types": ["../worker-configuration.d.ts"] }
}
ts_test(
name = "handler_test",
srcs = ["handler.test.ts"],
tsconfig = "tsconfig.json",
deps = ["//workers/proxy:worker_types", "//workers/proxy/src", "@npm//:vitest"],
)
A paths alias resolves at run time as it did at compile time. oxc leaves an
import specifier alone, so a compiled test still names the alias, and the Bazel
layer of the generated config resolves it to the module the value names in the
runfiles, the compiled sibling for a .ts value (A paths
Alias). A value import through an alias into another package
needs that package's target in deps, which is what puts the module in the
runfiles. Under node:test an alias is type-checking only
(The node:test Runner).
Runners¶
A runner is a target providing TsTestRunnerInfo
(providers), the way a toolchain is: ts_test
compiles the tests against the importer chain, and the runner's launch turns
them into the launcher's config and the runfiles of one test. Two ship,
//ts/runners:vitest, the default, and //ts/runners:node_test
(@rules_typescript//ts/runners:node_test from a consumer); a third is a rule
in its own ruleset returning the provider. A runner names the npm packages it
needs in the closure -- vitest for the vitest runner -- and a test whose
deps provide none fails at analysis:
ts_test @@//path/to:my_test: @@//ts/runners:vitest runs the tests and needs
vitest in the npm closure, which no dep provides.
Add the hub label of each to deps.
runner is the owner's attribute. tsgo's listing records no edge for an import
of an ambient module -- node:test resolves to no file -- so nothing Gazelle
reads says which runner a test file was written for; Gazelle never writes
runner, and a hand-written value survives every run without # keep.
The two runners run different module formats, and a runner says which with
TsTestRunnerInfo.es_modules. vitest imports every file through vite's ESM
transform, so the program a vitest test runs is ES modules whatever its
tsconfig's module: the test's own srcs are emitted as such, and a
ts_compile dep whose program tsgo emits -- module: "commonjs", or
"nodenext" in a package with no type -- is staged as the ES twins it
emits beside its .js
(The Module Format). node:test has no
transform, so it runs the package's format as tsc emits it
(The node:test Runner). //tests/vitest/commonjs and
//tests/node_test/cjs pin the two over one module: commonjs shape.
An emitted custom callback using mode = "node_test" and the generated
test_files_list runs first-party modules as regular files at their logical
paths in the build-owned runtime view, regardless of resolve_hook. Native Node
resolution retains one internal authority per selected npm store. Callbacks using
mode = "node" use the same view while preserving the caller's working
directory. Their compiler-output runfiles paths must have the declared scopes,
even when another test alias has the required scope. Other launcher modes or a
different test-file list also require compiler-output scope admission.
The vitest Runner¶
vitest is the one the first importer on the chain links, from the test's
package up, the JS runtime the toolchain's: the Node your .nvmrc names
(Node.js). Under bazel test the
runner sets CI=true so vitest writes no .snap (Snapshots);
env = {"CI": "false"} opts out.
The Generated vitest Config¶
A config is always generated and always passed with --config, so vitest never
auto-discovers a stray config out of the runfiles tree. It layers four sources,
lowest precedence first; every vitest setting the table does not name --
test.environment, test.globals, test.setupFiles, test.globalSetup,
test.reporters, test.coverage.thresholds -- is the config file's, as under
plain vitest:
| Layer | Contents | Workspace projects |
|---|---|---|
| 1. Bazel | cacheDir under TEST_TMPDIR, server.fs.allow naming the workspace's runfiles and bazel-bin's realpath, test.coverage.allowExternal, test.server.deps.inline naming each workspace member in the closure, the plugin giving each module its id (below), the plugin resolving a relative .ts specifier to its compiled sibling, and the plugin resolving a tsconfig paths alias |
yes |
| 2. user | the config file |
it supplies the projects |
| 3. provider | test.coverage.provider from coverage_provider |
no, root only |
| 4. snapshots | test.resolveSnapshotPath |
no, root only |
Objects merge key by key; arrays concatenate base-first, as vite's own
mergeConfig does, so a plugins list in the config joins layer 1's. Scalars
from a later layer win: a cacheDir the config sets wins over layer 1's, and
coverage_provider over a provider the config names. Layers 3
and 4 are root-only because coverage and resolveSnapshotPath are vitest's
non-project options, applied once and never merged into a project.
After the merge, root resolves relative to the config's directory, or the
test's package with no config. Inline projects inherit that effective root;
an explicit project root resolves relative to it. Absolute roots stay
absolute.
test.include and test.dir are set after the merge, on the root and on
every project. The include matches .test and .spec filenames using the
selected source files' extensions, such as .ts, .tsx, .mts, .cts or
JavaScript extensions. The launcher stages this shard's selected test entries
at their source paths in a separate discovery tree beneath TS_TEST_FILES_ROOT
(Sharding). Each entry resolves to its selected runtime module,
including its emitted file when emit = True; helpers remain available without
becoming extra test entries.
A config's include and dir are replaced by this discovery layout. Its root
and exclude still select tests using source coordinates. Use source filenames
for positional CLI filters too, even when the tests execute emitted JavaScript.
After discovery, module ids follow the runfiles rules below and snapshots use
their source locations (Snapshots).
//tests/vitest/config_include and //tests/vitest/many_files are examples.
When neither the config nor the project declares a root, additional include
patterns prepend ../ for each ancestor through the staged discovery root, so a
shared config can discover declared tests in sibling directories. An explicit
root, including one inherited from the parent config, scopes discovery to its
staged subtree. Exclusions keep their path basis at the effective root's
test.dir; expanding the include patterns does not rebase them. The config's
authored include and dir are replaced so discovery uses the selected source paths. The cost of this traversal is not measured.
A module's id is its runfiles path where the runfiles hold the file and its
realpath otherwise. Vite resolves every id to its realpath -- a test file's and
a compiled module's lie in bazel-out, a source file's in the source tree --
and layer 1's plugin gives a file the runfiles hold at that path its runfiles
path back, so import.meta.url and a relative import stay in the runfiles
tree; a file under node_modules keeps its realpath, so a package's own
imports resolve from its place in the tree, and one file is one module. An id
that resolves to a file the runfiles do not hold is refused:
rules_typescript: "<id>" resolved to <path>, which this test's runfiles do not
hold. server.fs.allow names the workspace's runfiles and bazel-bin's
realpath, the two places an id can be, for the DOM environments that load
through Vite's server.
server.deps.inline in layer 1 names every workspace member in the closure.
vitest runs a module under node_modules in node unless a pattern names it,
and a member's emitted .js keeps the extensionless relative imports vite
resolves and node's loader rejects; under pnpm the same member is inlined
because its link's realpath lies outside node_modules. Inlined, a member's
own imports resolve through vite with the environment's conditions on both
layouts, and vitest 4 gives a DOM environment the server set (node,
development|production): a package whose exports map splits browser from
node answers with its node build unless the config sets
resolve.conditions.
Two things sit outside the layering: npm resolution into the runfiles tree
(the launcher's node_modules link at the runfiles root, Files at Run
Time) and coverage output paths (vitest CLI flags, so
bazel coverage writes lcov where Bazel expects it).
bazel run //path/to:my_test -- --dump-config # what the launcher resolved
bazel build //path/to:my_test --output_groups=vitest_config # the config that ran
A Config File¶
ts_test(
name = "component_test",
srcs = ["Button.test.tsx"],
deps = [":button", "@npm//:react", "@npm//:vitest"],
config = "vitest.config.ts",
data = ["test/fixtures.json"],
)
The file may default-export an object, a function of env, or a promise of
either. An array is read as a list of vitest projects and becomes
test.projects; each project in it receives the Bazel layer too, because every
project gets its own Vite server.
Vite's root defaults to the config's directory, or the test's package with no
config. The config can set root; inline projects inherit it or set their own
(The Generated vitest Config). Vitest's working
directory stays at the config's directory, and the config loads from its own
path, so __dirname and import.meta.dirname name that directory too
(Files at Run Time).
config_srcs names the modules the config imports relatively and the ones
they import; each is written at its own path in the runfiles, so ./plugins/foo
is plugins/foo.ts beside the config, and a bare import in plugins/foo.ts
walks up to the runfiles tree's node_modules. A config from an ancestor
package names its modules as that package's files, //<package>:<file>.
//tests/vitest/config_srcs is the example.
Config staging cannot replace a published runtime module or change the nearest
package scope of a JavaScript or TypeScript runtime module. Analysis rejects an
authored config scope that would overwrite an emitted program's rewritten
package.json, including when the config imports that JSON. Place the config
in a separate package scope, or use source mode so both consumers share the
authored scope File. A producer's unchanged scope symlink retains its exact input
File identity, so staging that input is allowed. Copying the same published File
remains supported. JSON modules retain exact File identity without requiring
JavaScript package-scope equality.
config_node_modules names the importer or member-link targets that supply the authored
config's npm imports, including imports from its relative modules and packages
declared by an ancestor importer. These targets use the same runfiles staging
as data. The test keeps its own node_modules and deps, so a sibling config
may use another version of the same npm package. //tests/vitest/config_importer
asserts both versions at run time. Existing importer targets in data continue
to work and remain authored inputs; Gazelle manages only config_node_modules
for this closure, withdrawing entries when imports or config selection change.
A # keep on config_node_modules preserves the authored list, including an
empty one. Gazelle cannot infer missing runfiles from that list: the test's
importer and dependencies, data filegroups or workers_pool can supply them.
The author owns the kept closure; Bazel analysis and runtime loading check the
actual inputs.
Member packages load from their store closures; their source files are not copied into config_srcs.
Generated config files retain their declared runtime owner in deps. A source
import resolved through a generated declaration retains that owner's JavaScript
and data outputs without copying the declaration or following its imports.
Gazelle writes config from the file plain vitest would read -- a
vitest.config.*, else a vite.config.*; //tests/vitest/vite_config is the
example, a define the test reads -- beside the tests by name, else the one
in the nearest directory above holding a package.json, or the repository
root, as the label //pkg:vitest_config of a public filegroup it writes over
the file; and config_srcs from the config's listing, followed through every
first-party module it reaches (what Gazelle
writes).
The array form needs vitest 3.2 or later
test.projects is the name test.workspace was renamed to in vitest 3.2;
vitest 4 removed the old name and throws on it. Every other config shape
(object, function, promise) uses no version-sensitive key.
Setup Files¶
test.setupFiles in the config names the files that run before every test
file, which is where DOM polyfills (matchMedia, ResizeObserver,
PointerEvent) belong; test.globalSetup runs once around the whole run. An
entry is resolved against the root. Once the layers have merged, an entry
ending in .ts, .tsx, .mts or .cts whose compiled sibling is in the
runfiles (.js, .mjs or .cjs; .jsx for a .tsx under jsx: preserve)
is rewritten to that sibling, so setupFiles: ["./test/vitest.setup.ts"] in a
config at the package root runs test/vitest.setup.js. An entry with no
compiled sibling staged is left as written and fails to load: Cannot find
module '.../test/vitest.setup.ts'. The ts_compile over the source has to be
in deps; nothing imports a setup file, so Gazelle writes no such dep and the
entry is # keep. //tests/setup_files_compiled is the example, with the
config at the package root and beside the tests.
vitest resolves each entry through Node's resolver, which follows the runfiles
link to the compiled file in bazel-out; layer 1 gives the module its runfiles
path back as its id, as it does every file the runfiles hold, so a DOM
environment (jsdom, happy-dom), which loads setup files through Vite's
server, is served a path under the root. //tests/setup_files_compiled/dom is
the example.
.ts Specifiers¶
import { x } from "./util.ts" is legal TypeScript under
allowImportingTsExtensions, and the emit keeps the specifier as written.
Resolved as written, a same-package import runs the source under vite's
transform, and an import of another package's file fails:
Layer 1 carries a plugin that resolves a specifier ending in .ts, .tsx,
.mts or .cts to the compiled file the runfiles hold for it (.js, .mjs
or .cjs; .jsx for a .tsx under jsx: preserve) whenever that file
resolves from the importing file: a relative specifier to the sibling beside
it -- the rule a setupFiles entry is rewritten by -- and a bare one, a
subpath into a workspace member (subpath-member/src/value.ts from a member
that declares it), to the file under the member's view in the tree (What a
Workspace Member Is Imported
As). A specifier
with no compiled file resolves as written; the source and the emit are
untouched. //tests/vitest/relative_ts and
//tests/npm:by_name_member_test are the examples.
A paths Alias¶
A compiled test imports @app/flags as its source did: oxc leaves the
specifier alone, and vitest alone would resolve it as a package. At compile
time tsgo resolved it through the tsconfig's paths
(ts-compile.md);
at run time layer 1 does the same, over the runfiles.
The TsTestPaths action reads the tsconfig chain with the reader the
compile's TsConfig action uses and writes its paths beside the generated
config, with the directory of the chain file that set them (a leaf replaces
the map whole, as under tsc). The plugin matches a specifier the way tsc does:
the exact key first, then the pattern with the longest prefix; the matched
key's values in order, each read from that directory, a value naming a .ts
swapped for its compiled sibling before resolving; the first value that
resolves wins, and one that resolves nothing leaves the specifier to vite. A
relative, absolute or virtual specifier is never matched.
// tsconfig.json
{ "compilerOptions": { "paths": {
"@shared/*": ["./shared/*"],
"@platform/auth": ["./lib/auth/platform-adapter.ts"]
} } }
@shared/flags resolves to shared/flags.js (vite's extension order puts the
compiled .js before the staged .ts); @platform/auth to
lib/auth/platform-adapter.js, the sibling of the file the value names. A
value under another package resolves when that package's target is in deps,
which stages its compiled module; the source it names is not in the runfiles
and is not needed. A config that sets resolve.tsconfigPaths still finds no
tsconfig.json in the runfiles, and does not need one.
//tests/vitest/path_aliases is the example: a pattern alias, an exact alias
naming a .ts, and an alias into another package, every one a value import.
Snapshots¶
vitest resolves a .snap beside the test file it ran, which under Bazel is the
compiled .js in bazel-out. Layer 4 replaces that resolution with the path
the .ts source implies, <package>/__snapshots__/<source file name>.snap,
the path a plain vitest run uses. The .snap is a src of the test, as every
other file under the package is (srcs); Gazelle
lists it with the package's files:
ts_test(
name = "widget_test",
srcs = [
"__snapshots__/widget.test.ts.snap",
"widget.test.ts",
],
deps = [":widget", "@npm//:vitest"],
)
A snapshot the test cannot read is a failure: CI=true puts vitest in
read-only snapshot mode, so no bazel test writes a .snap. Writing one is
vitest -u in the package, as outside Bazel. Commit the result.
Coverage¶
bazel coverage //path/to:my_test works on any ts_test on the vitest runner
with no attribute set; @vitest/coverage-v8 must be in node_modules.
Bazel's collect_coverage.sh runs the test with COVERAGE_DIR set; the
launcher runs vitest with the lcov reporter and writes the report, its paths
resolved against vitest's root, the config's package, and made
workspace-relative, as vitest.dat in that directory. Bazel then runs the
rule's merger, the tools toolchain's lcov_merger through
@rules_typescript//ts/toolchain:lcov_merger_resolved, over the directory, and
it keeps the records the coverage manifest selects. The merger is the rule's
rather than Bazel's own because the manifest names the .ts a target declared
and the report names the .js compiled from it, and Bazel's merger keeps a
record only under the manifest's exact spelling; the rule's matches the two by
path. tools/ci/check_coverage_report.sh runs the two commands below in CI and
reads the report.
--instrumentation_filter selects the targets whose files reach the report.
Bazel derives a default from the targets on the command line; for
bazel coverage //foo:bar_test that is ^//foo[/:], so a library in another
package is absent from the report until a wider filter names it:
bazel coverage //tests/vitest/coverage:math_coverage_test --combined_report=lcov
# SF:tests/vitest/coverage/same_package.js only
bazel coverage //tests/vitest/coverage:math_coverage_test --combined_report=lcov \
--instrumentation_filter='^//tests/vitest[/:]'
# adds SF:tests/vitest/math.js
Every ts_compile carries its own InstrumentedFilesInfo, so the filter is
applied per target. The test's own files are a test target's, which Bazel
leaves out of the report unless --instrument_test_targets is set. Coverage is
reported against the compiled .js in bazel-out, so SF: paths and line
numbers are the compiler's.
A test whose pool runs the tests in a second runtime, such as a
@cloudflare/vitest-pool-workers test in workerd, needs istanbul: v8 coverage
is counters read back out of Node's inspector, which workerd has no equivalent
of, and istanbul instruments at transform time. Set
coverage_provider = "istanbul" and put @vitest/coverage-istanbul, pinned to
the same version as vitest, in deps. With only the package in deps and no
coverage_provider, vitest falls back to its v8 default and the run fails with
MISSING DEPENDENCY Cannot find dependency '@vitest/coverage-v8'.
Finding What a Test Reads¶
A test that opens a file the runfiles do not hold fails in the sandbox with
ENOENT, and nothing in the build says which file: a run-time read is in no
program listing, so Gazelle writes no data entry for it.
runs the same compiled tests unsandboxed, from the runfiles tree bazel test
runs them in, under a Node --require hook that records every path the test
processes open, stat or read. Once vitest has exited it prints, on stdout,
every regular file under the workspace that the run reached outside the
runfiles tree -- once, sorted, as the workspace-relative path and the label a
data entry would take:
MODULE.bazel //:MODULE.bazel
tests/vitest/reads_report/fixtures/outside.txt //tests/vitest/reads_report/fixtures:outside.txt
vitest's own output goes to stderr; a test reading nothing outside its runfiles
prints nothing; the exit status is the test's. A test that walks up from its
own directory to a MODULE.bazel or a package.json leaves the runfiles tree
and finds the workspace's file through the execution root, and the marker it
stopped at is in the report beside the files it then opened: both go in
data. What the vitest process itself opens -- a config, a globalSetup
entry -- is not recorded.
data is the owner's attribute: Gazelle writes nothing into it and leaves what
is written. A file another package's test reads needs an exports_files in
its package:
ts_test(
name = "reads_declared_test",
srcs = ["reads_declared.test.ts"],
data = [
"//:MODULE.bazel",
"//tests/vitest/reads_report/fixtures:outside.txt",
],
deps = [":workspace_root", "@npm//:types_node", "@npm//:vitest"],
)
//tests/vitest/reads_report is the example: reads_report_test makes the
read undeclared and is manual, and run with --reads it prints the two lines
above; reads_declared_test lists them and prints nothing.
Environments¶
The Workers pool is the vitest runner's environment: wrangler_config, the
config layer the generated config imports, the WranglerTestConfig action,
and the runfiles and symlinks it adds are one file,
ts/private/actions/workers_pool.bzl, the only file that names wrangler. The
runner reaches it through the one struct it returns, so a Workers ruleset
takes the file as it is.
A Workers Pool¶
A config whose plugins hold @cloudflare/vitest-pool-workers runs the tests
inside workerd. The default effective root is the config's directory, so
wrangler.configPath names the file beside it. The generated config gives each
module one id (The Generated vitest Config):
the pool resolves vitest's own modules for workerd by realpath, and a package's
file has its realpath as its id. wrangler_config prepares the worker entry:
ts_test(
name = "worker_test",
srcs = ["worker.test.ts"],
config = "//workers/proxy:vitest_config",
coverage_provider = "istanbul",
tsconfig = "tsconfig.json", # lib esnext + webworker; types @cloudflare/vitest-pool-workers/types
workers_pool = "//workers/proxy:node_modules",
wrangler_config = "//workers/proxy:wrangler.jsonc",
deps = [
"//workers/proxy:worker",
"@npm//:cloudflare_vitest-pool-workers",
"@npm//:vitest",
"@npm//:vitest_coverage-istanbul",
],
)
The pool boots the file main names, relative to the configuration directory. wrangler_config selects the exact live source/runtime File pair, including moved TypeScript and emitted JavaScript. A test's own srcs take precedence for those exact sources; joining dependency sources for checking does not recompile them. For compatibility with older providers, an unmatched entry can select its conventional emitted path from declared runtime JavaScript Files not covered by explicit pairs. A simultaneously published source entry is ambiguous and fails. Explicit pairs, including identity pairs, remain authoritative; incidental files on disk do not establish an entry mapping. Conflicting runtime owners fail; choose one representation in the test dependencies.
The action uses the pool's Wrangler parser and prepares one copy. It replaces the original configuration's runfiles binding and every live asset/module binding whose record names that exact original File, including moved ?raw imports. Environment entries use the same entry projection. An entry with no declared runtime match remains unchanged, so unused environments need no extra dependencies; the pool reports a missing entry if that environment is selected. A configuration with no main, or a .toml file containing # comments, still fails the action. Other keys and supported comments survive with Wrangler's formatting.
workers_pool names the importer or workspace-member link that declares the pool
for the module importing it. If config imports ./pool/setup.mjs and that helper
imports the pool, name the helper's declaring importer or member link. Its pool link and store are inputs to
WranglerTestConfig and are staged for the runtime config. config_node_modules
and data alone do not identify which of their packages the config imports.
An explicit workers_pool takes precedence over the test's importer chain.
Existing hand-written targets that omit it keep using the first pool linked
on that chain, including member links in deps; they need no attribute changes. An explicit owner that
links no pool is an error and does not fall back to the test's pool.
Gazelle writes this selection from the resolved npm edge when preparing a
Wrangler config. Importers of the same full pool resolution, including its peer
dependencies, share preparation; Gazelle keeps their runtime links and selects a
stable owner. Automatic selection requires one full pool resolution. A
workers_pool with # keep supplies the owner directly and bypasses inferred
pool conflicts; Gazelle still derives config inputs, Wrangler and coverage
settings. Without Wrangler preparation, no selection is needed; the runtime
imports and coverage settings remain the config's.
Automatic selection and config runtime staging follow compiler-observed source
imports. Gazelle omits declaration files and helpers reached only through type
references or declarations from config runtime inputs. A source import retains
its npm link or generated owner before traversal stops at the declaration.
The compiler listing cannot distinguish import type in ordinary TypeScript
source from runtime imports. If a type-only helper introduces another pool
resolution, set workers_pool to the pool that executes and mark it # keep.
The author owns that choice; analysis validates the selected pool link and
Wrangler loads its parser. Configs that actually run different pools require
separate test targets.
A workspace wrapper with separate types and import exports can expose only
its declaration entry to the compiler while its member store executes JavaScript
that loads the pool. Gazelle cannot infer that hidden runtime pool. Before
regenerating such a target, set workers_pool to the importer or member link
declaring the actual pool, set wrangler_config, and set
coverage_provider = "istanbul". Add # keep to
each attribute and to the matching @vitest/coverage-istanbul entry in deps.
Mark existing generated settings the same way when upgrading: generation can
withdraw unmarked settings and the coverage dependency. The wrapper's member
store continues to supply its runtime files and dependencies through
config_node_modules; they need no copied config_srcs or direct test
dependencies.
The runner omits each replaced config File and validates that every binding selects the prepared File. Listing the authored config separately in data, config_srcs or transitive runfiles cannot override it; analysis reports the conflicting File. Other dependency assets retain their published identities. //tests/workers_nested covers a moved producer and nested test; //tests/workers covers a config beside the tests.
What else a wrangler config names, and where each comes from under ts_test:
| Key | Source |
|---|---|
main, env.<name>.main |
the declared runtime source or emitted entry, through the staged copy |
rules modules (**/*.txt, **/*.md, ...) |
a src of the worker's ts_compile |
assets.directory |
its contents in data, at the same path relative to the config |
.dev.vars, .dev.vars.<env> |
read beside the config; in data when a test needs one |
compatibility_date, compatibility_flags, vars, kv_namespaces, r2_buckets, services, durable_objects, migrations |
inline values; miniflare emulates the bindings and nothing is staged |
durable_objects[].script_name naming another worker |
miniflare.workers in the config, as under plain vitest |
tsconfig, alias, define, no_bundle, build |
esbuild and deploy keys; the pool runs none of them |
Gazelle writes wrangler_config and coverage_provider from the config's
edges (a Workers-pool config).
The node:test Runner¶
A test written against node:test registers with node's runner,
not with vitest's collector, so vitest reports 0 test for the file and fails
it as an empty suite. runner = "//ts/runners:node_test" runs such a file
under node --test:
ts_test(
name = "scripts_test",
srcs = ["cloudflare-account-token.test.ts"],
runner = "@rules_typescript//ts/runners:node_test",
deps = [":scripts", "@npm//:types_node"],
)
tsconfig means on a node:test target what it means above, but a paths
alias is type-checking only under node:test: the resolve hook below answers a
relative specifier and a bare one from the tree, and nothing reads the chain's
paths. Under vitest an alias resolves at run time
(The Test's tsconfig).
The build action materializes the program's declared scalar modules, including imported JSON, as regular files at their admitted runfiles coordinates, preserving bytes and permissions. Ordinary File and store authorities are copied once; their aliases and unrelated dangling links retain link roles. The configured runtime executable uses its original runfiles. The launcher execs without a temporary application tree or cleanup helper, so parent exit does not remove inputs still read by children. Directory and manifest runfiles select the same immutable view; the manifest takes precedence when both are available. Materialization cost is not measured.
The runner target's synchronous node:module resolve hook
(ts/private/node_test_hook.mjs) resolves specifiers from files outside the
store, whose paths hold no node_modules/ segment:
- A relative specifier resolves, at the importer's runfiles path, to the file
the compiled tree holds for it:
./util.ts,.tsx,.mtsor.ctsto the compiled sibling (.js;.jsxfor a.tsxunderjsx: preserve;.mjs;.cjs), whether or not the source is staged beside it; an extensionless./utiltoutil.jsorutil/index.js, the file bundler resolution named at compile time; any other to the file as written.//tests/node_testpins the three::ts_specifier_test,:extensionless_test,:runfiles_layout_test. - The package's own name -- the nearest
package.jsonabove the test names it and hasexports, node's first step for a bare specifier -- resolves as written from the test's own path, and the source itsexportsname to the compiled sibling (//tests/node_test/self_reference). Private#imports use the same rule with the nearest manifest'simportsmap. Both keep the same module identity as a relative import, including in borrowed sources. - A bare specifier resolves from the importing module's staged path first.
When that lookup selects no package, the hook tries the declared importer
chain on
NODE_PATHin order;requirereadsNODE_PATHitself. A selected package's missing entry or rejected export remains an error on either route. One ending in.ts,.tsx,.mtsor.cts-- a subpath into a workspace member -- resolves to the compiled file the store holds for it, on either route (//tests/npm:by_name_member_node_test). Code in the store uses Node's native realpath resolution.require,require.resolve,require.cacheandcreateRequiretherefore agree on installed module identities and dependency edges (//tests/npm/multi_version:own_edge_node_test). The hook also maps a.tsspecifier to its compiled form; nativerequire.resolvedoes not apply this extension fallback.
The compiled tests run in the module format their tsconfig gives them
(The Module Format). A package whose
module is commonjs, or nodenext with no type in its package.json,
runs as CommonJS: __dirname is the test's runfiles directory, require is
node's, a relative require resolves through the hook as an import does,
and a named import from a CommonJS dependency is that dependency's export.
//tests/node_test/cjs pins the four.
Under vitest, layer 1's plugin resolves the .ts specifier
and the generated config the rest.
node:test takes no config file; it is configured by CLI flags and by the test
file itself. args are those flags, placed before --test so that the
children node --test spawns inherit them: a suite that calls mock.module
sets args = ["--experimental-test-module-mocks"], as its package's test
script does (//tests/node_test:module_mocks_test). The runner refuses the
vitest attributes, naming the ones set:
ts_test @@//scripts:scripts_test: the node:test runner reads none of config,
coverage_provider. Every one of them configures vitest, which this target does
not run. Drop them, or drop `runner` to run the test under vitest.
The rejected set is config, config_srcs, config_node_modules, workers_pool,
coverage_provider and wrangler_config. bazel coverage on such a target
fails saying it reports none. --test_filter reaches node as
--test-name-pattern (a regular expression over test names), and the exit
status is the test result. Nothing writes a JUnit XML on either runner; Bazel
synthesises test.xml from the log.
Sharding¶
Set shard_count on the target. The launcher splits the compiled test files
the same way under both runners: sorted by runfiles path, shard i of n
runs every n-th file from the i-th. Under vitest the shard's files are
the root the launcher stages for the walk (The Generated vitest
Config); under node:test they are node's
command line. A shard the split leaves no file exits 0 with ts_test: no test
files assigned to shard <i>/<n>. A globally empty Vitest suite still runs
Vitest, which applies its own passWithNoTests configuration and arguments.
Before either run the launcher touches
TEST_SHARD_STATUS_FILE, the file Bazel reads after a shard to know its
runner honoured the shard variables. //tests/vitest/sharding and
//tests/node_test/sharding run six files over three shards under each
runner; every file asserts it ran in its own slice and, under vitest, that
the staged root holds that slice alone.
Debugging¶
ts_test(
name = "my_test_debug",
srcs = ["my.test.ts"],
deps = [":my_lib", "@npm//:vitest"],
tags = ["manual"],
env = {"NODE_OPTIONS": "--inspect-brk=9229"},
)
bazel run //path/to:my_test_debug, then attach with VS Code or
chrome://inspect.
Listing npm Deps¶
Test sources are checked for undeclared imports like any other ts_compile
sources: a module that only some dep's own deps provide fails the build with the
label to add (Deps have to be direct).
A ts_compile dependency contributes its importer links and store trees to
runfiles through TsInfo.npm_files; the test need not repeat those npm
dependencies. Under node:test, a first-party dependency's
bare ESM import uses its own staged importer links, preserving its declared
package version. The test's importer chain supplies a fallback only when the
module's native lookup selects no package. deps lists what the test files
import, each resolved through the
test's own chain (the chain).
bazel run //:gazelle writes the list from tsgo's listing of the package: the
edges of the test files, the production sources and the declarations, and the
nearest package.json's dependencies and devDependencies. The authored
config's npm imports contribute their declaring importers to
config_node_modules; first-party runtime owners remain in deps.