Home » Boost Code Quality: Master Code Coverage with JaCoCo
Latest Article

Boost Code Quality: Master Code Coverage with JaCoCo

A lot of teams start caring about code coverage with JaCoCo only after a bad release. A production bug gets through, the rollback is noisy, and someone asks a painful question in the incident review: “Did we even test that path?”

Usually, the answer is complicated. The team did run tests. The build was green. But nobody had a clear picture of what those tests exercised, where the blind spots were, or whether integration flows were covering the same code paths that matter in production.

That’s where code coverage with JaCoCo becomes useful. Not as a vanity number. Not as a scoreboard for engineers. As a practical signal. It tells you which parts of your Java application are getting exercised, which branches are being skipped, and where your CI pipeline gives a false sense of safety. When you use it well, it changes test discussions from opinion to evidence.

Why Code Coverage Is Your Team's Secret Weapon

A release can look clean on Friday and still fail hard on Monday because the code path that broke was never exercised. I see this most often in the branches teams assume are safe enough to skip. Null handling, retry logic, feature-flag variations, fallback behavior, and adapter exceptions. The tests pass, the pipeline stays green, and production is still carrying untested risk.

That is where coverage earns its place in the workflow. It gives the team a concrete view of which code ran during tests and which paths were ignored. JaCoCo is especially useful here because it reports instruction, line, branch, method, and class coverage, and the official JaCoCo documentation also explains how its bytecode-based analysis derives complexity-related insight that helps expose decision-heavy code. Line coverage on its own is rarely enough. A method can look covered while the error path, alternate branch, or timeout handling never ran once.

A focused man wearing a green sweater looking at a critical system error alert on his computer.

Good teams use coverage as a risk map.

That changes how test discussions work in practice. Instead of arguing in general terms about whether a feature is "well tested," the team can inspect a report and see that the happy path is covered, but the failed payment response is not. Or that unit tests hit a service method, but nothing exercised the code that wires it to the database, message broker, or third-party API. That matters in business-critical applications, where the expensive bugs usually live in the edges between components, not in the obvious path a developer ran locally.

The strongest use of JaCoCo is not chasing a single percentage across the repository. It is using coverage to decide where to spend engineering effort.

  • Core business rules deserve closer coverage review than plain data holders or generated code.
  • Branch coverage matters more than line coverage for code with retries, validation, pricing rules, or entitlement checks.
  • Low-visibility legacy modules often hide more risk than new code because the team assumes they are stable.
  • Integration paths need their own attention because unit coverage can make them look safer than they are.

If you need a broader refresher on test coverage in software testing, it pairs well with a JaCoCo rollout because it helps frame coverage as one input to quality, not the whole quality story.

I have seen teams get real value from coverage only after they stop treating it as a target to game. The useful question is not "How do we get to 90%?" The useful question is "Which untested paths could hurt customers, revenue, or on-call sleep if they fail in production?"

That is the shift that makes JaCoCo a practical engineering tool. It helps teams combine local feedback, merged unit and integration coverage, and CI enforcement into a coverage model that reflects how the application behaves, not just how the unit suite behaves in isolation.

Your First JaCoCo Setup for Maven and Gradle

A common first-week failure looks like this: the team adds JaCoCo to the build, the job turns green, and nobody opens the HTML report. Two days later, someone adds a coverage gate in CI and discovers the numbers are incomplete, the wrong classes are counted, or the build never produced XML for downstream tooling. Start locally, verify the output files, and make sure developers can reproduce the same report before CI gets involved.

For Maven builds, the standard starting point is the jacoco-maven-plugin. The plugin declaration below uses version 0.8.12, consistent with the JaCoCo Maven setup example. Pairing that with a disciplined automated testing workflow in DevOps pipelines keeps coverage from becoming a one-off build artifact that no one trusts.

Maven setup that gets you a real report

Add the plugin to pom.xml:

<build>
  <plugins>
    <plugin>
      <groupId>org.jacoco</groupId>
      <artifactId>jacoco-maven-plugin</artifactId>
      <version>0.8.12</version>
      <executions>
        <execution>
          <goals>
            <goal>prepare-agent</goal>
          </goals>
        </execution>
        <execution>
          <id>report</id>
          <phase>verify</phase>
          <goals>
            <goal>report</goal>
          </goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

Run:

mvn clean verify

That command attaches the JaCoCo agent during the test phase and generates report output during verify. On a healthy first setup, the files worth checking are the execution data under target and the HTML report under target/site/jacoco/. If those files are missing, the build may still look successful while giving you no usable coverage signal.

One practical warning. Maven setups often break the first time because another plugin overwrites JVM args, tests run in a different phase than expected, or a parent POM applies JaCoCo inconsistently across modules. Catch that now, on a laptop, not after a CI gate starts failing every pull request.

Gradle setup for a clean baseline

Gradle is just as workable, but the simplest version is usually the best first version.

plugins {
    id 'java'
    id 'jacoco'
}

test {
    useJUnitPlatform()
}

jacocoTestReport {
    dependsOn test
    reports {
        html.required.set(true)
        xml.required.set(true)
        csv.required.set(false)
    }
}

Run:

./gradlew test jacocoTestReport

That gives you an HTML report for developers and XML output for later CI parsing. In Gradle projects, I usually confirm the report path and task ordering before changing anything else. Teams get into trouble when they customize test tasks too early, especially if they already have separate source sets, Spring Boot test slices, or integration tests wired into custom tasks.

JaCoCo agent vs build plugin

Early confusion usually comes from mixing up the runtime agent with offline instrumentation. They are related, but they are not the same operational choice.

AspectJaCoCo Agent (On-the-fly)JaCoCo Build Plugin (Offline)
How it worksAttaches a JVM agent at runtime and monitors loaded classesInstruments classes as part of the build process before execution
Best fitStandard local builds and CI runsCases where runtime agent attachment is difficult or tightly controlled
Setup effortLower for most Maven and Gradle projectsHigher and usually more explicit
Operational behaviorFits normal test tasks and build lifecyclesGives more control over instrumented artifacts
Common downsideAdds some overhead during test executionAdds build complexity and more failure points

Typically, the runtime agent is the right default. Offline instrumentation has legitimate uses, but it is rarely the right starting point for a first rollout.

What to look for in the first report

Open the HTML report and inspect real code paths, not just the summary percentage.

  1. Red methods in code the team assumed was already covered
  2. Partial branches in validation, retry, pricing, or authorization logic
  3. Generated classes, DTOs, and framework glue that distort the top-line number

The first report should challenge assumptions. If it does not, inspect whether tests ran under coverage or whether JaCoCo missed part of the build.

A local workflow that survives CI

Keep the developer loop boring.

  • Run coverage with the same command every time: mvn clean verify or ./gradlew test jacocoTestReport
  • Open the HTML report before reading the percentage: the class and method view is where setup mistakes show up
  • Confirm XML generation early: CI platforms and quality tools usually depend on XML, not HTML
  • Check one known class manually: pick a class with obvious tested and untested paths so you can validate the report is believable
  • Avoid early gate thresholds: get accurate reports first, then decide what to enforce

That discipline matters later when you start combining unit and integration results. If the single-run local report is shaky, the merged CI version will be worse.

Unifying Coverage From Unit and Integration Tests

The most misleading coverage setup is the one that only reports unit tests. It looks tidy. It runs fast. It also leaves out a lot of the code that only executes when modules talk to each other, when persistence layers initialize, or when real service boundaries get exercised.

That’s where JaCoCo’s aggregation model matters. It can combine execution data from multiple runs into a shared jacoco.exec file. In a documented example, separate test executions each showed gaps, but merging both runs into the same execution data produced 100% coverage, which is the clearest proof of why consolidated reporting matters in real projects, as shown in this JaCoCo aggregation tutorial.

A diagram illustrating how combining unit and integration tests leads to comprehensive code coverage versus relying only on units.

Why one test layer is never enough

Unit tests are great for fast feedback and isolated logic checks. Integration tests do different work. They expose wiring mistakes, serialization issues, transaction behavior, and framework-driven execution paths that unit tests rarely touch.

If your team still treats those reports separately, you’re splitting one quality signal into disconnected fragments. That weakens the value of code coverage with JaCoCo.

A broader automated testing discipline matters here too. Teams working through automated testing in DevOps pipelines usually hit this exact issue once they move beyond small single-module services.

Maven merge pattern

In Maven, the practical pattern is simple: produce separate execution data files, then merge them before generating the final report.

<build>
  <plugins>
    <plugin>
      <groupId>org.jacoco</groupId>
      <artifactId>jacoco-maven-plugin</artifactId>
      <version>0.8.12</version>
      <executions>
        <execution>
          <id>prepare-unit-tests</id>
          <goals>
            <goal>prepare-agent</goal>
          </goals>
          <configuration>
            <destFile>${project.build.directory}/jacoco-unit.exec</destFile>
            <append>true</append>
          </configuration>
        </execution>

        <execution>
          <id>merge-results</id>
          <phase>verify</phase>
          <goals>
            <goal>merge</goal>
          </goals>
          <configuration>
            <fileSets>
              <fileSet>
                <directory>${project.build.directory}</directory>
                <includes>
                  <include>jacoco-*.exec</include>
                </includes>
              </fileSet>
            </fileSets>
            <destFile>${project.build.directory}/jacoco.exec</destFile>
          </configuration>
        </execution>

        <execution>
          <id>report-merged</id>
          <phase>verify</phase>
          <goals>
            <goal>report</goal>
          </goals>
          <configuration>
            <dataFile>${project.build.directory}/jacoco.exec</dataFile>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

The missing piece is making your integration test phase also emit execution data. Some teams do that through Failsafe. Others use dedicated profiles. The important part is consistency in output files and explicit merge behavior.

Gradle merge pattern

Gradle gives you more freedom, which is helpful and dangerous in equal measure.

tasks.register('integrationTest', Test) {
    useJUnitPlatform()
    shouldRunAfter test
}

tasks.register('mergedJacocoReport', JacocoReport) {
    dependsOn test, integrationTest

    executionData.setFrom(fileTree(buildDir).include(
        "jacoco/test.exec",
        "jacoco/integrationTest.exec"
    ))

    sourceDirectories.setFrom(files(sourceSets.main.allSource.srcDirs))
    classDirectories.setFrom(files(sourceSets.main.output))

    reports {
        html.required.set(true)
        xml.required.set(true)
    }
}

This works if each test task writes distinct execution data and the report task explicitly consumes both.

The best merged report is the one your team trusts enough to use in code review. If engineers keep asking “which test suite produced this,” the setup still needs work.

What actually breaks

Three things usually go wrong:

  • Execution files overwrite each other: You meant to append or separate outputs, but one test task replaced the other.
  • Integration tests run in another process or container: Coverage never reaches the file you expect.
  • The merged report includes the wrong classes: Multi-module builds often need careful class directory selection.

When teams solve those three, coverage starts reflecting reality instead of whichever test task happened to run last.

Automating JaCoCo in GitHub Actions and Jenkins

Local reports help an engineer. Automated reports help a team. Once JaCoCo runs inside CI, coverage becomes part of normal delivery instead of a side exercise someone remembers before release.

That’s the point where code coverage with JaCoCo starts influencing behavior. Pull requests get better conversations. Broken assumptions show up earlier. And test regressions stop hiding behind a passing build.

Three abstract glowing spheres encased in a transparent fluid wave with the text Automate Coverage below.

GitHub Actions workflow that teams can live with

For GitHub Actions, keep the first workflow simple. Run the build, generate the report, then upload the artifact so engineers can inspect it without reproducing the branch locally.

name: coverage

on:
  pull_request:
  push:
    branches: [ main ]

jobs:
  test-and-coverage:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Java
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '17'

      - name: Run Maven verify
        run: mvn clean verify

      - name: Upload JaCoCo report
        uses: actions/upload-artifact@v4
        with:
          name: jacoco-html-report
          path: target/site/jacoco

If your team is still standardizing workflow design, this guide on building CI/CD pipelines with GitHub Actions is a useful companion because the coverage step is only one part of a stable pipeline.

For organizations tightening their broader testing posture, it also helps to align coverage with the rest of continuous integration testing practices, especially around artifact retention and consistent failure conditions.

Jenkins pipeline for report publishing

Jenkins is still common in Java shops because it handles complex enterprise workflows well. The trick with JaCoCo in Jenkins is not just running the tests. It’s making the report visible after every build.

pipeline {
    agent any

    stages {
        stage('Build and Test') {
            steps {
                sh 'mvn clean verify'
            }
        }

        stage('Archive Coverage Report') {
            steps {
                archiveArtifacts artifacts: 'target/site/jacoco/**', fingerprint: true
            }
        }
    }
}

That’s enough for a first version. From there, teams usually add XML consumption for dashboards or quality platforms, but HTML artifacts are still the fastest way to debug a suspicious number.

Containerized services need different handling

Many clean examples stop being useful because JaCoCo writes execution data when the JVM exits. That’s fine for normal test runs. It’s a problem for long-running services, app servers, and containerized workloads that don’t shut down neatly during the pipeline.

For Docker and Kubernetes environments, JaCoCo’s agent can run with output=tcpserver so coverage data is dumped over the network. That matters because long-running servers may never trigger the default on-exit write, a problem affecting about 40% of enterprise setups, and using TCP server mode can increase capture from 60% to over 95% in microservice environments according to this Teamscale write-up on JaCoCo profiling in containerized systems.

-javaagent:jacocoagent.jar=destfile=jacoco.exec,output=tcpserver,address=*,port=6300

In containers, the question isn’t “did the tests run?” It’s “did the JVM ever flush the coverage data?”

A practical production-like pattern looks like this:

  • Instrument the application JVM explicitly: Don’t assume your test container and app container share the same lifecycle.
  • Pull or dump coverage before teardown: For long-lived services, rely on TCP or controlled dump behavior.
  • Aggregate execution data after all relevant test stages finish: This is critical for distributed suites.
  • Publish one final report artifact: Multiple partial reports confuse more than they help.

Later in the pipeline, it helps to surface coverage as something engineers can see during review, not just after a failed gate. This walkthrough gives a visual complement before you wire that into your own process:

What works better than people expect

Two practices usually pay off fast.

First, archive the full HTML report every time. Console summaries are too thin for debugging. Second, keep execution data collection and gate enforcement separate at first. Teams adopt coverage faster when they can inspect reports for a few cycles before builds start failing on thresholds.

From Metrics to Action Interpreting Reports and Gates

A team gets its first JaCoCo report into CI, sees a decent overall percentage, and assumes the risk is under control. Then a release slips through with a broken fallback path in pricing, a missed permission check, or error handling that was never asserted. The report was not wrong. The team read the wrong signal.

JaCoCo gives you several counters to work with: instruction, line, branch, method, and class coverage. They do not answer the same question, and treating them as interchangeable is how teams end up chasing a higher number instead of better tests. For business-critical services, branch coverage usually carries more weight than line coverage because it shows whether decision points were exercised in both directions.

A person pointing at a computer monitor displaying various data analytics charts and visualizations on a desk.

What the main counters actually tell you

Use the counters for different jobs.

MetricWhat it tells you in practiceWhere it helps most
Instruction coverageWhether bytecode instructions executedFine-grained execution baseline
Line coverageWhether executable lines ran at least onceFast scanning in HTML reports
Branch coverageWhether decision paths were exercisedBusiness rules, validation, error handling
Method coverageWhether methods were invokedFinding untouched entry points
Class coverageWhether classes executed at allHigh-level package review

Line coverage is the easiest metric to explain in a pull request. It is also the easiest one to misread. A line can execute while one side of the condition on that same line never runs. That is why branch coverage usually exposes the defects that matter in production systems.

Method and class coverage help with discovery, not confidence. They are useful for spotting dead zones in a module, but they do not tell you whether the logic inside those methods was exercised in a meaningful way.

How to read the HTML report like an engineer

Start with yellow lines, not green totals.

Yellow usually means partial branch coverage, and partial branch coverage is where weak tests hide. Open the method and check what occurred:

  • Did the test prove only the happy path?
  • Was the condition evaluated only one way?
  • Did exception handling run without any assertion on the outcome?
  • Did coverage come from an unrelated higher-level test that passed through the method by accident?

That last case shows up often after teams merge unit and integration coverage. The report looks healthier, but the signal gets blurrier if nobody checks whether the important branch was tested deliberately or just touched on the way to something else.

If your team treats release quality and review quality as part of the same workflow, coverage discussions fit naturally into secure code review practices for engineering teams. The branches nobody tests are often the same branches nobody challenges during review.

Read the yellow lines first. They often point straight at missing scenario coverage, especially in validation, permissions, retries, and fallback logic.

Adding gates without creating nonsense

Coverage gates work when they reflect system risk and current team maturity. They fail when they turn into one blunt threshold for every package in the repo.

A useful first gate usually targets the modules where failures cost real money or trust. Payment flows, authorization rules, pricing logic, and customer-visible decisioning deserve stricter thresholds than DTOs, configuration classes, or generated code. In practice, I have seen teams get better results by gating branch coverage on a few critical packages first, while publishing reports for the rest without failing the build.

For Maven, that usually means enforcing limits with the JaCoCo check goal. In Gradle, it means adding verification rules to the build so the report and the gate run predictably in CI. The exact percentage matters less than the policy behind it.

A gate policy holds up better when it does four things:

  • Sets stricter thresholds for critical code paths
  • Avoids treating boilerplate and business logic as equal
  • Fails on regression instead of demanding all historical debt be fixed at once
  • Shows developers the report artifact immediately so they can act on the failure

The regression point matters. Teams adopting JaCoCo across an existing codebase usually break trust fast if the first rollout blocks every build for old debt. A ratcheting approach works better. Hold the line on new or changed code, then raise expectations package by package.

Turning reports into backlog decisions

The best use of coverage data happens after the build passes or fails.

A low-coverage module with high churn usually deserves a testability refactor. A package with strong line coverage and weak branch coverage usually needs more scenario-based tests, not more tests of the same shape. A sudden drop after a refactor often points to behavior that used to be exercised incidentally and is now skipped entirely.

That is the practical shift teams need to make. JaCoCo is not just a reporting step in CI. It is a way to decide where to spend testing effort, where to tighten gates, and where hidden production risk still sits in code that looks finished.

Navigating Common Pitfalls and Exclusions

Most JaCoCo frustrations come from one bad assumption: if the number looks wrong, the tests must be wrong. Sometimes that’s true. Often it isn’t.

Coverage can be distorted by generated code, framework glue, DTOs, boilerplate accessors, or bytecode patterns that don’t map neatly back to the source code engineers think they’re reading. If you don’t handle those cases, the report becomes noisy enough that people stop trusting it.

Exclusions reduce noise when used carefully

Exclusions are useful, but teams often misuse them. If you exclude too aggressively, you inflate confidence. If you exclude nothing, your report gets dragged around by code nobody intends to test directly.

Good exclusion candidates are usually:

  • Generated classes: Code produced by tools rather than engineers
  • Boilerplate-heavy models: Simple containers with little behavior
  • Framework wiring: Configuration layers that don’t contain real business decisions
  • Low-value accessors: Getter and setter blocks that add noise to totals

The test for every exclusion is simple. If the code contains meaningful logic, keep it in. If it mostly exists to satisfy frameworks or generation pipelines, excluding it may improve signal quality.

The exception-handling coverage bug

One of the more frustrating edge cases is exception-heavy code. A frequently reported JaCoCo issue marks methods that throw exceptions as uncovered even when tests execute them. That bug can produce a 20-30% drop in reported coverage in exception-heavy code, which is enough to fail builds and send teams on a false debugging chase, according to the JaCoCo issue discussing exception-related false negatives.

That matters most in services with validation layers, defensive guards, and error translation. Those methods may be tested correctly while the report still says otherwise.

If a method is designed to throw and your test asserts that behavior, distrust the report before you distrust the test.

Practical workarounds

You usually won’t fix this with one magic switch. What works is a combination of discipline and selective configuration:

  • Inspect the compiled behavior, not just the source line coloring: Bytecode-level tooling can behave differently than source expectations.
  • Use targeted exclusions when a known false negative keeps breaking CI: Don’t exclude broad packages to hide a narrow problem.
  • Separate hard gates from suspicious classes until the report is trustworthy: A brittle gate teaches teams to hate the metric.
  • Document the edge case in the repository: Otherwise someone will “fix” the tests later and lose time rediscovering the same issue.

Another common mistake is counting all uncovered code as equally important. It isn’t. A missed branch in pricing logic deserves attention. An uncovered generated mapper may not.

Making Code Coverage a Sustainable Practice

JaCoCo works best when the team treats coverage as a feedback loop, not a compliance ritual. The number alone doesn’t improve software. The conversations and changes that follow it do.

Sustainable teams use coverage to guide testing effort toward risky code, protect critical logic from regression, and spot blind spots in integration flows. They don’t weaponize it in code review. They don’t demand perfect scores in every package. And they don’t confuse broad execution with meaningful verification.

The practical target is trust. Engineers should trust that the report reflects reality closely enough to influence decisions. Once that happens, code coverage with JaCoCo becomes part of normal delivery. It helps shape test strategy, refactoring priorities, and release confidence without taking over the whole engineering culture.

Incremental improvement is enough. A clearer report, a better merged view, and a smarter gate on critical paths will do more for quality than a rushed push to 100%.


If you’re building out CI, Kubernetes, testing, or hiring practices around software delivery, DevOps Connect Hub is worth keeping on your radar. It’s a practical resource for U.S. startups and SMBs that need actionable DevOps guidance without the usual fluff.

About the author

admin

Veda Revankar is a technical writer and software developer extraordinaire at DevOps Connect Hub. With a wealth of experience and knowledge in the field, she provides invaluable insights and guidance to startups and businesses seeking to optimize their operations and achieve sustainable growth.

Add Comment

Click here to post a comment