Code Coverage in CI for .NET Projects

Code coverage tells you which lines of your code are exercised by tests. It's not a measure of test quality, but it's a useful signal — especially when it's trending downwards. This article shows how to collect coverage in CI using Coverlet, generate reports with ReportGenerator, and enforce minimum thresholds.

Setting Up Coverlet

Coverlet is the standard code coverage tool for .NET. The easiest way to use it is through the coverlet.collector NuGet package, which integrates with dotnet test.

Add it to your test projects (or to tests/Directory.Build.props if you're using Central Package Management):

config.xml
<ItemGroup>
  <PackageReference Include="coverlet.collector" />
</ItemGroup>

Then collect coverage when running tests:

terminal
dotnet test --collect:"XPlat Code Coverage"

This produces a coverage.cobertura.xml file in each test project's TestResults directory.

Generating HTML Reports

Raw Cobertura XML isn't human-readable. Use ReportGenerator to turn it into an HTML report:

terminal
dotnet tool install dotnet-reportgenerator-globaltool

reportgenerator \
    -reports:"**/coverage.cobertura.xml" \
    -targetdir:"coverage-report" \
    -reporttypes:"Html;Cobertura"

The Html report type gives you a browsable, file-by-file breakdown. The merged Cobertura output is useful for uploading to coverage services.

GitHub Actions Workflow

Here's a complete workflow that tests, collects coverage, and publishes the report:

.github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '9.0.x'

      - name: Restore tools
        run: dotnet tool restore

      - name: Test with coverage
        run: dotnet test --configuration Release --collect:"XPlat Code Coverage" --results-directory ./TestResults

      - name: Generate report
        run: |
          reportgenerator \
            -reports:"TestResults/**/coverage.cobertura.xml" \
            -targetdir:"coverage-report" \
            -reporttypes:"Html;MarkdownSummaryGithub"

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage-report

      - name: Write coverage summary
        run: cat coverage-report/SummaryGithub.md >> $GITHUB_STEP_SUMMARY

The MarkdownSummaryGithub report type produces a markdown summary that renders neatly in the GitHub Actions job summary. This gives you coverage visibility without leaving GitHub.

Enforcing Coverage Thresholds

You can fail the build if coverage drops below a threshold. Coverlet supports this natively with MSBuild properties:

tests/Directory.Build.props
<PropertyGroup>
  <CollectCoverage>true</CollectCoverage>
  <CoverletOutputFormat>cobertura</CoverletOutputFormat>
  <ThresholdType>line,branch</ThresholdType>
  <Threshold>80</Threshold>
</PropertyGroup>

With this configuration, if line or branch coverage drops below 80%, the test run fails. Be careful with thresholds — setting them too high leads to tests that exist solely to hit the number rather than to verify behaviour.

For a less aggressive approach, use the coverlet.msbuild package and set thresholds in CI only:

config.yaml
      - name: Test with threshold
        run: dotnet test /p:CollectCoverage=true /p:Threshold=80 /p:ThresholdType=line

Excluding Code from Coverage

Not all code is worth measuring. Exclude generated code, migrations, and infrastructure:

Example.cs
[ExcludeFromCodeCoverage]
public class MyDbContext : DbContext
{
    // EF Core context — tested through integration tests
}

You can also exclude by namespace in the Coverlet configuration:

config.xml
<PropertyGroup>
  <ExcludeByAttribute>ExcludeFromCodeCoverage</ExcludeByAttribute>
  <ExcludeByFile>**/Migrations/**</ExcludeByFile>
</PropertyGroup>

Coverage in Pull Requests

For pull request comments showing coverage changes, use a third-party action:

config.yaml
      - name: Coverage report PR comment
        uses: marocchino/sticky-pull-request-comment@v2
        if: github.event_name == 'pull_request'
        with:
          path: coverage-report/SummaryGithub.md

This posts (and updates) a comment on the pull request with the current coverage summary.

What Coverage Doesn't Tell You

Coverage measures whether a line was executed, not whether the result was verified. A test that calls a method without asserting anything gives you coverage but not confidence. Use coverage as a directional signal:

Summary

Collecting code coverage in CI takes minutes to set up and provides ongoing visibility into your test suite. Use Coverlet for collection, ReportGenerator for readable reports, and be thoughtful about thresholds. The goal isn't 100% coverage — it's making sure new code comes with tests.