SonarQube Code Coverage Integration: Jest, JaCoCo, pytest-cov, and More

SonarQube Code Coverage Integration: Jest, JaCoCo, pytest-cov, and More

SonarQube doesn't run your tests — it reads coverage reports that your test runner produces. To see coverage in SonarQube, you need two things: a test run that generates a coverage file (LCOV, JaCoCo XML, Cobertura XML, etc.) and a scanner configuration that tells SonarQube where to find it. This guide covers the configuration for the most common language/test runner combinations.

How SonarQube Coverage Works

The flow is always the same:

  1. Run your tests with coverage enabled → test runner writes a coverage report file
  2. Run sonar-scanner → scanner reads the report and sends it to SonarQube
  3. SonarQube displays coverage metrics and highlights uncovered lines

SonarQube supports several coverage report formats:

  • LCOV — for JavaScript/TypeScript (Jest, Istanbul, Vitest)
  • JaCoCo XML — for Java
  • Cobertura XML — for Python, .NET, C/C++
  • Generic coverage format — SonarQube's own XML format
  • OpenCover — for .NET

JavaScript / TypeScript with Jest

Step 1: Configure Jest to produce LCOV

// jest.config.js or package.json
{
  "jest": {
    "collectCoverage": true,
    "coverageReporters": ["lcov", "text"],
    "coverageDirectory": "coverage"
  }
}

Or pass flags on the CLI:

npx jest --coverage --coverageReporters=lcov

This writes coverage/lcov.info.

Step 2: Configure sonar-project.properties

sonar.projectKey=my-js-project
sonar.sources=src
sonar.tests=src
sonar.test.inclusions=**/*.test.ts,**/*.test.js,**/*.spec.ts,**/*.spec.js
sonar.javascript.lcov.reportPaths=coverage/lcov.info
sonar.typescript.lcov.reportPaths=coverage/lcov.info

Full CI example (GitHub Actions)

- name: Run tests with coverage
  run: npx jest --coverage --coverageReporters=lcov

- name: SonarQube Scan
  uses: sonarsource/sonarqube-scan-action@master
  with:
    args: >
      -Dsonar.projectKey=my-js-project
      -Dsonar.javascript.lcov.reportPaths=coverage/lcov.info
  env:
    SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
    SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}

TypeScript with Vitest

Vitest uses V8 or Istanbul for coverage:

// vite.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['lcov', 'text'],
      reportsDirectory: './coverage'
    }
  }
})

Run:

npx vitest run --coverage

Then configure SonarQube the same way as Jest:

sonar.javascript.lcov.reportPaths=coverage/lcov.info

Java with JaCoCo

Step 1: Add JaCoCo plugin to Maven

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

Run tests:

mvn test

This generates target/site/jacoco/jacoco.xml.

Step 2: Configure SonarQube scanner

sonar.projectKey=my-java-project
sonar.sources=src/main/java
sonar.tests=src/test/java
sonar.java.binaries=target/classes
sonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml

Gradle with JaCoCo

// build.gradle
plugins {
  id 'jacoco'
  id "org.sonarqube" version "4.4.1.3373"
}

jacoco {
  toolVersion = "0.8.11"
}

test {
  finalizedBy jacocoTestReport
}

jacocoTestReport {
  dependsOn test
  reports {
    xml.required = true
  }
}

sonarqube {
  properties {
    property "sonar.projectKey", "my-java-project"
    property "sonar.coverage.jacoco.xmlReportPaths", "${buildDir}/reports/jacoco/test/jacocoTestReport.xml"
  }
}

Run:

./gradlew test sonarqube

Python with pytest-cov

Step 1: Generate coverage in Cobertura XML format

pip install pytest-cov

pytest --cov=src \
       --cov-report=xml:coverage.xml \
       --cov-report=term-missing

Step 2: Configure SonarQube

sonar.projectKey=my-python-project
sonar.sources=src
sonar.tests=tests
sonar.python.coverage.reportPaths=coverage.xml

Full GitHub Actions example

- name: Run tests
  run: |
    pip install pytest pytest-cov
    pytest --cov=src --cov-report=xml:coverage.xml

- name: SonarQube Scan
  uses: sonarsource/sonarqube-scan-action@master
  with:
    args: >
      -Dsonar.projectKey=my-python-project
      -Dsonar.python.coverage.reportPaths=coverage.xml
  env:
    SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
    SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}

Go with Built-in Coverage

Go has native coverage support:

go test ./... -coverprofile=coverage.out

SonarQube reads Go coverage files directly:

sonar.projectKey=my-go-project
sonar.sources=.
sonar.exclusions=**/*_test.go
sonar.tests=.
sonar.test.inclusions=**/*_test.go
sonar.go.coverage.reportPaths=coverage.out

.NET with Coverlet

dotnet add package coverlet.msbuild

# Run tests with coverage
dotnet test /p:CollectCoverage=true \
            /p:CoverletOutputFormat=opencover \
            /p:CoverletOutput=./coverage.opencover.xml
sonar.projectKey=my-dotnet-project
sonar.cs.opencover.reportsPaths=coverage.opencover.xml

Multi-Module Projects

For monorepos or multi-module Maven builds, collect all coverage reports:

Maven multi-module:

<!-- In parent pom.xml, aggregate coverage -->
<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <executions>
    <execution>
      <id>report-aggregate</id>
      <phase>verify</phase>
      <goals>
        <goal>report-aggregate</goal>
      </goals>
    </execution>
  </executions>
</plugin>
# Point to the aggregate report
sonar.coverage.jacoco.xmlReportPaths=coverage-report/target/site/jacoco-aggregate/jacoco.xml

JavaScript monorepo (multiple packages):

# Comma-separated list of paths
sonar.javascript.lcov.reportPaths=packages/core/coverage/lcov.info,packages/api/coverage/lcov.info,packages/ui/coverage/lcov.info

Diagnosing Coverage Not Showing Up

Problem: Coverage shows 0% or N/A

Check the SonarQube analysis logs (visible on the Analysis Activity page). Look for:

WARN: No coverage information will be saved because all files are excluded from analysis.

or

WARN: Could not find the coverage report file: coverage/lcov.info

Common fixes:

  1. Wrong path — the path in sonar.javascript.lcov.reportPaths must be relative to the project root (where you run sonar-scanner), not the source directory
  2. Coverage not generated — run tests first; confirm the report file exists before running the scanner
  3. Test files included in sources — if your test files are in sonar.sources, SonarQube may count test code in coverage calculations, skewing numbers. Use sonar.exclusions or sonar.test.inclusions to separate them

Problem: Coverage lower than expected

SonarQube may be counting files that the test runner excludes. Check:

  • sonar.exclusions matches what your test runner excludes (e.g., mock files, generated code, *.config.ts)
  • Your coverage report includes the right set of files

Problem: "Executable lines" vs "Lines to Cover"

SonarQube counts executable lines (lines that can actually be executed), not all lines. Blank lines, comments, and type-only declarations don't count. This can make your effective coverage percentage look different from what your test runner reports.

Coverage Thresholds in sonar-project.properties

You can set coverage thresholds at the project level (not recommended — use Quality Gates instead):

# These are informational, not blocking
sonar.coverage.minimum=80

For blocking behavior, use Quality Gates (Administration → Quality Gates) instead — they integrate with CI/CD properly.

Best Practices

Always run tests in CI before scanning — the coverage file must exist when the scanner runs. Put the test step before the scan step in your pipeline.

Don't exclude too much — excluding node_modules, dist, and auto-generated files is correct. Excluding actual business logic from coverage is hiding problems.

Use branch coverage in addition to line coverage — line coverage tells you a line ran; branch coverage tells you all logical paths (if/else, ternary, etc.) were tested. Enable both in your test runner:

// Jest
{
  "coverageThreshold": {
    "global": {
      "lines": 80,
      "branches": 75
    }
  }
}

Track coverage trends — SonarQube's project history shows coverage over time. A coverage graph trending downward is an early warning that the team is shipping faster than they're testing.

Read more

Start now free