Revision history (first version, published Nov 1, 2026)
- First published
Cite this article(DOI: 10.5281/zenodo.23856503)
This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.
Go Komura (2026). Finding Untested Code with Coverage ── Checking C# Tests Written by AI. KomuraSoft LLC. https://doi.org/10.5281/zenodo.23856503 https://comcomponent.com/en/blog/code-coverage-find-untested-code/
- DOI (latest version)
- 10.5281/zenodo.23856503
- DOI (this version)
- 10.5281/zenodo.23856504
When you have AI write code and tests together, all the tests may pass while it remains unclear which code they actually checked. The number of tests or an “all passed” message alone will not tell you that error handling or boundary conditions were never executed.
This article explains how to find such “code the tests do not execute” with code coverage. Coverage records which code was executed while the tests ran. Once you know which lines were not executed and which branches were never taken, you can decide whether those places need tests, or whether the code is needed at all.
On the other hand, coverage counts only “was it executed.” It cannot tell you whether the tests check the results correctly, so that is investigated with mutation testing in the next article in the series.
The scope is unit tests in C# and .NET (xUnit, MSTest, NUnit). The commands and package names were checked against Microsoft and Coverlet documentation as of November 2026. The example method and tests were actually measured with the .NET 8 SDK and Coverlet (coverlet.collector), confirming the line and branch results described here.
This article is the second in the series “Improving the Quality of AI-Written Programs.” The first, “Can You Trust Tests Written by AI?,” covers the types of tests that pass without checking anything.
What code coverage counts
Code coverage expresses the amount of code that unit tests executed, in units of lines, branches and methods1. For example, in code with two branches, a test that checks only one of them gives 50% branch coverage1.
flowchart TB
accTitle: How coverage is measured
accDescr: Shows that when the tests run, the measurement tool records the code that was executed, writes it to a file, and a report then distinguishes executed lines from lines that were not executed.
T["Run the tests"] --> C["The tool records the executed code"]
C --> F["Write the results to a file"]
F --> R["See in the report what ran"]
R --> Y["Executed lines and branches"]
R --> N["Lines and branches not executed"]
Figure 1: When the tests run, which lines and branches were executed is recorded, and a report lets you tell them apart.
Line coverage and branch coverage
Line coverage counts whether a line was executed at least once. Branch coverage counts whether each path of an if statement or conditional operator was taken. The two disagree when a condition is written within a single line.
Take the following method as an example. It was made up for this explanation.
public static class PriceCalculator
{
public static decimal Subtotal(decimal unitPrice, int quantity)
{
if (unitPrice < 0) throw new ArgumentOutOfRangeException(nameof(unitPrice));
return quantity > 0 ? unitPrice * quantity : 0m;
}
}
Suppose the method has only the following test.
[Fact]
public void Subtotal_MultipliesPriceByQuantity()
{
Assert.Equal(300m, PriceCalculator.Subtotal(100m, 3));
}
This test executes every line of the method. The condition on the if line is evaluated and the return line runs too, so line coverage shows no unexecuted lines. Yet the path that throws an exception and the path that returns 0 when the quantity is 0 or less are never taken. Branch coverage leaves these two as branches that were not taken.
flowchart TB
accTitle: A condition written on one line and the two kinds of coverage
accDescr: Shows that running only a test with a positive unit price and quantity marks both the if line and the return line as executed, while the path that throws an exception and the path that returns 0 are never taken.
S["Test: unit price 100, quantity 3"] --> L1["The if line (executed)"]
L1 --> B1["Path that throws (not taken)"]
L1 --> L2["The return line (executed)"]
L2 --> B2["Path returning price × quantity (taken)"]
L2 --> B3["Path returning 0 (not taken)"]
Figure 2: Even when every line is executed, one path of a condition written on one line may never be taken.
How many branches there are is decided by the measurement tool from the compiled code. So the number of branches in the report may not match the number you count by looking at the source. Rather than the count itself, look at which lines have branches that were not taken.
In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (5 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle
Measuring coverage in .NET
.NET coverage tools fall into two kinds: “data collectors,” which record data while the tests run, and “report generators,” which turn that data into readable reports1. For recording, there is Microsoft’s code coverage built into .NET and the open-source Coverlet.
Which one to use depends on the test platform your test project runs on: the traditional VSTest, or Microsoft.Testing.Platform (MTP).
flowchart TB
accTitle: How to collect coverage on each test platform
accDescr: Shows that a test project running on VSTest uses coverlet.collector, a project running on Microsoft.Testing.Platform uses coverlet.MTP or Microsoft.Testing.Extensions.CodeCoverage, and both write the Cobertura format, which ReportGenerator turns into a report.
P["Test platform"] -->|"VSTest"| V["coverlet.collector"]
P -->|"MTP"| M1["coverlet.MTP"]
P -->|"MTP"| M2["Microsoft code coverage"]
V --> X["Cobertura XML"]
M1 --> X
M2 --> X
X --> R["Turn it into HTML with ReportGenerator"]
Figure 3: The package depends on the test platform, but if the output is in Cobertura format, the same tool can turn it into a report.
Test projects that run on VSTest
An xUnit test project created with dotnet new xunit already includes the coverlet.collector package, which is Coverlet’s data collector1. Run the following command in the test project’s folder.
dotnet test --collect:"XPlat Code Coverage"
"XPlat Code Coverage" is the name that refers to Coverlet’s data collector1. When the run finishes, coverage.cobertura.xml is written to a GUID-named folder created under the TestResults folder12.
If you write "Code Coverage" instead of "XPlat Code Coverage", the data collector built into .NET is used. Its output is a binary .coverage file, so to read it outside Visual Studio you convert it to a readable format with a tool such as dotnet-coverage1.
Test projects that run on Microsoft.Testing.Platform
coverlet.collector is not compatible with Microsoft.Testing.Platform (MTP v2)2. For test projects that run on MTP, use one of the following3.
- Add the
coverlet.MTPpackage and run withdotnet test --coverlet. You can specify the Cobertura format with--coverlet-output-format cobertura. - Add the
Microsoft.Testing.Extensions.CodeCoveragepackage and run with--coverage. You can specify the Cobertura format with--coverage-output-format cobertura.
By default, Microsoft.Testing.Extensions.CodeCoverage excludes the test project’s own assembly from measurement3.
Turning the results into an HTML report with ReportGenerator
You can turn Cobertura XML into an HTML report with ReportGenerator1. Install ReportGenerator as a .NET global tool and run it on the XML that was written.
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:"TestResults\{guid}\coverage.cobertura.xml" -targetdir:"coveragereport" -reporttypes:Html
Replace {guid} with the name of the folder created for that run. Open the HTML created in the coveragereport folder to see, file by file and line by line, what was executed.
Finding untested branches in the report
What to look at in Cobertura XML
In Cobertura XML, each line records hits (the number of times it was executed). Lines with branches also get branch="True" and condition-coverage, which shows the share of branches taken1. In the example in Microsoft’s documentation, a line whose branches were all taken is recorded as condition-coverage="100% (2/2)".
In the report, look for two things:
- Lines with
hitsof 0. These are lines the tests never executed. - Lines whose
condition-coverageis below 100%. These lines were executed, but some of their branches were never taken.
Where to look first
When you find many lines that were not executed, do not give them all the same weight. Start with the logic where mistakes would hurt.
- Logic that validates arguments and input values and rejects invalid values
catchblocks that handle exceptions, and logic that returns errors to the caller- Branches that handle null or empty collections
- Retry and timeout logic
- Conditions that split on boundary values (0, the upper limit, an empty string)
When AI wrote the tests, use this list to check that they do not stop at the normal input examples you gave in your instructions. In the example in the previous section too, testing only normal inputs left the path that throws an exception and the path that returns 0.
flowchart TB
accTitle: What to do with lines found in the report
accDescr: Shows that for lines that were not executed and branches that were not taken, you decide whether the code is needed, remove it if not, add tests if it is, and for executed lines go on to check whether the results are verified.
A["Line in the report"] --> B{"Was it executed?"}
B -->|"hits is 0"| C{"Is the code needed?"}
B -->|"Only some branches"| D["Add tests for the remaining branches"]
B -->|"All taken"| E["Check how it is verified (next article)"]
C -->|"Needed"| F["Add tests"]
C -->|"Not needed"| G["Remove the code"]
Figure 4: For code that is not executed, decide whether to add tests or, if it is not needed, remove it.
An article on the Google Testing Blog says that in coverage, the parts that were not executed are more meaningful than the parts that were, and that discussing specific uncovered lines in code review is more useful than fixating on an arbitrary target number4.
Why not to make the percentage a goal
When you set a coverage percentage such as “80% or more” as a goal, raising the percentage easily becomes the goal itself. Martin Fowler writes that coverage is useful as a tool for finding untested parts of the code but of little use as a number expressing how good the tests are, because even low-quality tests easily reach high percentages5. As an extreme example, he cites “assertion-free tests” that only execute the code and never check the results5.
Let us try it with Subtotal from the earlier section. The following test has not a single assertion.
[Fact]
public void Subtotal_DoesNotThrow()
{
PriceCalculator.Subtotal(100m, 3);
PriceCalculator.Subtotal(100m, 0);
}
This test executes both paths of the conditional operator, so its branch coverage is higher than that of the first test, which checked the value with Assert.Equal. Measuring this example with Coverlet, branch coverage rose from 50% for the first test to 75%. Even so, if Subtotal mistakenly added instead of multiplying, this test would not fail.
flowchart TB
accTitle: The difference between executing and checking
accDescr: Shows that coverage counts only whether code was executed, while whether the results were checked depends on whether there are assertions, so even tests without assertions raise coverage but find no mistakes.
T["The test executes the code"] --> C["Coverage goes up"]
T --> A{"Are the results checked with assertions?"}
A -->|"Checked"| D["The test fails if there is a mistake"]
A -->|"Not checked"| E["The test passes even with a mistake"]
Figure 5: Coverage counts only execution, so even tests that do not check results raise the percentage.
The Google Testing Blog article also says that a high coverage percentage does not guarantee high-quality tests, and that mutation testing is better for assessing whether tests sufficiently check the lines they execute4. The same article says that a low percentage means large parts of the product were never exercised by automated tests4. The percentage gives clearer information when it is low than when it is high.
Whether tests check the results is covered in the next article in the series, “Testing Your Tests with Mutation Testing.”
Excluding generated code from measurement
When code generated by tools (database migrations, code generated by the Windows Forms designer, and so on) is included in measurement, the unexecuted lines of the code you wrote yourselves get buried. Such code can be excluded from measurement.
Classes and methods marked with ExcludeFromCodeCoverageAttribute are excluded from coverage measurement. Applied to a class or struct, it excludes all of its members6. You can record the reason for the exclusion in the Justification property6. Coverlet always excludes code marked with this attribute2.
Generated code that cannot carry the attribute is excluded in Coverlet’s settings file (runsettings) by specifying attribute or file names.
<?xml version="1.0" encoding="utf-8" ?>
<RunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat code coverage">
<Configuration>
<Format>cobertura</Format>
<ExcludeByAttribute>GeneratedCodeAttribute,CompilerGeneratedAttribute</ExcludeByAttribute>
<ExcludeByFile>**/Migrations/*.cs</ExcludeByFile>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>
dotnet test --collect:"XPlat Code Coverage" --settings coverlet.runsettings
GeneratedCodeAttribute and CompilerGeneratedAttribute are not attributes Coverlet excludes by default. To exclude them, list them in ExcludeByAttribute as shown here2. The ExcludeByFile path is an example; adjust it to wherever generated code lives in your own project.
Use exclusion settings to make the code you wrote easier to see, not to raise the percentage. If you exclude hand-written logic, the fact that it is untested disappears from the report. Record what you excluded and why, in Justification or in comments in the settings file.
flowchart TB
accTitle: Deciding whether to exclude code from measurement
accDescr: Shows that code generated by tools is excluded with attributes or a settings file, hand-written logic is not excluded and stays in the report, and what was excluded is recorded along with the reason.
A["Code being measured"] --> B{"Was it generated by a tool?"}
B -->|"Yes"| C["Exclude it with an attribute or settings file"]
B -->|"No (hand-written logic)"| D["Keep it in the report"]
C --> E["Record what was excluded and why"]
Figure 6: Exclude only generated code from measurement, and record the reason.
Looking at coverage for changed lines only
In a large project, the overall percentage does not tell you whether the current change has tests. When deciding whether to accept a change written by AI, looking at coverage for only the lines that change added or modified narrows down what you need to check.
diff-cover is a tool that compares a coverage report with the output of git diff and reports coverage for the changed lines7. It reads Cobertura XML, so you can pass it Coverlet’s output as is. By default it compares against origin/main; to compare against another branch, specify --compare-branch. With --fail-under, it exits with a nonzero exit code when the percentage falls short of that value7.
pip install diff-cover
diff-cover TestResults\{guid}\coverage.cobertura.xml --compare-branch=origin/main
flowchart TB
accTitle: Coverage for changed lines only
accDescr: Shows matching the coverage report against the lines changed in git diff, listing the changed lines that were not executed, and deciding in review whether to add tests for those lines.
A["Coverage report"] --> C["Match against the changed lines"]
B["Changed lines from git diff"] --> C
C --> D["Changed lines that were not executed"]
D --> E["Decide in review whether to add tests"]
Figure 7: Narrowing to changed lines shows where this change lacks tests.
The Google Testing Blog article says that a project-wide target above 90% is probably not worth it, while a per-change coverage target of 99% is reasonable and 90% is a good lower bound4. These numbers are Google’s guidelines. When deciding your own values, it is easier to keep from making the percentage the goal if you start by reading unexecuted lines in review, and only later block changes mechanically on a number.
Related Articles
- Can You Trust Tests Written by AI?
- Testing Your Tests with Mutation Testing
- Property-Based Testing: Trying the Inputs Examples Never Reach
- Where to Draw the Line Between Unit Tests and Integration Tests
References
-
Microsoft Learn, Use code coverage for unit testing. The definition of code coverage, data collectors and report generators,
dotnet test --collect:"XPlat Code Coverage", Cobertura output, and how to use ReportGenerator. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 -
Coverlet, VSTest Integration. Settings in runsettings, attributes excluded by default, and compatibility with MTP v2. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Microsoft.Testing.Platform (MTP) code coverage. Options for
Microsoft.Testing.Extensions.CodeCoverageandcoverlet.MTP. ↩ ↩2 -
Google Testing Blog, Code Coverage Best Practices. The relationship between the percentage and quality, mutation testing, per-change coverage guidelines, and using coverage in code review. ↩ ↩2 ↩3 ↩4
-
Martin Fowler, TestCoverage. Coverage is a tool for finding untested parts of the code, not a number for how good the tests are. ↩ ↩2
-
Microsoft Learn, ExcludeFromCodeCoverageAttribute Class. What the attribute applies to, and the
Justificationproperty. ↩ ↩2 -
diff-cover, README. A tool that compares a coverage report with
git diffand reports coverage for the changed lines. ↩ ↩2
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Testing Your Tests with Mutation Testing ── Finding Tests That Check Nothing with Stryker.NET
Mutation testing deliberately changes code slightly and checks whether tests fail. Learn to find tests that run code but check nothing: s...
Can You Trust Tests Written by AI? ── How to Spot Tests That Pass Without Checking Anything
Tests written by AI can pass without checking anything. Learn to spot missing assertions, expected values copied from the code, overused ...
Date, Time, and Timezones in Business Apps — From DateTime Pitfalls to the UTC-Storage Principle and Test Design
Timestamps drift by nine hours after a server migration; only the overseas office's dates roll back to the previous day — we trace date/t...
UI Automated Testing for Windows Desktop Apps — How UI Automation Works and Building Robust Tests with FlaUI
A practical guide to UI automated testing for WinForms/WPF apps, working from how Windows UI Automation itself works (the tree, Automatio...
Connecting Ada with C and C# — Building a DLL and Calling It
How to build a shared library in Ada and call it from C and C#, verified by running it: Interfaces.C, Convention => C, gprbuild, elaborat...
Related Topics
These are topic pages close to this theme. Starting from the article, you can go on to related services and other articles.
Windows Technical Topics
Entry point collecting technical topics on Windows development, bug investigation, and use of existing assets.
Services Connected to This Topic
This article leads to the following service pages. Start from the entry point closest to your situation.
Technical Consulting & Design Review
How you measure coverage and read the reports works best when decided together with how you write tests and run reviews, which makes this a good topic for technical consulting and design review.
Frequently Asked Questions
Questions commonly asked at the time of consultation about the topic of this article.
- What is code coverage?
- It is a value showing how much of a program was executed when the tests ran, measured in units of lines, branches and methods. It counts only whether code was executed, so it does not tell you whether the tests checked the results correctly.
- What is the difference between line coverage and branch coverage?
- Line coverage counts whether a line was executed at least once, while branch coverage counts whether each path of an if statement or conditional operator was taken. When a condition is written on a single line, the line can be executed while one of its paths is never taken, and branch coverage reveals that.
- How do I measure coverage in C#?
- For a test project that runs on VSTest, add the coverlet.collector package and run dotnet test --collect:"XPlat Code Coverage"; a coverage.cobertura.xml file is created under TestResults. If you run on Microsoft.Testing.Platform, use coverlet.MTP or Microsoft.Testing.Extensions.CodeCoverage. You can create an HTML report with ReportGenerator.
- What coverage percentage should we aim for?
- We do not recommend making the percentage itself the goal, because even tests without assertions raise it. Reading the lines and branches that were not executed and deciding whether they need tests is more useful than the percentage. You can also look at coverage for changed lines only.
Author Profile
Profile page for the article author.
Go Komura
Representative of KomuraSoft LLC
Focused on Windows software development, technical consulting, and bug investigation, with particular strength in projects where existing assets remain and in investigating failures whose cause is hard to see.