Coverage, Honestly
By the end of this lesson
Use coverage as a hint and explain what it cannot tell you.
Code coverage measures which lines of your code ran while the tests ran. That is the whole mechanism. The tool instruments the assemblies, records what was executed, and divides.
Read that definition again, because the gap between it and how coverage gets used is the subject of this lesson. Coverage does not measure whether anything was checked. It cannot distinguish a line that ran under a precise assertion from a line that ran inside a test with no assertions at all.
It is still worth collecting. Uncovered code is a genuine fact — nothing you have written exercises it — and that fact is often the fastest way to find a rule nobody tested. The failure is treating the percentage as a measure of quality.
# Add the collector to the test project, once
dotnet add Ordering.Domain.Tests package coverlet.collector
# Run the suite and produce a coverage file
dotnet test --collect:"XPlat Code Coverage"
# Turn the raw file into something readable
dotnet tool install --global dotnet-reportgenerator-globaltool
reportgenerator -reports:**/coverage.cobertura.xml -targetdir:coverage -reporttypes:Html
# Then open coverage/index.html and read the uncovered lines,
# not the number at the top- The collector runs inside dotnet test and writes a Cobertura XML file per test project under TestResults.
- That file is not meant for people. The report generator turns it into HTML where each source file is shown with covered and uncovered lines marked, which is the view that is actually useful.
- The last comment is the point of the whole exercise. The summary percentage is the least informative thing in the report; the list of uncovered lines is where the information is.
- In a pipeline, publish the HTML report as an artefact. A number in the log gets glanced at once; a browsable report gets used when somebody is deciding what to test next.
public decimal ApplyDiscount(decimal price, int percentage)
{
if (percentage < 0 || percentage > 100)
{
throw new ArgumentOutOfRangeException(nameof(percentage));
}
decimal reduction = price * percentage / 100m;
return decimal.Round(price - reduction, 2);
}
// -- Test A: covers every line of the happy path. Verifies nothing.
[Fact]
public void ApplyDiscount_Runs()
{
var calculator = new PriceCalculator();
calculator.ApplyDiscount(100m, 20);
}
// -- Test B: same lines, and it would fail if the answer were wrong.
[Fact]
public void ApplyDiscount_WithTwentyPercentOffOneHundred_ReturnsEighty()
{
var calculator = new PriceCalculator();
decimal result = calculator.ApplyDiscount(100m, 20);
Assert.Equal(80m, result);
}- Both tests execute the same lines, so both report the same coverage for this method. The tool has no way to see the difference, because assertions are not part of what it measures.
- Test A passes for any implementation that does not throw. Return the price unchanged, return zero, return a random number — still green, still counted as covered.
- This is not a hypothetical. Tests written to raise a coverage number look exactly like Test A, because that is the shortest route to the number.
- Note also what neither test covers: the guard clause. Line coverage for the method is high while the branch that rejects bad input has never run — which is the gap branch coverage exists to show you.
What the different numbers mean, and what none of them mean:
- Line coverage
- The share of executable lines that ran. The headline figure, and the least discriminating: a line with a condition on it counts as covered when either outcome happened.
- Branch coverage
- The share of decision outcomes that ran — both sides of each if, each case of a switch. Always lower than line coverage and consistently more informative, because an untested branch is a rule nobody has checked.
- Uncovered lines
- The only part of the report that reliably tells you something you did not know. Each one is a piece of behaviour no test touches.
- What no coverage metric measures
- Whether anything was asserted, whether the assertion was correct, whether the expected value was computed with the code under test, or whether the behaviour that matters was even considered.
- Mutation testing
- A different technique that does address the gap: it changes your code deliberately — flips a comparison, alters a constant — and reports which changes your tests failed to notice. Slower to run, and a far better signal about assertion strength.
How to get value from coverage without being misled by it:
- Read the uncovered lines, not the percentage. Ask of each one: is this behaviour that matters? If yes, write the test. If no, leave it.
- Look at coverage on the diff rather than the whole repository. "This change added forty lines and none are covered" is a useful review observation; "the project is at 78 percent" is not.
- Prefer branch coverage when you are reading one number, because an untested branch is an untested rule.
- Check the assertions in any test that was written to close a coverage gap. That is where tests with no assertions come from.
- Expect some code to stay uncovered, and be able to say why: generated code, trivial property accessors, startup wiring that integration tests cover more usefully.
- If you want evidence that your assertions are strong, run mutation testing occasionally. It answers the question coverage cannot.
Summary
- Coverage measures lines executed while tests ran, not behaviour that was verified
- A test with no assertion raises coverage exactly as much as a precise one
- Branch coverage is the more useful single number, because an untested branch is an untested rule
- High coverage with weak assertions is worse than honest low coverage, because the whole team acts on it
- Read the uncovered lines, review coverage on the diff, and use mutation testing when you need evidence about assertion strength
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Think about it
Two teams, two numbers
Team A reports 94 percent line coverage. Reading their tests, many call a method and assert only that the result is not null.
Team B reports 61 percent. Their tests assert on specific values, and their coverage report shows the untested areas are a reporting module and an admin screen.
Which suite would you rather rely on before a release, and what would you propose to each team?
Show solution
Team B's, without much hesitation. Sixty-one percent of the code is genuinely checked, and — importantly — they know which 39 percent is not, so a release decision can account for it. Team A cannot tell you which parts of their 94 percent would catch a mistake.
For Team A: stop reporting the percentage for a while and look at assertion quality instead. A practical way in is to break something on purpose — change a comparison operator, alter a rounding rule — and count how many tests notice. If a deliberate bug passes the suite, the number is describing execution rather than verification. Mutation testing automates exactly this.
For Team B: use the uncovered list as a work queue, in order of consequence. A reporting module that produces figures somebody acts on deserves tests sooner than an admin screen used twice a year. Raising the number is the side effect, not the goal.
Worth saying plainly to both: neither number tells you whether the important behaviour is tested. Only reading the tests does that.
Try it yourself
Find the branch nobody tested
Collect line and branch coverage for a project you have tests for, and generate the HTML report.
Find a method where line coverage is high and branch coverage is noticeably lower. Work out which outcome has never run, then write the test that covers it.
Show solution
The pattern you are looking for is a guard clause or an early return. Tests exercise the successful path, so the lines all run, while the rejecting outcome of the condition never does.
Those branches matter more than average, not less. They encode the rules about what the code refuses, and refusals are added under pressure and removed during refactoring without anyone noticing — precisely because no test is watching.
When you write the test, assert on more than the fact that an exception occurred. Check the type, and check a structured detail such as ParamName, so the test also proves the right argument was rejected.
Then look at the number you started with and consider what it told you. The line figure was high the whole time, and the rule was untested the whole time. That is the lesson the exercise is for.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.