Combining multiple test coverage reports for SonarCloud in GitHub Actions workflows

Combining multiple test coverage reports for SonarCloud in GitHub Actions workflows

In a typical multi-tier test setup, we run different test suites across several GitHub Actions jobs, each of which produces a coverage report. For example, we might start with unit tests running locally on the GitHub Actions runner and subsequently move on to integration tests running against a test deployment.

Our goal is to monitor and analyze the total test coverage on SonarCloud. The most straightforward solution would be to submit each coverage report individually once it has been generated. But SonarCloud treats each push as an independent report. So we need to send all coverage reports in one go.

Thanks to GitHub Action's artifacts it's not the most difficult thing to set up. But as always, there are some subtleties we have to get right. So here's a complete example.

We'll use Python with pytest and pytest-cov to generate the coverage reports, as described in detail in my previous post on Python test coverage reporting in GitHub monorepos. But the pattern applies to other languages just the same.

Note that we'll use version 4 of GitHub Action's artifacts which uses an entirely new setup under the hood and is not backwards compatible. (With the deprecated previous version 3 it was possible to upload to the same artifact multiple times. This meant you could rename the coverage.xml files to coverage-unit.xml and coverage-integration.xml and push to a single coverage artifact, which over time accumulated all coverage reports.)

Collecting coverage reports as artifacts

We use GitHub's actions/upload-artifact action to upload the coverage report generated from our test suite.

Here's what the relevant bits of the complete test workflow look like:

# ...

unit-test:
  # ...
  steps:
  - name: Run unit tests
    run: |
      pytest -m "unit" --cov=. --cov-report=xml tests/

  - name: Store coverage report
    uses: actions/upload-artifact@v4
    with:
      name: coverage-unit
      path: coverage.xml

# ...

integration-test:
  # ...
  steps:
  - name: Run integration tests
    run: |
      pytest -m "integration" --cov=. --cov-report=xml tests/

  - name: Store coverage report
    uses: actions/upload-artifact@v4
    with:
      name: coverage-integration
      path: coverage.xml

# ...

Combining and uploading the coverage reports

Once all our tests are completed, we can retrieve all artifacts using GitHub's actions/download-artifact action:

report-coverage:
  # ...
  depends-on: [unit-test, integration-test]
  steps:
  - name: Download coverage reports
    uses: actions/download-artifact@v4

This will download all artifacts, storing each in a directory with the name of the artifact, i.e.,

coverage-unit/
├─ coverage.xml
coverage-integration/
├─ coverage.xml

Hence, the sonar.*.coverage.reportPaths parameter in the sonarcloud-project.properties file has to look as follows:

...
sonar.tests=tests

sonar.python.coverage.reportPaths=coverage-unit/coverage.xml,coverage-integration/coverage.xml

Then we can push to SonarCloud using the official sonarsource/sonarcloud-github-action action:

report-coverage: 
  # ...
  - name: Download coverage reports
    uses: actions/download-artifact@v4

  - name: SonarCloud Scan
    uses: sonarsource/sonarcloud-github-action@master
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

If you need this in several workflows or repositories, you can create a reusable workflow or a composite action.