Code Coverage — Measuring, Reporting and Setting Meaningful Thresholds

Code coverage measures which lines, branches, and methods are executed during tests. It is a useful tool for finding gaps in the test suite, not a quality metric in itself — 90% coverage with meaningless assertions provides less value than 70% coverage with focused, meaningful tests. The goal is to use coverage reports to identify genuinely untested critical paths, then add tests that make meaningful assertions about those paths.

Coverage Configuration and Reporting

// ── Run tests with coverage collection ────────────────────────────────────
// dotnet test --collect:"XPlat Code Coverage" --results-directory ./coverage

// ── Install ReportGenerator ────────────────────────────────────────────────
// dotnet tool install -g dotnet-reportgenerator-globaltool
//
// Generate HTML report from coverage XML:
// reportgenerator
//   -reports:"./coverage/**/coverage.cobertura.xml"
//   -targetdir:"./coverage/report"
//   -reporttypes:"Html;Cobertura;lcov"
//   -assemblyfilters:"-*Tests*;-*Migrations*"
//   -filefilters:"-*Generated*;-*Scaffold*"
//   -classfilters:"-*Dto;-*ViewModel;-*Exception"

// ── Coverlet configuration in csproj ─────────────────────────────────────
// BlogApp.Tests.csproj:
// <PropertyGroup>
//   <CollectCoverage>true</CollectCoverage>
//   <CoverletOutputFormat>cobertura</CoverletOutputFormat>
//   <CoverletOutput>./coverage/</CoverletOutput>
//   <Exclude>[*]BlogApp.Api.Migrations.*;[*]*Dto;[*]*Exception</Exclude>
//   <ExcludeByAttribute>GeneratedCodeAttribute;CompilerGeneratedAttribute</ExcludeByAttribute>
//   <Threshold>80</Threshold>
//   <ThresholdType>line</ThresholdType>
//   <ThresholdStat>minimum</ThresholdStat>
// </PropertyGroup>

// ── Combine unit + integration coverage ───────────────────────────────────
// Run both test projects and merge:
// dotnet test BlogApp.UnitTests    --collect:"XPlat Code Coverage" --results-directory ./cov/unit
// dotnet test BlogApp.IntegTests   --collect:"XPlat Code Coverage" --results-directory ./cov/int
// reportgenerator
//   -reports:"./cov/**/coverage.cobertura.xml"   -- merges all XML files
//   -targetdir:"./cov/merged"
//   -reporttypes:"Html;lcov"

// ── Reading the coverage report ───────────────────────────────────────────
// GREEN (covered):  all branches of an if/else were executed during tests
// YELLOW (partial): some branches not executed (partial branch coverage)
// RED (uncovered):  line never executed by any test
//
// High-value targets to test (currently uncovered):
// - PostsService.PublishAsync — error paths (wrong status, wrong owner)
// - AuthService.LoginAsync — account lockout path
// - FileUploadController.UploadImage — invalid magic bytes path
// - GlobalExceptionHandler — for each exception type mapping
//
// Low-value to skip (typically excluded):
// - EF Core migrations (auto-generated)
// - DTOs/ViewModels (no logic)
// - Exception subclasses (just constructors)
// - Program.cs bootstrapping code
Note: Branch coverage is more valuable than line coverage for the BlogApp’s business logic. A line like return post.Status == "review" ? PublishPost() : throw new DomainException() is one line but has two branches. Line coverage says “this line was executed” even if only one branch was taken. Branch coverage reveals that the unhappy path (the throw) was never tested. Always check both line and branch coverage for service-layer code with conditional logic.
Tip: Use the ExcludeByAttribute setting to automatically exclude auto-generated code marked with [GeneratedCode] and [CompilerGenerated] attributes — these come from source generators, EF Core scaffolding, and C# compiler-generated code (async state machines, record members). Excluding them prevents inflated “uncovered” percentages for code you cannot and should not test directly, and keeps the coverage report focused on your actual business code.
Warning: Never set coverage thresholds so high that they block development. If the threshold is 90% and a developer adds a new controller with no tests yet, the entire PR is blocked until they write tests — even if the PR’s primary purpose is a UI change. Set thresholds at the current coverage level (e.g., 78%) and increase them by 1-2% per sprint as coverage improves. The threshold is a floor to prevent regression, not a ceiling to optimise toward.

Coverage Metrics Reference

Metric What It Measures Best For
Line coverage % of lines executed Quick health check
Branch coverage % of if/else/switch branches taken Business logic paths
Method coverage % of methods called Finding untested features
Statement coverage % of statements executed Similar to line, more granular

Common Mistakes

Mistake 1 — Measuring coverage only from unit tests (misses integration-tested paths)

❌ Wrong — only unit test coverage; many controller and middleware paths only covered by integration tests; report shows false low numbers.

✅ Correct — merge coverage from unit tests AND integration tests; the combined report reflects total real coverage.

Mistake 2 — Setting 100% threshold (blocks all PRs with new untested infrastructure code)

❌ Wrong — 100% threshold; developer adds a new logging statement; line not tested; PR blocked.

✅ Correct — 75-85% threshold with reasonable exclusions; focus on testing business logic, not infrastructure boilerplate.

🧠 Test Yourself

A method has 100% line coverage but 50% branch coverage. What does this reveal?