Finding Untested Code with Coverage ── Checking C# Tests Written by AI

· · C#, .NET, Testing, Code Coverage, Coverlet, AI-Generated Code, Quality

Post on X
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.

How coverage is measuredShows 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.Run the testsThe tool records the executed codeWrite the results to a fileSee in the report what ranExecuted lines and branchesLines 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.

A condition written on one line and the two kinds of coverageShows 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.Test: unit price 100, quantity 3The if line (executed)Path that throws (not taken)The return line (executed)Path returning price × quantity (taken)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).

How to collect coverage on each test platformShows 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.VSTestMTPMTPTest platformcoverlet.collectorcoverlet.MTPMicrosoft code coverageCobertura XMLTurn 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.MTP package and run with dotnet test --coverlet. You can specify the Cobertura format with --coverlet-output-format cobertura.
  • Add the Microsoft.Testing.Extensions.CodeCoverage package 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 hits of 0. These are lines the tests never executed.
  • Lines whose condition-coverage is 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
  • catch blocks 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.

What to do with lines found in the reportShows 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.hits is 0Only some branchesAll takenNeededNot neededLine in the reportWas it executed?Is the code needed?Add tests for the remaining branchesCheck how it is verified (next article)Add testsRemove 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.

The difference between executing and checkingShows 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.CheckedNot checkedThe test executes the codeCoverage goes upAre the results checked with assertions?The test fails if there is a mistakeThe 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.

Deciding whether to exclude code from measurementShows 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.YesNo (hand-written logic)Code being measuredWas it generated by a tool?Exclude it with an attribute or settings fileKeep it in the reportRecord 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
Coverage for changed lines onlyShows 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.Coverage reportMatch against the changed linesChanged lines from git diffChanged lines that were not executedDecide 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.

References

  1. 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

  2. Coverlet, VSTest Integration. Settings in runsettings, attributes excluded by default, and compatibility with MTP v2. ↩ ↩2 ↩3 ↩4

  3. Microsoft Learn, Microsoft.Testing.Platform (MTP) code coverage. Options for Microsoft.Testing.Extensions.CodeCoverage and coverlet.MTP. ↩ ↩2

  4. 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

  5. Martin Fowler, TestCoverage. Coverage is a tool for finding untested parts of the code, not a number for how good the tests are. ↩ ↩2

  6. Microsoft Learn, ExcludeFromCodeCoverageAttribute Class. What the attribute applies to, and the Justification property. ↩ ↩2

  7. diff-cover, README. A tool that compares a coverage report with git diff and reports coverage for the changed lines. ↩ ↩2

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These are topic pages close to this theme. Starting from the article, you can go on to related services and other articles.

This article leads to the following service pages. Start from the entry point closest to your situation.

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.

Back to the Blog