@mucolabs/japa-tia
v0.3.0
Published
Test Impact Analysis for the Japa test runner — run only the tests affected by your changes
Maintainers
Readme
@mucolabs/japa-tia
Test Impact Analysis for the Japa test runner. Run only the tests your changes can reach.
Record which source files each test touches, then use that map to skip the tests a change cannot possibly affect. A suite that takes two minutes usually takes a few seconds once there is a baseline to work from.
It has out of the box support for:
- Two dependency providers — V8 precise coverage for exact, runtime-accurate edges, or the static ESM import graph for near-zero overhead.
- File and per-test granularity, refining individual tests out of an impacted spec file.
- Safety rules that always over-select, never under-select — new tests, previously failing tests and anything unrecognised always run.
- Dependencies coverage cannot see — templates, fixtures,
.envfiles and database tables, declared through watch patterns or recorded at runtime. - Migration-to-table mapping, so a changed migration runs only the tests that queried the tables it defines.
- Template and frontend mapping, so a changed template runs the tests that render it, and a changed component runs the tests whose pages import it.
- Shared baselines, recorded once in CI and downloaded by everyone else.
- Result replay, reporting the whole suite's outcome while executing only the impacted part.
- Framework presets, currently AdonisJS.
tia recording a baseline for 214 test files (no baseline recorded yet)
…
tia baseline updated (214 test files, 631 dependencies)
# later, after editing one service
tia 1 changed file → running 3 of 214 test filesTable of contents
- Installation
- Usage
- How it works
- Safety rules
- Providers
- What a replay costs
- Reporting the whole suite
- Dependencies coverage cannot see
- Presets
- Options
- AdonisJS
- Sharing one baseline across the team
- Running the suite on CI
- Limitations
- Baseline format
- Compared with Pest
- Inspiration and prior art
- Development
- License
Installation
Install the package from the npm registry as follows:
npm i -D @mucolabs/japa-tia
yarn add -D @mucolabs/japa-tiaRequires Node.js >= 22.15 and @japa/runner v5. Nothing else — no coverage driver to install, no native extension. V8 provides the coverage.
Usage
Register the plugin in your Japa entrypoint:
// bin/test.ts
import { configure, processCLIArgs, run } from '@japa/runner'
import { tia } from '@mucolabs/japa-tia'
processCLIArgs(process.argv.slice(2))
configure({
files: ['tests/**/*.spec.ts'],
plugins: [tia()],
})
run()It stays dormant until you ask for it:
node bin/test.ts --tia # replay if a baseline exists, otherwise record one
node bin/test.ts --tia --tia-fresh # throw the baseline away and re-record
node bin/test.ts --tia --tia-info # where the baseline lives and what is in it
node bin/test.ts # unaffected: a normal, full runBaseline sharing and result replay have their own flags:
node bin/test.ts --tia --tia-baselined # fetch a shared baseline now
node bin/test.ts --tia --tia-import <file> # seed from a file
node bin/test.ts --tia --tia-export <file> # publish the baseline after the run
node bin/test.ts --tia --tia-replay # report cached results for skipped testsJAPA_TIA=1 and JAPA_TIA_FRESH=1 do the same, which is handy in an npm script or a file watcher.
How it works
Recording. Japa runs plugins before it collects and imports any test file, which is early enough to instrument the whole suite. The plugin starts V8 precise coverage, then takes a snapshot at every file boundary. Profiler.takePreciseCoverage resets the counters as it collects, so each snapshot is exactly the set of files that ran since the last one. That mapping — test file to source files — is written to a baseline on disk together with a content hash of every file in it.
Replaying. On the next run the plugin works out which tracked files moved — by hashing them, or by asking git first on a large repository — and walks the graph backwards to the test files that reached them. It then replaces the runner's file list with that subset, so unaffected test files are never even imported. The run keeps recording, cheaply, so the baseline stays current for the files it did touch.
Safety rules
Test Impact Analysis is only useful if it never hides a failure. These run regardless of what the graph says:
| Situation | Behaviour |
| --- | --- |
| No baseline yet | Full run, records the baseline |
| A lockfile, package.json or tsconfig.json changed | Baseline discarded, full run |
| A fullRunOn glob changed | Full run |
| A test file the baseline has never seen | Always runs |
| A test that failed on the previous run | Always runs, until it is seen passing |
| A file matching a watch pattern changed | Runs the tests that pattern points at |
| A test file was edited | Runs — a spec is a dependency of itself |
| A changed migration whose tables cannot be read | Runs every test that touched the database |
| Anything the plugin cannot work out | Logs a warning and runs everything |
The plugin also never shrinks what it knows on a filtered run. If you narrow the run yourself (--tags, --files, --groups, --failed, --bail), recorded dependencies are merged rather than replaced, because a partial view of a test must not be mistaken for the whole truth.
Providers
| | coverage (default) | imports |
| --- | --- | --- |
| Source | V8 precise coverage | static ESM import graph, via module hooks |
| Sees runtime-only edges (IoC container, dynamic import(), string keys) | yes | no |
| Per-test granularity | yes | no, file only |
| Recording overhead | see below | ~3% |
Measured on a deliberately hostile benchmark — 40 files, 400 tests, pure CPU-bound arithmetic with no I/O, which is the worst case for coverage:
plain run 1615 ms
--tia record 4846 ms (200% overhead)
--tia=imports record 1651 ms (2% overhead)Recording is the expensive half, and you pay it once. Replays do not pay it at all — see What a replay costs. Real suites spend most of their time in the database and the network, where a tax on JavaScript execution matters far less than these numbers suggest; measure your own before deciding.
Pick one with an option or straight from the flag:
node bin/test.ts --tia=importsWhat a replay costs
The coverage provider is expensive because V8's callCount counters block optimisation — measured at roughly 3x on pure JavaScript, and there is no way around it: without callCount the counters never reset, so per-test deltas are impossible.
So replays avoid it instead. On a replay the only dependency that can have appeared since the baseline is one a static import graph can see, so the provider is downgraded and edges are merged rather than replaced. Nothing previously recorded is lost, and a new import is picked up immediately.
Same suite, every test depending on the changed file so the whole suite replays:
plain full run 706 ms
replay, replayRecording: 'full' 1964 ms (178% overhead)
replay, replayRecording: 'static' 741 ms (5% overhead) <- defaultRuntime-only edges — a service resolved through a container — stay as last recorded until the next full run. Set replayRecording: 'full' if your container wiring changes often enough for that to matter, and expect the cost above.
Two smaller savings ride along: a run that selects nothing never opens the coverage session at all, and change scanning reuses its hashes when the baseline is written back rather than re-reading the whole graph.
Reporting the whole suite
By default an unaffected test simply does not appear. Turn on replayResults and it reports its previous outcome instead, so the output and the summary describe the entire suite while only the impacted part executes:
tia 1 changed file → running 1 of 3 test files
tests/billing.spec.js
✔ billing works (2.75ms)
✔ billing handles errors (0.47ms)
✔ auth handles errors (replayed) (0.43ms)
✔ auth works (replayed) (2.8ms)
✔ search handles errors (replayed) (0.35ms)
✔ search works (replayed) (4.22ms)
tia replayed 4 cached results
PASSED
Tests 6 passed (6)Replayed tests are marked in the title, in every reporter, because a summary saying "6 passed" when two tests ran should say which is which. A failing result is never cached — a failed test always runs again — so a replayed test is always one that passed or was skipped.
This is off by default. Pest enables the equivalent by default and calls the opt-out --filtered; the default here is the other way round so that nothing claims to have run unless you asked for it. --tia-replay turns it on for a single run.
Dependencies coverage cannot see
Coverage observes JavaScript. For everything else — a template rendered by name, a JSON fixture, a database table — declare the edge where it happens:
import { linkSource, linkTable } from '@mucolabs/japa-tia'
view.on('render', (template) => linkSource(`resources/views/${template}.edge`))
db.on('query', ({ sql }) => tablesIn(sql).forEach(linkTable))Both are no-ops when impact analysis is not recording, so they can live in your test bootstrap permanently. isRecording() is exported if the bookkeeping around them is expensive.
linkTable pays off with migrations: a changed migration is read for the tables it defines, and only the tests that queried those tables run.
tia({ migrations: { files: 'database/migrations/**' } })Lucid, Knex and plain SQL are recognised out of the box; pass extract for anything else. When no table can be read from a changed migration — or the migration was deleted — every test known to touch the database runs instead.
On AdonisJS neither of these has to be written: presets: ['adonisjs'] wires the query hook and the template hook itself. tablesInQuery(sql) is exported if you want the same extraction against another ORM.
Presets
tia({ presets: ['adonisjs'] })Merges in the watch patterns, full-run triggers and migration globs a framework needs: resources/views, resources/lang and seeders as watched directories, adonisrc.ts, start/, providers/, config/ and .env as full-run triggers, and database/migrations/** for table mapping. Anything you configure yourself is merged on top.
A preset can also bring hooks into the framework itself, which is what makes the mapping worth having. adonisjs brings two, wired only while recording and unwired when the run ends:
- Lucid. Subscribes to
db:queryand callslinkTablefor every table a query names, so a changed migration re-runs the tests that use its tables instead of every test that touches the database. - Edge. Subscribes to the compiler and calls
linkSourcefor every template rendered, so a changed template re-runs the tests that render it — layouts, partials and mail templates each on their own. This is whyresources/viewsis not in the watch list above: a glob would send every view change at the whole suite. - Inertia. Records the page each test rendered, and asks the project's own bundler what that page imports, so a changed component re-runs the tests whose pages reach it. Same reason
inertia/is not a watch pattern.
Options
tia({
provider: 'coverage', // 'coverage' | 'imports'
granularity: 'file', // 'file' | 'test'
storage: 'local', // 'local' | 'home' | a path
include: ['**/*.{js,ts}'], // what may be recorded as a dependency
exclude: ['generated/**'], // merged with the built-in exclusions
watch: [ // files coverage cannot see
{ files: 'database/migrations/**', tests: 'tests/functional/**' },
{ files: 'resources/views/**', tests: 'tests/browser/**' },
],
fullRunOn: ['start/**', 'providers/**'],
invalidateOn: ['package-lock.json', 'tsconfig.json'],
share: { // see "Sharing one baseline across the team"
from: { github: { workflow: 'tia-baseline.yml' } },
maxAge: 86400, // refetch once the local baseline is this old
retryAfter: 86400, // back off this long after a failed fetch
},
replayResults: false, // report cached results for skipped tests
presets: ['adonisjs'], // bundled watch patterns and full-run triggers
migrations: { files: 'database/migrations/**' },
replayRecording: 'static', // 'static' | 'full' | 'off'
useGit: 'auto', // git fast path for change detection
normalize: true, // ignore comment-only and formatting edits
enabled: undefined, // force on/off, ignoring --tia
allowCI: false,
silent: false,
})granularity: 'test' refines individual tests inside an impacted file rather than running the whole file. It costs a coverage snapshot around every test instead of one per file, so it is meaningfully slower to record; worth it for large spec files, not for small ones. A test the baseline has never seen always runs.
storage: 'home' keeps the baseline in ~/.japa/tia/<project-key>, keyed off the normalised git remote, so several worktrees of the same repository share one graph. The default, local, writes to node_modules/.cache/japa-tia and is gitignored for free.
normalize strips whole-line comments and collapses whitespace before hashing JavaScript and TypeScript, so reformatting a file or editing a comment does not re-run its tests. It is a regex, not a parser — a line inside a template literal that begins with // would be stripped, hiding a change. Turn it off if your code does that. Comments sharing a line with code are never stripped, so those still count as changes.
useGit decides how changed files are found. Asking git costs a fixed 20-50ms in subprocesses; hashing costs about 0.01ms per file. Measured, git wins above roughly three thousand tracked files and loses below it, so 'auto' picks by size. Either way git is only a fast path — anything it cannot vouch for, including gitignored files, is hashed.
watch is the escape hatch for everything V8 cannot observe: SQL migrations, Edge templates, JSON fixtures, .env files. Coverage only sees JavaScript, so those relationships have to be declared.
AdonisJS
Suites are narrowed independently, so the standard layout works as is, and the preset covers the rest:
configure({
suites: [
{ name: 'unit', files: ['tests/unit/**/*.spec.ts'] },
{ name: 'functional', files: ['tests/functional/**/*.spec.ts'] },
],
plugins: [tia({ presets: ['adonisjs'] })],
})The coverage provider is the right choice here: services resolved through the IoC container are invisible to a static import graph but plainly visible to coverage.
The preset also connects Lucid and Edge, so migrations and templates map onto the tests that actually use them with no hook of your own. Four things that costs, all scoped to a recording run and undone at the end:
debugis turned on for every registered connection, because that is the flag Lucid'sdb:queryevent is gated behind. Knex's own logger stays off — Lucid hands itdebug: falseregardless — and the previous value is restored when the run finishes.- Only queries issued through Lucid are seen. That is deliberate. Knex emits everything, including the truncation your test utilities run between tests, which would attribute the entire schema to every test that touched the database. Anything the application runs under the ORM — a raw Knex call through
getWriteClient()— stays invisible, so declare those withlinkTableyourself. - Edge's template cache is turned off. A cached template compiles once per process, which would attribute every template to whichever test rendered it first and leave the rest watching nothing. AdonisJS already disables the cache outside production; the preset makes it certain, and restores the setting afterwards.
- A template is recorded when it compiles, so a page never rendered by any test is not in the graph. The thing that renders it changed too, which is what selects the test.
- The frontend graph is built once per recording run, with
rolldown— which Vite ships, so a Vite project already has it — and the aliases come fromvite.config.*through Vite's ownresolveConfig. Measured at about 340 ms for 36 pages and 86 modules, and skipped entirely on a downgraded replay, where those edges are already in the baseline. Without a bundler to build it, any rendered page falls back to watching the whole frontend directory.
Sharing one baseline across the team
Recording is the only cost that cannot be optimised away, and it is the same work for everyone. So do it once, in CI, and let everybody else download the result. A developer who has never run the suite gets a warm baseline on their first run instead of a full recording pass.
Publish it from a scheduled job:
# .github/workflows/tia-baseline.yml
name: tia-baseline
on:
schedule: [{ cron: '0 3 * * *' }]
push: { branches: [main] }
workflow_dispatch:
jobs:
baseline:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
- run: node bin/test.js --tia --tia-fresh --tia-export baseline.json
- uses: actions/upload-artifact@v4
with:
name: japa-tia-baseline
path: baseline.json--tia-export is the one case where the plugin will run on CI without allowCI, since publishing a baseline is exactly what that job is for.
Consume it:
tia({
share: {
from: { github: { workflow: 'tia-baseline.yml', branch: 'main' } },
maxAge: 24 * 60 * 60,
},
})The download goes through the gh CLI, so the developer's existing GitHub auth is reused and no token has to be configured. A baseline is fetched when there is no local one or the local one has gone stale; after a failed fetch the plugin backs off for retryAfter (24 hours by default) so an unreachable source costs one slow run rather than every run. A fetch that fails is never fatal — it just means recording locally.
Not on GitHub Actions? Any command works, as does a path:
share: { from: { command: 'aws s3 cp s3://ci-cache/tia.json "$JAPA_TIA_BASELINE"' } }
share: { from: { file: '/mnt/shared/tia-baseline.json' } }A shared baseline is portable because every path in it is project-relative and every comparison is content-based: a different checkout directory, a different commit, and a different Node version all still work. The fingerprint that invalidates a baseline deliberately contains nothing machine-specific — only the lockfiles, tsconfig.json and the provider.
Running the suite on CI
Don't use impact analysis for the pipeline that gates your merges. Without --tia-export or allowCI: true the plugin refuses to run when process.env.CI is set, and says so. A pipeline exists to validate the whole suite, and a graph is a cache — exactly the thing you do not want between a change and its verdict.
Limitations
- Compiled TypeScript. Run against your sources, through Node's built-in type stripping or a loader like
tsx, so recorded paths are the files you edit. Pointed at atscbuild directory the graph records build output, and editing a source file changes nothing it is watching. - Async leakage. Work that outlives the test that started it — a pending timer, a pooled connection — is attributed to whichever file was running when it finally executed. That over-selects rather than under-selects.
- Stale edges accumulate. Replays merge rather than prune, so a dependency that a test no longer reaches keeps selecting it until the next full run. That over-selects, never under-selects.
- New source files. A brand new module nothing imports yet is not in the graph. In practice whatever imports it changed too, which is what triggers the run.
- Test-level granularity keys on titles. Two tests with the same title in one file share an entry, so both run when either is impacted.
Baseline format
node_modules/.cache/japa-tia/baseline.json, all paths relative and posix:
{
"version": 1,
"fingerprint": "…", // invalidation inputs
"provider": "coverage",
"head": "…", // commit the baseline was recorded against
"graph": { // test file -> source files
"tests/mul.spec.ts": ["src/add.ts", "src/mul.ts", "tests/mul.spec.ts"]
},
"tests": { // only at test granularity
"tests/mul.spec.ts": { "multiplies": ["src/add.ts", "src/mul.ts"] }
},
"tables": { // recorded through linkTable
"tests/users.spec.ts": ["users"]
},
"results": { // for replayResults
"tests/mul.spec.ts": { "multiplies": { "duration": 4 } }
},
"hashes": { "src/add.ts": "…" },
"failed": ["tests/add.spec.ts::adds"]
}The impact calculation is exported if you want to build on it:
import { computeImpact, readBaseline, resolveStorageDir } from '@mucolabs/japa-tia'Compared with Pest
Pest's Tia engine is the closest equivalent in another ecosystem, so it is the useful thing to measure against. The two line up on most of it: recording, replaying, watch patterns, full-run triggers, fingerprint invalidation, content-normalised hashing, git change detection, failed-test retry, per-test granularity, migration-to-table mapping, template-to-test mapping, frontend module graphs, framework presets, and CI baseline sharing.
Three things Pest does are deliberately absent here:
- Coverage replay. Pest merges cached coverage into a partial run so
--coveragestill reports the whole project. Japa has no coverage integration to merge into; usec8over a full run. - Parallel workers. Pest coordinates a baseline across
--parallelprocesses. Japa runs in one process. - Architecture tests. No Japa equivalent exists.
Both reach for a JavaScript bundler to map the frontend, and where they differ is worth a note: Pest has to shell out to Node from PHP and cache the result, fingerprinted on the Vite config — which does not change when an import does. This plugin already runs in Node, so it calls rolldown in the same process and rebuilds on every recording run, and the map is never stale.
Prior art and credits
Test impact analysis as a technique is described in Martin Fowler's The Rise of Test Impact Analysis, and the safety posture here — always over-select, never under-select — follows it.
Inspired by Pest's Tia engine, which worked out what this feature should feel like day to day. Compared with Pest sets out how the two line up.
Plugin registration, option shapes and the layout of this README follow the conventions of @japa/api-client and Japa's other first-party plugins.
Adjacent tools worth knowing about, solving the same problem at a different layer: jest --onlyChanged and --changedSince select by git diff plus the static import graph, and nx affected and Turborepo do it at the package boundary in a monorepo rather than at the file boundary inside one.
Development
npm install
npm test # unit tests plus end-to-end runs against generated fixtures
npm run buildLicense
MIT — see LICENSE.
