Reuse tests one by one
Updated
When a check's code changed, hushrun run normally runs every test again. With tests set in hushrun.yaml, it leaves out the tests that already passed on the files they read, and runs the rest. It's off until you turn it on, check by check.
hushrun: unit: 3 of 101 tests run, in 2 of 2 test files; 98 already passed on the files they read, and 2 more that already passed run anyway, as a checkIt leaves out whole test files, or each test on its own. It starts after a miss: when the check's result key has a pass, the check skips as before, and no test runs.
Before you begin
- hushrun 0.16.0 or later, for tests one by one. Before it, hushrun reuses only whole test files.
- A check run as
hushrun run NAME -- COMMAND, where COMMAND starts one of the test runners below. The check's settings go under itsNAMEinhushrun.yaml, whichnpx hushrun initwrites. - Results on: an organization's backend, or this machine's cache (Where results go). With results off, hushrun looks at no test.
Check what your runner needs
Name the runner in COMMAND, as below. A script that hides it, such as npm test or make test, gets no test left out, except for pytest, found wherever it runs, and CTest and swift test, found in a shell line, a script or a Makefile that starts them by name.
| Runner | COMMAND | What to add |
|---|---|---|
| pytest | pytest, python -, or anything that starts pytest | Nothing |
| Vitest | vitest run, npx vitest run, pnpm vitest run | One line in Vitest's config, below |
| Jest 29 or 30 | jest, npx jest, pnpm jest | Nothing, unless a script replaces NODE_: below |
| Go | go test . | Nothing. Go 1.20 or later leaves out one test; older Go leaves out whole packages |
| Gradle: Java, Kotlin, Android's local unit tests | ., gradle test | One line in the settings file, below |
| Maven | mvn test, . | Nothing |
swift test | swift test | Nothing. Swift 6.0 or later, on macOS or Linux |
| CTest, for C and C++ | ctest - | Nothing. CTest 3.21 or later |
hushrun init proposes the lines for Vitest, Jest and Gradle in its list of changes to make by hand. It never writes them. Without HUSHRUN_VITEST, HUSHRUN_JEST or HUSHRUN_GRADLE, which hushrun sets for its own runs, each line does nothing.
Vitest loads hushrun's plugin from one entry in its config, vitest.config.ts or vite.config.ts. Where the config has plugins already, add the entry inside that list:
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [process.env.HUSHRUN_VITEST ? import(process.env.HUSHRUN_VITEST).then((m) => m.default()) : undefined],
})Jest loads hushrun's file through NODE_OPTIONS, so no file changes. A package.json script that sets NODE_OPTIONS without what it had drops it. Keep it (NODE_OPTIONS="$NODE_OPTIONS …"), or put this line first in jest.config.js:
if (process.env.HUSHRUN_JEST) require(process.env.HUSHRUN_JEST);For a jest.config.ts that compiles to CommonJS:
import { createRequire } from 'node:module'; if (process.env.HUSHRUN_JEST) createRequire(__filename)(process.env.HUSHRUN_JEST);For a config that is an ES module, the same line with createRequire(import.meta.url) in place of createRequire(__filename).
Gradle loads hushrun's script from one line in settings.gradle, after any pluginManagement {} and plugins {} block:
if (System.getenv("HUSHRUN_GRADLE")) apply from: System.getenv("HUSHRUN_GRADLE")Or in settings.gradle.kts:
System.getenv("HUSHRUN_GRADLE")?.let { apply(from = it) }Rust's cargo test and cargo nextest run reuse whole test binaries, never one test: with unit: test, the run says so once and goes on by test binary.
Watch first with observe
Start with observe. Every test runs, and hushrun records what each one read and says what reuse would have left out. Nothing is left out yet.
checks:
unit:
tests:
mode: observe
unit: testFrom the second run on, before the runner starts, a line says what reuse would have done:
hushrun: unit: with `tests: reuse`, 2 of 13 tests would have run, in 2 of 5 test files; 11 already passed on the files they readMake a few ordinary changes and read the line each time. If it says every test would have run, it says why, and the reasons are in Limits.
Turn on reuse
Change observe to reuse:
checks:
unit:
tests:
mode: reuse
unit: testNow the run leaves out the tests that already passed on the files they read. A few of them run anyway, as a check, and the line counts them:
hushrun: unit: 3 of 101 tests run, in 2 of 2 test files; 98 already passed on the files they read, and 2 more that already passed run anyway, as a checkThe runner prints its own output for the tests it ran, and the run's last line says what reusing saved:
hushrun: unit passed in 3s; reusing 98 tests saved about 1s; it won't run again on this codeTo reuse whole test files instead, leave out unit: test, or write tests: reuse. The line then counts test files. Each runner has its own word for them: test classes for Gradle and Maven, packages for Go, test targets for swift test, test programs for CTest.
Ask why a test ran
hushrun why says what a run would do with one test, for the files on disk now, and why. Name the test as its test file, ::, and its name:
hushrun why unit tests/test_cart.py::test_onetests/test_cart.py::test_one would be reused: it passed on the files it read, and none of them has changed sincetests/test_cart.py::test_flips runs: its newest result on the files it reads now failedIt writes nothing. Without the :: part, it answers for a whole test file. All its options: Commands.
What keeps it safe
A test is left out only when it passed on the same files it reads now. Three more rules keep a wrong pass from spreading:
- A failed run keeps nothing it can't explain. When a test file fails and none of its tests did, as with a failing
afterAllor a crash between tests, none of that file's tests keeps a pass from the run. Something hushrun couldn't follow inside a test, such as a process the test started, keeps that test from being reused. - An audit reruns a small share. Of the tests a run could leave out, 2% run anyway, at least one. One that fails, though it passed before on the same files, runs once more. If it fails again, reuse is suspended for the check: every test runs until a person turns it back on with
hushrun tests resume unit. A coding agent can't. - A build that is out of date records nothing. When a test ran code older than its sources, as a CTest program built before an edit, the run keeps its exit code and records no pass:
hushrun: unit passed, but it isn't recorded: t_misc ran old code: tests/t_misc.c changed after CMakeFiles/t_misc.dir/tests/t_misc.c.o was made; build before ctestThe audit's lines:
hushrun: unit: tests/test_two.py::test_ok failed, though it passed before on the same files it read: running it once more hushrun: unit: tests/test_two.py::test_ok failed again, though it passed before on the same files it read: reuse of unit's test files is suspended
Where it pays
It pays when a change reaches few tests:
- One test file changed. On open-source libraries it was tried on, a change to one test file ran 144 of click's 2,651 tests, 68 of httpx's 1,412 and 35 of luxon's 1,222.
- One file that few tests read changed. A change to one file ran 13 of gin's 641 tests, and 13 of swift-argument-parser's 569.
Collecting, importing and building still happen, even for one test of a file. So a run that leaves out 95% of the tests saves less than 95% of the time.
The trades
What you give up, and when it saves nothing:
- A change outside the sources runs every test. A README, a changelog, a CI file or a lockfile may be read by any test, so changing one runs them all. On the libraries it was tried on, nearly every commit touched one. List the files no test reads in
tests.ignore. - One shared entry point means every test reads everything. Where tests import the package through one
__init__.py, or an index file that re-exports all of it, every test reads every module, and a change to any source runs them all. reads: testcosts much more and saved little. Withreads: test, hushrun watches what each test itself calls and opens. On the libraries it was tried on, watching added 10 to 46% to a pytest, Vitest or Jest run withreads: file, and 81 to 453% withreads: test, which then ran only 2 to 10% fewer tests after a change. It works for pytest on Python 3.12 or later, Vitest and Jest; the other runners go on withreads: file, and say so. Try it inobservebefore you keep it.- Tests of one file now run apart. Tests that pass only together, through shared state or order, can fail when one runs alone. Use
unit: filefor that check, or list the file intests.always. - Reports hold only what ran. A run that left tests out writes test reports for the tests it ran, and its coverage isn't recorded.
The line for a change outside the sources:
hushrun: unit: all 8 test files run: requirements.txt changed, and any test may read itLimits
Some set-ups can't be watched. hushrun then runs every test and says why once:
- Vitest with
typecheckon,isolate: false, or tests in a browser;vitestwithoutrun, which watches. With Vitest's coverage on, no test is left out. - Jest with its coverage on runs every test file. Where ts-jest checks types with its language service, every test file reads every file of code.
- Go: a COMMAND with
-runor-skipreuses by package, and so does a package with aTestMain. A package that failed keeps its other tests' results only whengo testnames each one: add-v. - Gradle: Kotest's tests aren't left out one by one. A JUnit 4 task leaves out at most 250 tests. Switching between a plain Gradle run and one through hushrun reruns the test tasks, each way. With Isolated Projects on, nothing is watched.
- Maven: JUnit 5 needs Surefire 3.0.0-M4 or later, and JUnit 4 and TestNG Surefire 3.6 or later.
mvndisn't watched. swift test: not with--enable-code-coverage,--skip-build,xcrun swift testor a path toswift, and not on Windows.- CTest:
make test,ninja testandcmake --build … --target teststart CTest by its full path, and aren't watched. Build before you runctest. - pytest: under tox or nox, or a Python started with
-Ior-E, hushrun loads nothing, and the run says why. - Tests with tight timing can fail more often under
hushrun runthan plain, in every mode, since hushrun adds processes and pipes.
Every setting: tests in Configuration.
Next
- How it works: what hushrun compares before a whole check skips.
- Configuration:
testskey by key. - Day to day: run checks, and read what hushrun prints.