Test impact analysis (TIA) and code coverage both want the same thing from your build: a seat on the test JVM's command line. Coverage tools attach a Java agent through argLine. Many TIA tools do too. When two agents, or two plugins writing the same property, meet in one argLine, the usual outcomes are a corrupted jacoco.exec, an empty report, or a team quietly turning coverage off on pull requests.

This post walks through how blastradius avoids that on pull requests, where the one setup that does collide on the base branch is, and the one-property fix. Everything here comes from a small fixture I built and ran, so the results are reproducible rather than asserted.

Where the collision comes from

JaCoCo's prepare-agent goal writes a -javaagent: option into a Maven property, argLine by default. Surefire then reads that property when it forks the test JVM. A TIA tool that records which classes each test loads needs its own agent in the same fork, so both want to write argLine.

Where they disagree is when each one needs it:

  • Recording dependencies needs the agent once, on a trusted baseline build.
  • Using the recording needs no agent at all. It only needs to decide which tests to run.

Blastradius is built around that split.

TRACK on the base branch, SELECT on pull requests

TRACK (base branch). The select goal forks a nested mvn clean test with the dependency-recording agent appended to argLine. During that run the agent records, for each test, the production classes it loaded. The result is saved as an index under .blastradius/. This is the only point where blastradius adds a Java agent.

SELECT (pull requests). Blastradius diffs the commit against the Git merge base, compares the changed classes with the saved index, and narrows Surefire with the standard test filter, the same property -Dtest= sets. If nothing is selected it sets skipTests. It does not touch argLine, so your JaCoCo agent, your custom flags, and any other tooling run exactly as they do in a normal build.

That is the part worth emphasising: on the builds developers wait for, the only change is which tests run, not how the test JVM is launched.

What I measured

The fixture is one Maven module with six small utility classes and 60 two-test JUnit 5 classes (120 tests). Each test class exercises exactly one utility class. Tooling: Maven 3.9.12, JDK 25, Surefire 3.5.2, JaCoCo 0.8.13, JUnit 5.10.2, blastradius 0.4.0. It is synthetic on purpose, so treat the numbers as an illustration of behavior, not a benchmark.

Does base-branch tracking survive JaCoCo?

I ran the TRACK build on main with four configurations:

Setup JaCoCo Surefire argLine Tracking result
A none default index built
B prepare-agent (defaults) default (not set) index built, JaCoCo 6/6 lines
C prepare-agent (defaults) <argLine>@{argLine} -Dcustom.flag=on</argLine> tracking failed, no index
D prepare-agent with propertyName=jacocoArgLine <argLine>@{jacocoArgLine} ${argLine} -Dcustom.flag=on</argLine> index built, JaCoCo 6/6 lines

Setup C is the one to watch, because it is also the most common way people write a JaCoCo-plus-custom-flags configuration. With it, blastradius logs:

[WARNING] [blastradius] tracking failed; discarding its incomplete dependency data and falling back to the full suite 

The build still passes and the tests still run, so nothing breaks loudly. But no index is saved, which means every pull request build afterwards has nothing to select against and runs the full suite. You keep your coverage and lose the speedup, which is easy to miss.

Why setup C fails

In the forked tracking build, blastradius passes its agent on the command line as -DargLine=.... JaCoCo's prepare-agent reads and writes the project property argLine, which does not include that command-line value. It sets argLine to the JaCoCo agent alone, and an explicit <argLine>@{argLine} ...</argLine> in Surefire then resolves to exactly that. The tracking agent never reaches the test JVM, nothing is recorded, and blastradius correctly refuses to trust an empty recording.

(I confirmed this by running the same nested command by hand: the build logged argLine set to -javaagent:...jacoco... with no tracking agent in it, and no records were written.)

The fix: give JaCoCo its own property

Setup D separates the two concerns. JaCoCo writes to its own property, and your Surefire argLine lists it explicitly next to whatever -DargLine the build was given:

<properties> <!-- default so ${argLine} resolves in a normal build --> <argLine></argLine> </properties> <build> <plugins> <plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>0.8.13</version> <executions> <execution> <goals><goal>prepare-agent</goal></goals> <configuration> <propertyName>jacocoArgLine</propertyName> </configuration> </execution> <execution> <id>report</id> <phase>verify</phase> <goals><goal>report</goal></goals> </execution> </executions> </plugin> <plugin> <artifactId>maven-surefire-plugin</artifactId> <version>3.5.2</version> <configuration> <argLine>@{jacocoArgLine} ${argLine} -Dcustom.flag=on</argLine> </configuration> </plugin> </plugins> </build> 

In a normal build ${argLine} is the empty default. In blastradius's tracking build it carries the tracking agent. JaCoCo's agent and the tracking agent both end up in the fork. If you do not set a custom argLine at all (setup B), the default behavior already works and you need no change.

What coverage looks like on a pull request

Here is the part to plan for. On a pull request, blastradius runs fewer tests, so JaCoCo reports coverage for fewer tests. The report is valid, not corrupted, but it only reflects what ran.

I measured it on the fixture with a one-line change to MathUtil, using setup D:

Run Tests run JaCoCo lines covered
Full run on the PR (no blastradius) 120 6 / 6
blastradius SELECT on the PR 20 1 / 6 (MathUtil only)

The fixture is the extreme case, because each test touches only one class. Real suites overlap far more. But the principle holds in general:

  • Project-wide coverage percentages on a PR will drop if you run selection on that build. A jacoco:check rule with a project-wide threshold, evaluated on the PR build, can fail for a reason that has nothing to do with the change.
  • Coverage of the changed code is still meaningful, because the tests that load a changed class are the ones that get selected. In the fixture, the changed class MathUtil is fully covered. If you use a diff-coverage tool, point it at the changed lines.
  • Treat the base branch as the source of truth for overall coverage. Publish your coverage badge and project-wide gates from the full-suite build on main (the TRACK build is a full run), not from PRs.

If a team needs a project-wide coverage gate on every PR, that team should run the full suite for that gate and use selection only for the fast feedback build. That is a policy decision, not a limitation of the report.

A setup you can copy

  1. Use setup B (no explicit argLine) or setup D above for JaCoCo.
  2. Add the plugin:
 <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> 
  1. On main, run your normal mvn verify. The first run tracks and saves .blastradius/.
  2. Persist .blastradius/ with your CI cache between runs.
  3. Check that tracking succeeded: look for [blastradius] TRACK and the absence of tracking failed in the log. A failure is silent as far as the build status goes.
  4. On pull requests, run the same command. Blastradius selects tests from the saved index.
  5. Keep a regular full-suite run (daily is the recommendation) as a safety net, since selection is sound by default but not guaranteed.

FAQ

Why do test impact analysis tools conflict with JaCoCo?

Both need a Java agent in the forked test JVM, and both usually set it through the argLine property. If either overwrites the other's value, you lose one of the two. The collision is in how the property is written, not in the agents themselves.

Does blastradius change argLine on pull requests?

No. In SELECT mode it sets Surefire's test filter (the same property as -Dtest=), or skipTests when nothing needs to run. It only adds a Java agent in the forked tracking build on the base branch.

My Maven build passes but PRs still run every test. Why?

Check the base-branch log for tracking failed. If your Surefire <argLine> uses @{argLine} with JaCoCo's default prepare-agent, the tracking agent can be overwritten and no index is saved. Switching JaCoCo to its own propertyName as shown above fixes it.

Will my JaCoCo coverage report be correct on pull requests?

It will be valid but partial: it reflects the tests that ran. Project-wide percentages drop when fewer tests run, while coverage of the changed code stays meaningful. Run overall coverage and any project-wide jacoco:check gate on the base-branch full run.

Does this work with Failsafe and integration tests?

Selection narrows both Surefire and Failsafe. I only measured Surefire for this post, so check your integration-test setup with the same TRACK log check before relying on it.

Is selection safe?

It is sound by default, not a guarantee. Reflection, generated code and string-dispatched APIs can escape dependency tracking, so keep a regular full-suite run as a backstop. The first post in this series has the replay numbers: 61.4% fewer test executions across 800 commits, with the exclusions and caveats listed.

Disclosure: I am the author of blastradius.