Maven JaCoCo code coverage
last modified November 13, 2026
In this article we measure the code coverage of unit tests with JaCoCo. We generate coverage reports, read them, enforce minimum coverage with rules, and fix a common configuration problem with Surefire. The examples use Maven 3.10, JaCoCo 0.8.16, JUnit 6, and Java 25.
Code coverage tells us which parts of the code were executed by the tests. JaCoCo is a coverage library for Java. It works as a Java agent: when the test JVM loads a class, the agent instruments its bytecode on the fly and records which instructions and branches were executed. At the end, the agent writes the data into an execution data file, from which reports are generated. The sources and the compiled classes are not changed.
The project
Our project computes discounts in a shop.
package com.example.shop;
import java.math.BigDecimal;
import java.math.RoundingMode;
public class Discount {
public static int percent(BigDecimal total, boolean member) {
if (total.signum() < 0) {
throw new IllegalArgumentException("negative total: " + total);
}
int percent = 0;
if (total.compareTo(new BigDecimal("100")) >= 0) {
percent = 5;
}
if (total.compareTo(new BigDecimal("500")) >= 0) {
percent = 10;
}
if (member) {
percent += 3;
}
return percent;
}
public static BigDecimal apply(BigDecimal total, boolean member) {
BigDecimal rate = BigDecimal.valueOf(100 - percent(total, member), 2);
return total.multiply(rate).setScale(2, RoundingMode.HALF_UP);
}
}
The discount depends on the order total and on the membership of the
customer. The if statements give the code several branches,
which makes the coverage interesting.
package com.example.shop;
import java.math.BigDecimal;
public class Receipt {
public static String format(BigDecimal total, boolean member) {
BigDecimal toPay = Discount.apply(total, member);
return String.format("total %s, discount %d%%, to pay %s",
total, Discount.percent(total, member), toPay);
}
public static void main(String[] args) {
System.out.println(format(new BigDecimal(args[0]), args.length > 1));
}
}
The Receipt class formats a receipt and contains the
main method of the application.
package com.example.shop;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DiscountTest {
@Test
void noDiscountForSmallOrders() {
assertEquals(0, Discount.percent(new BigDecimal("40"), false));
}
@Test
void discountForLargeOrders() {
assertEquals(10, Discount.percent(new BigDecimal("650"), false));
}
@Test
void appliesDiscount() {
assertEquals(new BigDecimal("585.00"), Discount.apply(new BigDecimal("650"), false));
}
}
The tests check small and large orders. Members, totals between 100 and
500, negative totals, and the Receipt class are not tested.
Configuring the plugin
JaCoCo is enabled with two goals of the jacoco-maven-plugin.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>shop</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>25</maven.compiler.release>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>6.1.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.1</version>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.16</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
The prepare-agent goal binds to the initialize
phase by default, and the report goal to the
verify phase.
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
The prepare-agent goal does not run anything. It sets the
argLine property to a -javaagent option that
loads the JaCoCo agent. Surefire passes the value of argLine
to the test JVM, so the agent is attached to the tests.
<execution>
<id>report</id>
<goals>
<goal>report</goal>
</goals>
</execution>
The report goal reads the execution data and generates
reports for the classes of the project.
Generating the report
We run the build up to the verify phase.
The prepare-agent goal sets the argLine property;
the agent writes the coverage data to target/jacoco.exec. The
report goal creates the report in the
target/site/jacoco directory in three formats: HTML for
people, and XML and CSV for tools. CI servers and services such as
SonarQube read the XML report. The CSV report gives a quick overview; we
select the columns for branches and lines. No line of
Receipt was executed, and three lines and two branches of
Discount were missed.
$ mvn verify --- jacoco:0.8.16:prepare-agent (prepare-agent) @ shop --- argLine set to -javaagent:/home/user/.m2/repository/org/jacoco/org.jacoco.agent/0.8.16/org.jacoco.agent-0.8.16-runtime.jar=destfile=/home/user/project/shop/target/jacoco.exec Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.095 s -- in com.example.shop.DiscountTest --- jacoco:0.8.16:report (report) @ shop --- Loading execution data file /home/user/project/shop/target/jacoco.exec Analyzed bundle 'shop' with 2 classes BUILD SUCCESS $ ls target/site/jacoco com.example.shop index.html jacoco.csv jacoco-resources jacoco-sessions.html jacoco.xml $ cut -d, -f3,6-9 target/site/jacoco/jacoco.csv | column -t -s, CLASS BRANCH_MISSED BRANCH_COVERED LINE_MISSED LINE_COVERED Receipt 2 0 6 0 Discount 2 6 3 10
Reading the report
The HTML report starts in target/site/jacoco/index.html. It
shows the coverage per package, class, and method, and displays the source
code with highlighted lines. Green lines (fc) were fully
covered, yellow lines (pc) partly covered, and red lines
(nc) not covered. A diamond next to a line with branches says
how many branches were missed. We extract this information from the HTML
source of the Discount page.
Line 9 is partly covered, because no test passed a negative total, so line 10 never ran. Line 19 misses one branch: no test used a member, so line 20 never ran. Line 6 is the class declaration; it stands for the implicit default constructor, which no test calls. JaCoCo counts the following metrics: instructions, branches, lines, cyclomatic complexity, methods, and classes. Instructions and branches are the most precise, because they do not depend on how the source code is formatted.
$ grep -oE '<span class="[^"]+" id="L[0-9]+"( title="[^"]+")?' target/site/jacoco/com.example.shop/Discount.java.html <span class="nc" id="L6" <span class="pc bpc" id="L9" title="1 of 2 branches missed." <span class="nc" id="L10" <span class="fc" id="L12" <span class="fc bfc" id="L13" title="All 2 branches covered." <span class="fc" id="L14" <span class="fc bfc" id="L16" title="All 2 branches covered." <span class="fc" id="L17" <span class="pc bpc" id="L19" title="1 of 2 branches missed." <span class="nc" id="L20" <span class="fc" id="L22" <span class="fc" id="L26" <span class="fc" id="L27"
Enforcing coverage
The check goal fails the build when the coverage does not meet
the rules. It binds to the verify phase.
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
<rule>
<element>CLASS</element>
<limits>
<limit>
<counter>BRANCH</counter>
<value>MISSEDCOUNT</value>
<maximum>0</maximum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
A rule applies to an element: BUNDLE (the whole project),
PACKAGE, CLASS, SOURCEFILE, or
METHOD. Each limit names a counter (INSTRUCTION,
LINE, BRANCH, COMPLEXITY,
METHOD, or CLASS), a value
(TOTALCOUNT, COVEREDCOUNT,
MISSEDCOUNT, COVEREDRATIO, or
MISSEDRATIO), and a minimum or a maximum. Our first rule
requires 80% of the lines of the project to be covered; the second one
does not allow any missed branches in any class.
The build fails and lists all violated rules. The line coverage of the project is 52%, and both classes miss two branches.
$ mvn verify --- jacoco:0.8.16:check (check) @ shop --- Rule violated for bundle shop: lines covered ratio is 0.52, but expected minimum is 0.80 Rule violated for class com.example.shop.Receipt: branches missed count is 2, but expected maximum is 0 Rule violated for class com.example.shop.Discount: branches missed count is 2, but expected maximum is 0 BUILD FAILURE Coverage checks have not been met. See log for details.
Excluding classes
Some classes do not need to be measured, for example generated code or the
entry point of the application, which only parses its arguments. The
excludes parameter of the plugin removes classes from the
reports and the checks.
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.16</version>
<configuration>
<excludes>
<exclude>com/example/shop/Receipt.class</exclude>
</excludes>
</configuration>
The patterns refer to class files in the output directory, so they use
slashes and the .class extension. Because the configuration
is placed at the plugin level, all executions use it.
package com.example.shop;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class DiscountEdgeTest {
@Test
void memberDiscount() {
assertEquals(8, Discount.percent(new BigDecimal("100"), true));
}
@Test
void rejectsNegativeTotal() {
assertThrows(IllegalArgumentException.class,
() -> Discount.percent(new BigDecimal("-1"), false));
}
}
To cover the remaining branches, we add tests for members and for negative totals.
After the exclusion, the bundle has one class, and its line coverage is
76%. The new tests cover all branches of Discount, and all
coverage checks are met.
$ mvn verify Analyzed bundle 'shop' with 1 classes Analyzed bundle 'shop' with 1 classes Rule violated for bundle shop: lines covered ratio is 0.76, but expected minimum is 0.80 Rule violated for class com.example.shop.Discount: branches missed count is 2, but expected maximum is 0 BUILD FAILURE $ mvn verify Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 Analyzed bundle 'shop' with 1 classes Analyzed bundle 'shop' with 1 classes All coverage checks have been met. BUILD SUCCESS
Surefire and argLine
A frequent problem appears when the project configures JVM options for its tests.
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.1</version>
<configuration>
<argLine>-Xmx256m</argLine>
</configuration>
</plugin>
The argLine parameter of Surefire is set to a fixed value,
which replaces the argLine property set by JaCoCo.
<properties>
<maven.compiler.release>25</maven.compiler.release>
<argLine></argLine>
</properties>
We define an empty argLine property. It is used when JaCoCo
does not set the property, for example when the plugin is moved into a
profile that is not active. Without the default, Surefire would pass the
unresolved expression to the JVM, which would fail to start.
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.1</version>
<configuration>
<argLine>@{argLine} -Xmx256m</argLine>
</configuration>
</plugin>
The parameter now includes the property with the @{argLine}
syntax. Surefire replaces this expression when it starts the test JVM,
after prepare-agent has set the property. The usual
${argLine} syntax is replaced by Maven when it loads the POM,
when the property still has its empty default, so the agent would be lost
again.
With the hard-coded argLine, the agent is not attached, no
execution data is written, and JaCoCo silently skips the report; the build
succeeds without coverage. With the @{argLine} expression, the
agent is attached and the report is generated again.
$ mvn verify --- jacoco:0.8.16:report (report) @ shop --- Skipping JaCoCo execution due to missing execution data file. BUILD SUCCESS $ mvn clean verify --- jacoco:0.8.16:report (report) @ shop --- Analyzed bundle 'shop' with 2 classes BUILD SUCCESS
Integration tests and multi-module projects
JaCoCo can also measure the coverage of
integration tests. The
prepare-agent-integration goal sets the argLine
property for Failsafe and writes the data into
jacoco-it.exec, and the report-integration goal
creates a separate report. The merge goal combines several
execution data files.
In a multi-module project, every module
gets its own report. The report-aggregate goal, placed in a
module that depends on all other modules, creates one report that includes
the coverage of code in one module by the tests of another module.
Best practices
- Pin the version of the plugin; new versions of JaCoCo are required for new Java class file versions.
- Use
@{argLine}when the tests need their own JVM options. - Prefer branch coverage to line coverage in rules; it says more about the quality of the tests.
- Set thresholds that reflect the current state and raise them over time, rather than an arbitrary high number.
- Treat coverage as a way to find untested code, not as a goal; covered code is not necessarily well tested code.
Source
In this article we have measured code coverage with JaCoCo, read its
reports, enforced coverage rules, and fixed the argLine
configuration of Surefire.
Author
List all Java tutorials.