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):
<ItemGroup>
<PackageReference Include="coverlet.collector" />
</ItemGroup>
Then collect coverage when running tests:
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:
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:
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:
<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:
- 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:
[ExcludeFromCodeCoverage]
public class MyDbContext : DbContext
{
// EF Core context — tested through integration tests
}
You can also exclude by namespace in the Coverlet configuration:
<PropertyGroup>
<ExcludeByAttribute>ExcludeFromCodeCoverage</ExcludeByAttribute>
<ExcludeByFile>**/Migrations/**</ExcludeByFile>
</PropertyGroup>
Coverage in Pull Requests
For pull request comments showing coverage changes, use a third-party action:
- 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:
- Decreasing coverage on a PR probably means untested code is being added.
- High coverage doesn't mean your tests are good.
- Low coverage in a specific file might be fine if that file is tested through integration tests.
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.