Blastradius is an Apache-2.0 test impact analysis tool for JVM projects. It selects JUnit 5 tests potentially affected by Java and conventional Kotlin source changes, so CI can run a smaller test suite. The Maven plugin is published on Maven Central and the Gradle plugin on the Gradle Plugin Portal.
A Java agent records class dependencies observed during a base-branch test run. On a change
branch, Blastradius compares the Git merge base with HEAD and selects matching, new, or
modified tests, with additional conservative selection and fallback rules. Missing or unusable
baseline data triggers a full run. Selection has known limits; keep regular full-suite runs.
Get started: Maven and JUnit 5 · Gradle and pull requests · How test impact analysis works and compares · Validation and limitations
In the four historical replay windows below, the aggregate selected test executions were 61.4% fewer than full-suite executions. These are replay counts, not measured CI wall-clock savings. The bounded mutation experiment also found partial skips in commons-io; see the mutation table and footnotes before using these results to assess your own project.
200-pair replays against apache/shenyu, apache/httpcomponents-client, jhy/jsoup and apache/commons-io, each replaying 200 consecutive commits as the change each one introduced over the commit before it — with bounded mutation validation enabled on the same run. A would-miss is a test that caught a real regression or an injected mutant but that selection chose not to run.
| Project | Commit range | Commit pairs (excluded) | Would-miss | Test executions selected | Skipped |
|---|---|---|---|---|---|
69cd1d5 → 3a411e0 |
200 (0) | 01 | 153,142 / 527,508 | 71.0% | |
ef34bfa → 4dae8da |
200 (12) | 0 2 | 180,198 / 443,593 | 59.4% | |
b62e362 → 9d2241f |
200 (10) | 0 3 | 193,635 / 351,882 | 45.0% | |
d403237 → ffd9dda |
200 (12) | 0 4 | 439,393 / 1,178,122 | 62.7% | |
| 61.4% |
Bounded mutation validation ran on the same window: for each pair it injects synthetic faults into head and checks whether the tests selected catch them.
| Project | Mutants (compilable) | Mutants caught | Killing tests selected | Diff-targeted / fallback |
|---|---|---|---|---|
| 872 (778) | 339 | 943 / 943 | 686 / 257 | |
| 916 (916) | 551 | 75,380 / 75,380 | 3,571 / 71,809 | |
| 940 (931) | 606 | 53,300 / 53,300 5 | 49,073 / 4,227 | |
| 901 (901) | 850 | 30,637 / 31,648 6 | 4,469 / 27,179 |
A "killing test" is one that actually caught an injected fault (passed on head, failed on the mutant, stayed failed on confirmation), so it is a test the selection must not skip. Counts are per mutant, so a test that kills five mutants counts five times. Across shenyu's, httpcomponents-client's and jsoup's injected faults, selection never skipped a test that would have caught one; commons-io saw a 3.2% partial-skip rate on the fallback path (footnote 6) but still selected at least one killing test for every mutant it caught.
See HISTORICAL_REPLAY_ANALYSIS.md for footnotes 1–6 and the full per-project analysis behind these numbers.
- Track. On a build of your base branch, a
java.lang.instrumentagent watches every class actually loaded while each test runs and records which production classes it really touched — ground truth, not a guess. - Diff. On every other build, the current commit is diffed from its merge base with your base reference. This isolates the PR's own JVM source changes (Java and conventional Kotlin) from changes that landed on the target branch after the PR diverged.
- Select. A test runs if one of its tracked dependencies changed, it's new or was itself modified, an executed class reaches a changed class through one or two recorded static invocation edges, or a non-source change triggered the conservative "just run everything" fallback.
- Gate. The selection narrows Surefire/Failsafe via the standard
-Dtest=filter — nothing exotic, nothing that fights JaCoCo or a customargLine.
| Module | What it is | Status |
|---|---|---|
blastradius-core |
The shared engine — the dependency-tracking agent and the selection rules (dependency match, conservative fallback, always-select-new/modified). Built and proven first; reused unmodified by both modules below. | Complete, 118 tests |
blastradius-maven-plugin |
Maven plugin providing the blastradius:select goal. See its README for configuration and CI setup. |
Published on Maven Central |
blastradius-gradle-plugin |
Gradle integration for Java test tasks. | Published on the Gradle Plugin Portal (0.4.0) |
blastradius-validator |
The shadow-mode harness that produced the real-project numbers above — replays a project's own commit history, compares what would have been skipped against ground truth, and reports would-miss cases. Still here if you want to validate the mechanism against a project of your own before adopting the plugin. | Complete, 170 tests |
<plugin>
<groupId>io.github.baokhang83.blastradius</groupId>
<artifactId>blastradius-maven-plugin</artifactId>
<version>0.4.0</version>
<executions>
<execution>
<phase>process-test-classes</phase>
<goals><goal>select</goal></goals>
</execution>
</executions>
<configuration>
<baseRef>main</baseRef>
</configuration>
</plugin>For separate CI runners, first persist and restore the workspace's .blastradius/ directory
with your CI cache. S3 is optional; use these Maven settings only when an S3-compatible shared
store is a better fit for your runners:
<indexStore>s3</indexStore>
<s3Bucket>ci-dependency-indexes</s3Bucket>
<s3Prefix>blastradius</s3Prefix>
<s3Region>eu-central-1</s3Region>
<!-- <s3Endpoint>https://minio.example.com</s3Endpoint> optional -->The plugin is published on the Gradle Plugin Portal:
plugins {
id 'io.github.baokhang83.blastradius' version '0.4.0'
}
blastradius {
baseRef = 'main'
}See the Gradle guide.
There is no separate Gradle select task. Applying the plugin configures every Java Test
task, so run the normal ./gradlew test: it tracks on main and selects the relevant tests on
other branches when a saved index is available.
For separate CI runners, first persist and restore .blastradius/ with your CI cache. S3 is
optional; configure the same shared S3 index store only when needed:
blastradius {
baseRef = 'main'
indexStore = 's3'
s3Bucket = 'ci-dependency-indexes'
s3Prefix = 'blastradius'
s3Region = 'eu-central-1'
// s3Endpoint = 'https://minio.example.com' // optional
}No other change required — Surefire/Failsafe stay configured exactly as they already are.
See blastradius-maven-plugin/README.md for the full
configuration reference, what each build mode (TRACK/SELECT/FALLBACK) prints, and how
to set it up in CI.
Format-3 indexes also retain direct method-owner references for project classes that a test
actually executed. This can recover a dependency hidden when a cached string API skips its parser,
but it is intentionally class-level conservative and may select an extra test. It is on by
default: a fresh format-3 index uses this safety net automatically. Disable it for a comparison
with Maven -Dblastradius.directInvocationFallback=false, or Gradle
directInvocationFallback = false in the blastradius block. Explain output names both the
executed source class and changed target for every such selection.
By default, indexes stay under the workspace's .blastradius/ directory. That directory is a
saved map of which production classes each test used. A trunk TRACK job writes the map; a PR
SELECT job restores it, compares the PR's changed classes to it, and runs only matching tests.
On fresh CI workers, preserve .blastradius/ between the trunk and PR jobs with the CI cache
alongside your usual Maven dependency cache. S3 is not required. It is an alternative when
your runners cannot share a reliable cache: configure the Maven plugin or Gradle extension with
indexStore = s3, a bucket, and a region. The shared object store lets the PR job read the same
commit-keyed index that the trunk job wrote. Credentials come from the standard AWS credential
chain; do not put access keys in build files. If no saved index can be restored — for example,
on a first build or cache miss — Blastradius safely runs the full suite instead. See the
Maven S3 configuration reference.
In the workflow that runs Blastradius-enabled tests, put the restore step after checkout and
before mvn verify or ./gradlew test. Save only successful main builds, after the test
command, so pull requests always read a map made by the trusted base branch:
steps:
- uses: actions/checkout@v7
- name: Restore Blastradius index
id: blastradius-index
uses: actions/cache/restore@v4
with:
path: .blastradius
key: blastradius-index-${{ runner.os }}-${{ github.sha }}
restore-keys: |
blastradius-index-${{ runner.os }}-
- run: mvn -B --no-transfer-progress verify
- name: Save Blastradius index from main
if: ${{ github.ref == 'refs/heads/main' && success() && steps.blastradius-index.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v4
with:
path: .blastradius
key: blastradius-index-${{ runner.os }}-${{ github.sha }}The key includes the commit SHA so a main build saves an immutable snapshot. A PR has a new
SHA, so its exact lookup misses; restore-keys then restores the newest compatible
main snapshot. Do not put credentials or other secrets under .blastradius/ because GitHub
Actions caches are readable by pull-request workflows.
This repository self-hosts Blastradius in its own Maven workflow: CI first builds and installs
the plugin from the checkout, then runs the normal reactor with an internal CI-only Maven
profile. Successful main runs refresh the cached index; pull requests restore it and use the
same plugin code under review to select tests. The bootstrap and multi-module reporting mechanics
are intentionally kept in the workflow and feature design, not the adoption quick start above.
git clone https://github.com/baokhang83/blastradius.git
cd blastradius
mvn clean install # builds and tests all three modulesFully supported, without extra bookkeeping. Because tracking is based on actual class loads rather than a static per-module dependency graph, a change in one module correctly selects a dependent test living in another module — attribution falls out of the mechanism itself.
Blastradius recognizes conventional Kotlin source roots — src/main/kotlin and
src/test/kotlin — alongside their Java equivalents. A changed Greeting.kt contributes both
the ordinary Greeting name and Kotlin's generated GreetingKt file facade; recorded nested
or lambda classes such as GreetingKt$format$1 are attributed to that stable source root.
Kotlin inline functions are deliberately conservative. Their bodies are copied into callers, so there may be no stable class load to attribute to the changed source file. If either side of a Kotlin change contains an inline function, Blastradius runs the full suite instead of narrowing.
Custom @file:JvmName facades and Kotlin source files whose emitted class names do not follow
their file names are outside this filename-based mapping. Keep the recommended regular full-suite
run for those projects and for any other compiler-generated edge case.
The selection mechanism is sound by default, not by absolute guarantee — see the real-project numbers above for what "sound by default" has actually measured out to. We recommend every adopting team also run their full test suite portfolio on a regular cadence (recommended: daily) as a complementary safety net, so even an occasional gap is caught within a day rather than never. That combination — fast, sound-by-default selection on every build, backstopped by a full run — is the intended trust model, not either one alone.
- A class reached only through a string-dispatched API can still be unattributed when it is not
reachable within the bounded two-edge static-invocation fallback. The focused jsoup
QueryParserreplay recovered its observed two-edge paths, but reflection, generated calls, incomplete instrumentation, and deeper paths remain outside that evidence boundary. This bites hardest where the central API is a string DSL: selector engines, expression languages, query parsers. If that describes your project, weigh the daily full-suite run accordingly. - Refreshing the dependency index (a "track" build) runs the full suite once; correct, but not optimized for very slow suites. It only happens on base-reference builds, never on every PR build.
blastradius-core/ the engine: tracking agent + selection rules
blastradius-maven-plugin/ the product: the blastradius:select goal
blastradius-validator/ shadow-mode validation harness (real-project evidence above)
specs/ spec, plan, research (ADR-style), contracts, tasks — per feature
.specify/memory/ project constitution
SESSION.md narrative log of how T061's real-project validation went
- How We Cut Maven & JUnit 5 Test Runs by 61% Across Apache Repos Without Missing Regressions — Hashnode article walking through the historical replay on shenyu, httpcomponents-client, jsoup and commons-io, including what was excluded and where selection fell short.
- Why Maven Build Cache Still Re-Runs Your Entire Test Suite on One-Line Edits — Why the Maven Build Cache Extension re-runs a whole module's JUnit 5 tests after a one-line change, and how class-level selection narrows it.
- How to Run Test Impact Analysis in Maven Without Breaking JaCoCo Code Coverage —
How tracking and selection coexist with JaCoCo, including the
argLinesetup that disables tracking and its fix. - Predictive Test Selection Without SaaS Lock-In: Storing TIA Indexes in GitHub Actions Cache or S3 — A self-hosted CI setup that keeps the index in the GitHub Actions cache or your own S3 bucket.
