How blade test runs user tests¶
Test execution sits on top of the regular build: once the binaries are ready, blade decides which ones are worth running this time, prepares a per-test sandbox, schedules them in parallel (with one carve-out for exclusive tests), captures their results, and updates a persistent history so the next run can skip the unchanged ones.
| File | Role |
|---|---|
src/blade/test_runner.py |
Discovery, history-based dedup, result aggregation |
src/blade/test_scheduler.py |
Parallel job dispatch, worker threads, timeout |
src/blade/binary_runner.py |
Per-test runfiles dir, env vars, testdata staging |
src/blade/config.py |
cc_test_config, global_config.test_timeout, test_related_envs |
1. Pipeline overview¶
TestRunner.run()collects every*_testtarget (cc_test,java_test,py_test,scala_test,sh_test, ...) and asks_run_reason()for each whether it needs to run this time.- For tests that do,
BinaryRunner._prepare_env()builds a per-test<target>.runfilesdirectory, symlinks shared libs in, copies testdata, and assembles the env dict. TestScheduler.schedule_jobs()dispatches the jobs across worker threads. Tests markedexclusive=Trueget a separate single-worker pass after the parallel batch.- After all jobs complete, the runner merges results into the test history file, writes the JSON summary, and prints the passed/failed/unchanged/repaired counts.
2. Per-test environment¶
Each test runs with cwd=<target>.runfiles. The directory is rebuilt
from scratch every run; into it go:
- A symlink of the build dir (
runfiles/<build_dir_basename> -> <abs build dir>). Blade-built shared libs carry their relative build path as their identity (no soname / no@rpath), so this symlink is what letsbuild_release/lib/libfoo.soresolve at runtime from the test's cwd. This was the fix for issue #1167 and also makes macOS dyld and Linux ld.so behave the same way without an OS branch. - Per-soname symlinks for prebuilt libraries that do carry a
soname (
libcrypto.so.1.0.0, ...). Found at runtime via theLD_LIBRARY_PATHblade prepends to point at the runfiles dir. - Staged testdata copied from the target's
testdataattr (and from any sibling<target>.testdatafile). Items can be plain paths or(src, dst)tuples;//-prefixed paths are workspace-relative.
The env passed to the test is a copy of os.environ plus:
LD_LIBRARY_PATH(runfiles + configuredrun_lib_paths).PATHextended withjava_home/binifjava_config.java_homeis set.GTEST_COLOR,GTEST_OUTPUT=xml,HEAPCHECK(per-target),PPROF_PATH,BLADE_COVERAGEwhen applicable.
Windows skips the soname-symlink path (PE loading does not look there anyway), so this whole sandbox is POSIX-shaped.
3. Parallel scheduling¶
TestScheduler runs two passes:
- Normal pass: up to
global_config.test_jobsworker threads pull jobs from a shared queue. Each worker runs onesubprocess.Popen, captures stdout/stderr, applies the per-test timeout, and reports a result back to the main thread. - Exclusive pass: tests with
exclusive=Trueget serialized into a separate queue run by a single worker, after the normal pass completes. Useful for tests that fight over a fixed resource (port, shared file, system-wide config).
A timeout watcher on the main thread sends SIGTERM (then SIGKILL)
when a job exceeds its limit; the exit code is mapped through
_signal_map() so the result line reads SIGTERM:-15 rather than a
bare negative number.
The runner switches between two output modes. With a single worker and
normal verbosity it streams test stdout straight through, so an
interactive blade test of one target reads as if the test were run
directly. With multiple workers (or quiet mode) it buffers each test's
output and emits it as a single block after the test finishes, so
parallel runs don't interleave lines.
4. Results, summary, and the test history¶
A per-test TestRunResult(exit_code, start_time, cost_time) is recorded
for each job. The runner merges:
- Passed results: update the history, mark previously-failing tests as "repaired".
- Failed results: increment
fail_count, recordfirst_fail_timeif this is a new failure, add tonew_failed_tests.
The history file <build_dir>/.blade.test.stamp stores, per test, the
last TestJob (binary MD5, testdata MD5, env MD5, args) and the last
TestHistoryItem (run result, fail counters). It is a Python repr()
that loads back via eval(), so the schema stays human-readable and
forward-compatible with field additions.
A structured blade-bin/.blade-test-summary.json is also written every
run, with the same data partitioned into passed / failed /
unchanged / repaired / new_failed / unrepaired / excluded.
Outside tooling (CI dashboards, IDE integrations) reads from there
instead of parsing terminal output.
5. Incremental dedup of unchanged tests¶
The whole machinery in §4 and §5 — test history, the
.blade-test-summary.json, the unchanged / unrepaired buckets —
exists primarily for large-monorepo CI ergonomics: in a repo with
thousands of tests, one persistently-broken test must not gate the
whole CI pipeline, but it cannot disappear from the summary either.
The mechanism is a side-channel that keeps such tests visible (first-fail
time + retry count in unrepaired) so an owner is still nudged to fix
them, without holding the rest of the suite hostage.
_run_reason() decides whether a test must run:
EXPLICIT— the test was named on the command line.FULL_TEST—--full-testoverrides the dedup entirely.ALWAYS_RUN— target setsalways_run=True.BINARY/TESTDATA/ENVIRONMENT/ARGUMENT— the corresponding MD5 changed since the last passing run. Environment usesglobal_config.test_related_envs(regex list) to decide which env vars matter, so a change toEDITORdoesn't invalidate every test.STALE— the last run is older than the cutoff (currently one day).NO_HISTORY— first run.FAILED/RETRY— the test failed last time and isn't covered by the unrepaired-skip policy.
If none of the above triggers, the test is silently skipped and counted under "unchanged" in the summary. Tests that have been failing for a while can be parked into the "unrepaired" bucket (with first-fail time and retry count surfaced in the summary) rather than re-running every build — important when one stubborn test holds up an otherwise green suite.
6. Implementation details and UX optimizations¶
- Runfiles + cwd-relative dylib resolution. The single
runfiles/<build_dir>symlink is what makes blade-built.so/.dylibfiles findable from a test's cwd without special install names or rpath. The cost of that symlink is one syscall per test; the benefit is that test execution works identically on Linux and macOS. - Exclusive tests are a separate pass. Mixing exclusive tests into the parallel queue would have meant either capping parallelism for the whole pass or doing per-test gating. A second, single-worker pass is simpler and keeps the parallel path's logic simple.
- The env MD5 with a configurable allow-list.
test_related_envslets a workspace declare which env vars actually affect test results, so cosmetic changes (terminal program, shell PS1) don't invalidate the cache. Tests that genuinely depend on, say,TZadd it to the list. - Output capture vs streaming. Auto-selecting based on worker count and verbosity makes the common single-target debugging case pleasant (no buffering) without sacrificing readable output on a thousand-test parallel run.
- JSON summary alongside terminal output. The summary file lets CI hook into structured results without grepping the test log; it carries timing, exit code, fail counts, and the "new failed" / "repaired" deltas that humans care about.
unrepairedpolicy. Skipping known-broken tests by default (with a clear summary line) is a deliberate UX choice: a broken test should not hold up the rest of the suite, but the summary still says "still unrepaired, failing since X, retried N times" so it doesn't get forgotten.- Coverage cleanup. When
--coverageis set,_clean_for_coverage()removes the per-test runfiles build-dir symlinks at the end so the coverage tool walking the runfiles tree doesn't follow them into the real build dir.