How SonarQube Code Coverage Reporting Works

In automated testing, scenarios like “all tests passed but coverage remains at 0%” or “discrepancies between SonarQube and local tool data” are classic challenges for developers.  

As an authorized Sonar partner, DragonSoft provides a deep dive into the four-stage pipeline of SonarQube code coverage reporting. We will help you precisely locate the seven root causes of 0% coverage and reveal the underlying logic behind data discrepancies in languages like Python and Java across different tools. By building a deterministic verification layer, we empower enterprises to maintain the baseline of code quality and health while accelerating with AI.

Here’s a common developer scenario: every test passes, but SonarQube reports 0.0% code coverage. Or coverage shows up, but the number is 20 points lower than what pytest or JaCoCo reported on the same code, and the scanner logs don’t explain why.

The problem is almost never SonarQube itself. Coverage reporting is a four-stage pipeline, and most failures happen at the handoff points between your test framework, your coverage tool, the scanner, and the dashboard. Once you see the pipeline clearly, diagnosing a coverage failure takes minutes.

The coverage pipeline

TL;DR Overview

SonarQube code coverage measures how much of the codebase is exercised by automated tests; it doesn’t itself generate coverage data. It imports reports produced by JaCoCo, coverage.py, Istanbul, or the equivalent tool for your language. The pipeline then runs through four stages, and most failures happen at the handoff points between them.

0% coverage almost always traces to one of seven causes: automatic analysis mode, missing report file, wrong format, wrong scanner property name (deprecated names fail silently), wrong path, scanner running before tests, or file paths inside the report not matching the project layout.

When numbers don’t match between tools, the cause is one of three things: different definitions of “coverable line” (Python def and import, JaCoCo closing braces), different file scope (your coverage tool only reports on files tests loaded; SonarQube sees every file), or SonarQube’s combined line-plus-branch formula vs. tools that report them separately.

Coverage percentage alone misses tests that exercise code without verifying it. SonarQube rules flag tests with no assertions (java:S2699), assertions trapped inside pytest.raises blocks where they never execute (python:S5915), and empty test classes (java:S2187).

The Coverage Pipeline

SonarQube does not generate code coverage data. It imports reports produced by third-party tools. The pipeline works like this:

  • Your test framework (JUnit, pytest, Jest) runs your tests.
  • A coverage tool (JaCoCo, coverage.py, Istanbul/c8) instruments your code and records which lines and branches executed during those tests.
  • The coverage tool writes a report file to disk in a specific format (JaCoCo XML, Cobertura XML, LCOV).
  • The sonar-scanner reads that report file via a configured analysis property and uploads the data to SonarQube.

The report file is the handoff artifact. It sits between your build tooling and SonarQube’s scanner, and it’s where most failures happen, such as wrong format, wrong path, or being absent entirely.

In practice, stages 1 and 2 are often a single command. JaCoCo hooks into Maven’s test phase. Jest has Istanbul built in. go test -coverprofile combines both. The conceptual separation matters for troubleshooting because the test can pass while the coverage tool fails to produce a report, but you won’t need to run two separate commands.

One constraint to know upfront is that coverage requires CI-based analysis, where you run sonar-scanner yourself. SonarQube Cloud’s automatic analysis mode doesn’t support it.

Each programming language has its own coverage tool, report format, and scanner property:

Language Test Framework Coverage Tool Report Format Scanner Property
Java (Maven)
JUnit 5
JaCoCo (Maven plugin)
JaCoCo XML
sonar.coverage.jacoco.xmlReportPaths
Java (Gradle)
JUnit 5
JaCoCo (Gradle plugin)
JaCoCo XML
sonar.coverage.jacoco.xmlReportPaths
JavaScript/TypeScript
Jest / Vitest
Istanbul / c8
LCOV
sonar.javascript.lcov.reportPaths
Python
pytest
coverage.py
Cobertura XML
sonar.python.coverage.reportPaths
C# (.NET)
xUnit / NUnit
Dotnet-coverage / coverlet
VS Coverage XML / OpenCover XML
sonar.cs.vscoveragexml.reportsPaths or sonar.cs.opencover.reportsPaths
Go
go test
Native (-coverprofile)
Go coverage format
sonar.go.coverage.reportPaths

When Coverage Shows 0%

The pipeline has four transitions and a failure at any one of them produces the same symptom: 0% coverage on the dashboard. Work through these checks in order as most issues are caught by the first four.

1. Is coverage supported in your analysis mode?

Automatic analysis doesn’t import coverage reports. Check your project’s Administration > Analysis Method in SonarQube Cloud. If it says “Automatic,” switch to CI-based analysis. No amount of property configuration will fix this.

2. Does the report file exist?

Before sonar-scanner runs, your build must produce a coverage report. After your test step completes, verify the file is where you expect it:

Language Coverage Report Path Command
Java (Maven)
target/site/jacoco/jacoco.xml
mvn verify (with JaCoCo plugin configured)
Java (Gradle)
build/reports/jacoco/test/jacocoTestReport.xml
./gradlew test jacocoTestReport
JavaScript/TypeScript
coverage/lcov.info
npx jest –coverage or npx vitest –coverage
Python
coverage.xml
coverage run -m pytest && coverage xml
C# (.NET)
coverage.xml
dotnet-coverage collect “dotnet test” -f xml -o coverage.xml
Go
coverage.out
go test -coverprofile=coverage.out ./…

If the file doesn’t exist after your build step, the problem is in your build configuration, not SonarQube.

3. Is the report in the right format?

Each programming language requires a specific format. Using the wrong one causes the scanner to silently ignore the report. You won’t get an error or a warning in normal output.

JaCoCo must produce XML, not binary .exec files. The old sonar.jacoco.reportPaths property that accepted binary format is deprecated. Python’s coverage.py must output Cobertura XML (coverage xml), not the .coverage binary or HTML report. JavaScript coverage must be LCOV, not JSON or Clover format.

Open the report file. XML starts with <?xml. LCOV starts with TN: or SF:. If you see binary data or HTML tags, you have the wrong format.

4. Does the scanner property point to the right file?

The scanner needs a property telling it where to find the report. Paths are relative to the directory where sonar-scanner runs (usually the project root). A report at build/coverage/lcov.info with a property set to coverage/lcov.info won’t be found.

Check your sonar-project.properties file or -D arguments:

				
					# Java
sonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml
# JavaScript / TypeScript
sonar.javascript.lcov.reportPaths=coverage/lcov.info
# Python
sonar.python.coverage.reportPaths=coverage.xml
				
			

5. Does the scanner run after the coverage report is generated?

A common CI mistake occurs when the sonar-scanner step starts before tests finish, or runs in a parallel job that doesn’t wait for the test step. The scanner step must explicitly depend on the test step in your pipeline.

6. Do file paths in the report match the project structure?

The paths inside the coverage report must match how sonar-scanner sees your source files. Three common mismatches:

  • Python in CI: Set relative_files = True in .coveragerc or pyproject.toml. Without it, coverage.py writes absolute container paths (/home/runner/work/my-project/…) that SonarQube can’t resolve to your source tree, which produces silent 0% coverage with no error.
  • Monorepos: If the scanner runs from the repo root but the coverage report references files relative to a subdirectory, paths won’t match.
  • Multi-module Maven: Aggregated JaCoCo reports may use module-relative paths. Use JaCoCo’s report-aggregate goal with properly configured source sets.

7. Is the property name correct and current?

Deprecated or misspelled property names silently produce 0% coverage. These are the ones that catch people:

Deprecated (silently ignored) Current
sonar.jacoco.reportPaths
sonar.coverage.jacoco.xmlReportPaths
sonar.typescript.lcov.reportPaths
sonar.javascript.lcov.reportPaths
sonar.python.coverage.reportPath
sonar.python.coverage.reportPaths

No warning, no error message; the scanner just doesn’t find coverage data. Any misspelled property name fails the same way. Copy your property name and check it against the test coverage parameters reference.

Check the scanner logs

If everything above looks correct, run the scanner with the -X flag for debug output. Search for:

  • Sensor JaCoCo XML Report Importer (Java) to confirm it found the report
  • The word coverage to find how many files had coverage imported. If the log says 0, the report wasn’t found or wasn’t parseable
  • WARN for unresolved file paths or missing reports
				
					0% coverage?
 |-- Using automatic analysis? -> Switch to CI-based
 |-- Report file exists? -> Check build config
 |-- Report in right format? -> XML/LCOV, not binary
 |-- Scanner property correct? -> Check name + path
 |-- Scanner runs after tests? -> Fix CI step order
 |-- File paths match? -> Check relative_files, monorepo paths
 |-- Property name current? -> Check for deprecated names
 |-- Still 0%? -> Run scanner with -X, search for "coverage"
				
			

Why Your Numbers Don't Match

You fixed the 0% problem and coverage appears on the dashboard; but coverage.py says 57% and SonarQube says 38%. Or JaCoCo says 44% and SonarQube says 42%. The tools aren’t broken, they’re just counting different things.

Take a Python calculator with four methods, where tests cover add() and the happy path of divide() but skip classify() and sqrt(). coverage.py reports 56.5% line coverage. SonarQube reports 37.5%, a 19% gap on the same code, with the same tests.

This is because in Python, def is an executable statement that runs at class load time, binding the function object to a name. When any test imports the module, every def line executes, even for methods the test never calls. coverage.py counts those def lines as coverable and covered, and it does the same for import and class lines. SonarQube doesn’t count any of them as executable, because they aren’t logic statements.

The five def lines, one import, and one class declaration inflate coverage.py’s numerator (all seven are “covered”) without adding any real coverage signal. A developer who sees 57% in pytest output and 38% on the dashboard assumes SonarQube is wrong. SonarQube is measuring what percentage of your logic ran during tests, whereas def lines executing at import time tells you nothing about whether the method’s body was tested.

The same principle applies at a smaller scale in other languages. In Java, JaCoCo operates at the bytecode level, and the compiler maps return bytecode to method-closing braces. SonarQube doesn’t count closing braces as executable statements. For a simple add() method:

				
					public int add(int a, int b) {
  lastResult = a + b; // Both tools: coverable, covered
  return lastResult; // Both tools: coverable, covered
} // JaCoCo: coverable | SonarQube: not counted

				
			

The same pattern repeats for divide(), classify(), and getLastResult(), each contributing one or two closing braces to JaCoCo’s count that SonarQube ignores. Across the full class, JaCoCo counts 18 coverable lines (including 6 braces) to SonarQube’s 12. The gap: JaCoCo says 44.4%, SonarQube says 41.7%. It’s only ~3% because the counting difference is limited to braces.

Language Tool Tool Says SonarQube Says Gap Main cause
Python
coverage.py
56.5%
37.5%
~19 pts
import, def, class lines
JavaScript
Istanbul
54.5%
50.0%
~4.5 pts
Class declaration, method signatures
Java
JaCoCo
44.4%
41.7%
~3 pts
Closing braces counted as coverable

Two root causes explain every discrepancy:

Different denominator. Each tool defines “coverable line” differently. SonarQube counts executable statements only. coverage.py includes imports, class declarations, and function definitions. JaCoCo includes closing braces, and Istanbul includes class declarations and method signatures.

Different file scope. Coverage tools report only on files loaded during testing, but SonarQube includes all project files. In an open-source Java project analyzed, the sample component (143 lines, 0% coverage) drags the overall number down to 53.2% even though IT sits at 76.7%. Untested utility code, generated files, or modules without tests appear at 0% in SonarQube but don’t appear at all in your coverage tool’s report. SonarQube is showing you the full picture, which is sometimes less flattering. If those files genuinely shouldn’t count (generated code, vendored dependencies), exclude them via sonar.exclusions. But untested application code that your coverage tool quietly omitted is worth knowing about.

A third factor compounds both: SonarQube combines line and branch coverage into a single metric.

				
					Coverage = (CT + CF + LC) / (2*B + EL)

				
			

CT and CF are conditions evaluated to true and false, LC is covered lines, B is total conditions, and EL is executable lines. Each branch counts double because it has two outcomes. With real project data, the math works out to 5,989 / 11,256 = 53.2%, matching the dashboard exactly. JaCoCo reports line and branch coverage as separate numbers, so when you have many untested branches, SonarQube’s combined metric runs lower than JaCoCo’s line-only figure.

In small, well-tested projects the gap between tools is a few percentage points. In large projects with untested modules or generated code, the gap can be more substantial.

Beyond Percentage: When Covered Code Isn't Tested

Coverage tells you which lines ran during tests but doesn’t tell you whether the tests actually verified anything. A test that calls a method without asserting the result produces full line coverage for that method, but catches zero bugs. SonarQube detects these gaps with rules that analyze test quality, not just test execution.

Tests without assertions (java:S2699)

The most common test quality issue. A test that exercises code but asserts nothing provides line coverage without verifying behavior:

				
					@Test
void testAddNoAssertion() { // Noncompliant: S2699
  Calculator calc = new Calculator();
  calc.add(2, 3);
  // Line coverage: 100% of add(). Bugs caught: zero.
}
				
			

SonarQube flags this as a BLOCKER. The rule recognizes assertions from many popular frameworks including JUnit, AssertJ, Mockito, and Hamcrest, so it won’t flag tests that use a supported assertion library.

Assertions that never execute (python:S5915)

Subtler and harder to catch manually. An assertion inside a pytest.raises block never runs because the exception exits the block first:

				
					def test_divide_by_zero():
  calc = Calculator()
  with pytest.raises(ValueError):
    calc.divide(1, 0)
  assert calc.last_result is None # Dead code — never executes
				
			

The test passes. coverage.py marks the raise line as covered, but the assertion on the last line is dead code. Moving it outside the with block fixes it. SonarQube flags this as high-impact.

Empty test classes (java:S2187)

A class named CalculatorEdgeCaseTest with no test methods shows up in test reports, occupies space in the test directory, and leads someone reading the project to think edge cases are covered. SonarQube flags test classes with zero test methods as a BLOCKER across JUnit 3/4/5, TestNG, and other supported frameworks.

These rules catch problems that coverage percentage misses entirely. AI coding agents frequently generate tests like this with high line coverage and zero meaningful assertions.

What's Next

Code coverage reporting in SonarQube is a pipeline, not a button. When the number looks wrong, the question isn’t “is SonarQube broken?” but “where in the pipeline did the chain break?”

For language-specific setup instructions, please contact DragonSoft!

High-quality code coverage isn’t just “generated”—it’s “engineered” through precision management.

As an authorized Sonar partner, DragonSoft leverages extensive experience in DevSecOps implementation to offer:

  • Troubleshooting & Configuration Optimization: Resolve technical bottlenecks such as missing coverage reports or path mismatches within your CI/CD pipelines.
  • AI-Era Governance Solutions: Integrate the latest Sonar AC/DC framework to identify “quality illusions” like tests without assertions, enhancing overall testing effectiveness.
  • Version Upgrades & Trials: Apply for a trial of the SonarQube Server 2026.1 LTA to experience groundbreaking analysis speeds for Python, Java, and other major languages.
  • Full-Cycle Technical Support: Benefit from localized architecture planning, deployment, professional training, and comprehensive technical backing.

Subtler and harder to catch manually. An assertion inside a pytest.raises block never runs because the exception exits the block first:

Website: https://hkdsdtech.com/

Tel: +852-51679050

Email: customer@hkdsdtech.com

About DragonSoft

Founded in 2006, DragonSoft is now a leading DevSecOps solution provider in China. Integrating DevOps with the Agile management concept and the leading tools in the world, DragonSoft offers ALM (SDLM) / DevSecOps / Agile solutions as well as implementation, upgrading, training, customized development and maintenance services to automate the software development process, drive team collaboration, improve both efficiency and quality, and also ensure the traceability and measurability.