ZetCode

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.

shop/src/main/java/com/example/shop/Discount.java
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.

shop/src/main/java/com/example/shop/Receipt.java
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.

shop/src/test/java/com/example/shop/DiscountTest.java
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.

shop/pom.xml
<?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.

pom.xml (check execution)
    <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.

pom.xml (plugin configuration)
<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.

src/test/java/com/example/shop/DiscountEdgeTest.java
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.

pom.xml (Surefire plugin)
    <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.

pom.xml (properties)
<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.

pom.xml (Surefire plugin)
    <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

Source

JaCoCo Maven plug-in

In this article we have measured code coverage with JaCoCo, read its reports, enforced coverage rules, and fixed the argLine configuration of Surefire.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Java tutorials.