All articles

// Knowledge.log — 技術記事

JaCoCo on Maven: the line-coverage gate that leaves the error branch untested

JaCoCo 0.8.15 / JDK 25: jacoco:check LINE 0.80 passes at 85.7% while the invalid-charge if never runs. BRANCH 0.80 fails at 0.50 on the same .exec.

The pipeline is green. jacoco:check requires 80% line coverage, the report shows 85.7%, and the merge goes through. But no test has ever reached the true side of the if that rejects a charge of zero or less. The HTML report shows the throw line in red, and nobody opens the HTML when the build is green.

JaCoCo isn't broken here. The LINE counter measures exactly what it says it measures: whether any instruction on that line ran. The if comparison is evaluated on every call, so the if line counts as covered. The branch you actually care about is the one that never runs.

The experiment is deliberately small: one Java SE class, one JUnit 6 test that only covers the happy path, and a POM with two profiles. With the gate set to LINE 0.80, the result is BUILD SUCCESS. With the gate set to BRANCH 0.80, the same code and the same test give BUILD FAILURE with a ratio of 0.50. The report's CSV and XML confirm both results.

Mutation testing with PIT already showed that 100% line coverage can still leave mutants alive. PIT isn't part of this one. The only subject here is jacoco:check with LINE and with BRANCH, run against the data recorded in the .exec file.

Versions: 0.8.15 from Central, not the docs snapshot

Versions were checked on 5 October 2026. The newest jacoco-maven-plugin on Maven Central (both latest and release) is 0.8.15, alongside org.jacoco.core 0.8.15. The mojo pages on jacoco.org/trunk show 0.8.16-SNAPSHOT because the documentation tracks trunk. That snapshot isn't GA, so it doesn't belong in the POM. Copy the coordinate from the docs without checking Central and you get a build that works only on machines that already have the snapshot cached.

The rest of the stack: JUnit Jupiter 6.1.3, maven-surefire-plugin 3.6.0, maven-compiler-plugin 3.16.0, Temurin 25.0.4 and Maven 3.9.12. For Java 25 (classfile 69), the JaCoCo changelog lists official support starting with 0.8.14. In 0.8.13 that support was still experimental, so 0.8.13 is out for the same reason the snapshot is.

The fixture: one class, one test, two profiles

The production class has one validation if and a few lines on the happy path:

package academy.devdojo.cobranca;

public final class CobrancaServico {
  public int cobrarEmCentavos(int centavos) {
    if (centavos <= 0) {
      throw new IllegalArgumentException("valor invalido");
    }
    int taxaFixa = 10;
    int valorComTaxa = centavos + taxaFixa;
    int descontoFidelidade = 1;
    return valorComTaxa - descontoFidelidade;
  }
}

The test is the kind that tends to show up when a feature ships in a hurry. It passes a positive amount, checks the result, and stops there:

package academy.devdojo.cobranca;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;

class CobrancaServicoTest {
  @Test
  void cobraValorPositivo() {
    assertEquals(109, new CobrancaServico().cobrarEmCentavos(100));
  }
}

cobrarEmCentavos(100) returns 109. No test passes zero or a negative value.

The POM runs the plugin's three usual goals:

  • prepare-agent is bound to initialize. It writes to target/jacoco.exec and sets argLine so Surefire starts with the agent attached.
  • report is bound to verify. It generates HTML, XML and CSV under target/site/jacoco/.
  • check is also bound to verify, and it reads the .exec file.

The rule takes the counter and the minimum from properties, so each profile can swap them:

<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <version>0.8.15</version>
  <executions>
    <execution>
      <id>prepare-agent</id>
      <goals><goal>prepare-agent</goal></goals>
    </execution>
    <execution>
      <id>report</id>
      <goals><goal>report</goal></goals>
    </execution>
    <execution>
      <id>check</id>
      <goals><goal>check</goal></goals>
      <configuration>
        <rules>
          <rule>
            <element>BUNDLE</element>
            <limits>
              <limit>
                <counter>${jacoco.check.counter}</counter>
                <value>COVEREDRATIO</value>
                <minimum>${jacoco.check.minimum}</minimum>
              </limit>
            </limits>
          </rule>
        </rules>
      </configuration>
    </execution>
  </executions>
</plugin>
<profiles>
  <profile>
    <id>line-gate</id>
    <properties>
      <jacoco.check.counter>LINE</jacoco.check.counter>
      <jacoco.check.minimum>0.80</jacoco.check.minimum>
    </properties>
  </profile>
  <profile>
    <id>branch-gate</id>
    <properties>
      <jacoco.check.counter>BRANCH</jacoco.check.counter>
      <jacoco.check.minimum>0.80</jacoco.check.minimum>
    </properties>
  </profile>
</profiles>

maven.compiler.release is 25, and JUnit comes in as junit-jupiter 6.1.3 with test scope. There's no Spring and no container. The build needs nothing beyond Maven Central.

LINE gate: green at 85.7%

mvn -B -e -Pline-gate verify
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
All coverage checks have been met.
BUILD SUCCESS

The exit code was 0, and the build finished at 2026-10-05T05:13:43Z. Here is the CSV row generated for the class:

GROUP,PACKAGE,CLASS,INSTRUCTION_MISSED,INSTRUCTION_COVERED,BRANCH_MISSED,BRANCH_COVERED,LINE_MISSED,LINE_COVERED,COMPLEXITY_MISSED,COMPLEXITY_COVERED,METHOD_MISSED,METHOD_COVERED
jacoco-line-branch,academy.devdojo.cobranca,CobrancaServico,5,17,1,1,1,6,1,2,0,2

That's 6 of 7 lines covered, a ratio of 0.8571, which clears 0.80. The only missed line is line 6, the throw. The XML shows the details line by line:

<line nr="5" mi="0" ci="2" mb="1" cb="1"/>
<line nr="6" mi="5" ci="0" mb="0" cb="0"/>

Line 5, the if, has both of its instructions covered (ci="2"), one branch covered (cb="1") and one branch missed (mb="1"). The counters page explains why the line still counts as covered: "A source line is considered executed when at least one instruction that is assigned to this line has been executed." In the HTML, that line gets the yellow diamond, which means "Only a part of the branches in the line have been executed". Line 6 has five instructions, and none of them ran.

So the LINE gate approved a throw that has never been thrown.

BRANCH gate: same test, red build

The code and the test stay the same. Only the profile changes:

mvn -B -e -Pbranch-gate verify
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[WARNING] Rule violated for bundle jacoco-line-branch: branches covered ratio is 0.50, but expected minimum is 0.80
[ERROR] Failed to execute goal org.jacoco:jacoco-maven-plugin:0.8.15:check (check) on project jacoco-line-branch: Coverage checks have not been met. See log for details.
BUILD FAILURE

The exit code was 1, and the build finished at 2026-10-05T05:13:47Z. The test is still green; the check is what fails. haltOnFailure defaults to true, so the violation breaks the build instead of ending up as a warning in the log.

The ratio is 0.50 because the whole bundle has exactly one binary if. The false side ran and the true side didn't, which makes 1 covered branch out of 2. According to the documentation, JaCoCo computes branch coverage "for all if and switch statements". That 1/2 is the yellow diamond on line 5.

Here are the bundle counters side by side:

CounterCovered / totalRatioAgainst a 0.80 minimum
LINE6 / 70.8571passes (executed)
BRANCH1 / 20.50fails (executed)
INSTRUCTION17 / 220.7727would fail (not run as a gate)

Pitfalls: INSTRUCTION, the .exec, excludes, and the 80% nobody configured

INSTRUCTION isn't LINE. The example in the check documentation uses INSTRUCTION with COVEREDRATIO and a minimum of 0.80. Copied unchanged into this fixture, that example would have failed at 77% while LINE passed at 86%. The five instructions in the throw count for more under INSTRUCTION than one line counts under LINE. It looks like luck on your side, but you can't count on it. Neither counter was designed to tell you whether a business branch ran. BRANCH is the counter that does that.

80% isn't a default. The 0.80 appears in the documentation example and nowhere else. A rule without a minimum has no numeric limit at all. You can write the value as 0.80 or 80%; they mean the same thing. If your team has 80% in the POM, someone on the team put it there.

The check reads the .exec, not the XML. The check's dataFile is target/jacoco.exec. The XML and CSV are reports for people and tools, and the gate never looks at them. Because the check is bound to verify, mvn test doesn't run the gate. Moving the check to a phase before the tests doesn't help either, because the .exec doesn't exist yet at that point.

includes/excludes on the check can hide the class. The check's filters remove class files from the analysis. An <exclude> that's too broad, added to keep generated DTOs out of the numbers, can take the billing package with it. The gate then stays green because it never looked at the class. Review the excludes as carefully as you review the minimum.

To keep an eye on this in CI, save target/site/jacoco/jacoco.xml and jacoco.csv as job artifacts, including when the build passes. The CSV's BRANCH_MISSED column and the XML's mb attribute point to the untested branch. A simple search for mb greater than zero in the classes that hold business rules shows you the yellow diamond without opening the HTML.

Limits of the experiment

This is one class, one method and one test, and the bundle has exactly one if. In a real project, a BRANCH minimum at BUNDLE level dilutes the problem the same way LINE does: one untested billing if among hundreds of covered branches still passes at 0.80. For classes where the error branch is the business rule, you need a narrower rule, either with the CLASS element or by tracking the mb attribute in the XML.

BRANCH has blind spots of its own. The documentation warns that "exception handling is not considered as branches", so a try/catch doesn't show up as a branch. And 100% BRANCH proves the code ran, not that the test checked the result. For that second question, see the PIT article. Build time and agent overhead weren't measured. The run was a functional check on JDK 25 against the bytecode javac produced for this source.

Recommendation

DevDojo wouldn't use a LINE-only jacoco:check as a merge gate for code whose important behavior lives inside an if: amount validation, authorization, credit limits, state transitions. In those cases the gate needs BRANCH. Otherwise the LINE ratio isn't measuring what the team thinks it measures.

LINE alone still works as a rough alarm for code without meaningful decisions, such as mapping, configuration or thin adapters, or as a floor that keeps coverage from collapsing. Revisit that choice if the artifact CSV shows BRANCH_MISSED above zero on a domain class while the build is green, or if someone proposes lowering the BRANCH minimum because it was getting in the way of merges.

Next step

Add a second limit to the same rule and keep the LINE one:

<limits>
  <limit>
    <counter>LINE</counter>
    <value>COVEREDRATIO</value>
    <minimum>0.80</minimum>
  </limit>
  <limit>
    <counter>BRANCH</counter>
    <value>COVEREDRATIO</value>
    <minimum>0.80</minimum>
  </limit>
</limits>

This combination wasn't run in the experiment. Run mvn verify and confirm the build fails with the same branches covered ratio is 0.50 message. Then write the missing test, something like assertThrows(IllegalArgumentException.class, () -> new CobrancaServico().cobrarEmCentavos(0)), and run it again. The build should pass, and line 5 in the XML should show mb="0". Check that in jacoco.xml rather than assuming it from the green build.

javatestingmavenjacoco

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles