# How to Run Test Impact Analysis in Maven Without Breaking JaCoCo Code Coverage

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](https://github.com/baokhang83/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:

```plaintext
[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:

```xml
<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:

   ```xml
   <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>
   ```
3. On `main`, run your normal `mvn verify`. The first run tracks and saves `.blastradius/`.
4. Persist `.blastradius/` with your CI cache between runs.
5. **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.
6. On pull requests, run the same command. Blastradius selects tests from the saved index.
7. 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](https://baokhang.hashnode.dev/how-we-cut-maven-junit5-test-runs-61-percent-apache-repos).

*Disclosure: I maintain blastradius.*
