Testing and Code Quality
Keeping C# code correct and maintainable: unit tests with xUnit, dotnet test, the built-in analyzers, nullable warnings as errors, EditorConfig and dotnet format, and naming conventions.
Why automated tests
Every time you change code, something that used to work can break. Checking by hand does not scale; an automated test does: a small piece of code that calls your code with known input and checks the result. The most common kind is the unit test, which checks one method or class in isolation and runs in milliseconds.
The idea fits in one file. This program checks a billing method against three known answers, the way a test framework would:
int failures = 0;
Check("412 kWh at 0.27", Billing.CalculateCost(412m, 0.27m), 111.24m);
Check("zero usage", Billing.CalculateCost(0m, 0.27m), 0m);
Check("rounds half a cent up", Billing.CalculateCost(0.5m, 0.25m), 0.13m);
Console.WriteLine(failures == 0 ? "All checks passed" : $"{failures} check(s) failed");
void Check(string name, decimal actual, decimal expected)
{
bool ok = actual == expected;
if (!ok) failures++;
Console.WriteLine($"{(ok ? "PASS" : "FAIL")} {name}: expected {expected}, got {actual}");
}
static class Billing
{
public static decimal CalculateCost(decimal kwh, decimal pricePerKwh)
{
ArgumentOutOfRangeException.ThrowIfNegative(kwh);
return Math.Round(kwh * pricePerKwh, 2, MidpointRounding.AwayFromZero);
}
}
PASS 412 kWh at 0.27: expected 111.24, got 111.24 PASS zero usage: expected 0, got 0.00 PASS rounds half a cent up: expected 0.13, got 0.13 All checks passed
Note the second line: 0m and 0.00m print
differently but are equal: a decimal keeps its number
of decimal places, which affects how it prints, but
== compares only the value. A real test framework adds
what this sketch lacks: it finds the tests automatically, runs each
one separately so one failure does not hide the others, and reports
results in a form that IDEs and build pipelines understand.
Test projects and xUnit
In .NET, tests go in a test project: a separate project, next to the project it tests, that references that project and a test framework. The tests are never shipped with the application, and the production project does not depend on any test package. A typical layout, building on the solution from Projects, NuGet and Building:
MeterTools/
├── MeterTools.slnx
├── src/
│ └── MeterTools.Core/
│ ├── MeterTools.Core.csproj
│ └── Billing.cs
└── tests/
└── MeterTools.Tests/
├── MeterTools.Tests.csproj
└── BillingTests.cs
dotnet new xunit -o tests/MeterTools.Tests
dotnet solution add tests/MeterTools.Tests
dotnet reference add src/MeterTools.Core --project tests/MeterTools.Tests
xUnit (xUnit.net) is a free,
open-source unit testing framework for .NET, and a common choice for
new projects. Two others are common: NUnit, the
oldest, and MSTest, Microsoft's own. All three work
with dotnet test and the IDEs, and their ideas are the
same; only attribute and method names differ.
| Concept | xUnit | NUnit | MSTest |
|---|---|---|---|
| A test | [Fact] |
[Test] |
[TestMethod] |
| A test with data rows | [Theory] + [InlineData] |
[TestCase] |
[DataRow] |
| Class marker | none needed | optional [TestFixture] |
[TestClass] |
| Set-up before each test | the constructor | [SetUp] |
[TestInitialize] |
The project file of a v2 test project, as the template creates it:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4"></PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"></PackageReference>
<PackageReference Include="xunit" Version="2.9.3"></PackageReference>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"></PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MeterTools.Core\MeterTools.Core.csproj"></ProjectReference>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"></Using>
</ItemGroup>
</Project>
IsPackable keeps the tests out of NuGet packages,
Microsoft.NET.Test.Sdk and
xunit.runner.visualstudio connect the tests to
dotnet test and the IDEs,
coverlet.collector
measures
code coverage, and the Using item makes the
Xunit namespace available in every test file. The
template writes the package references in XML's short form (<PackageReference … />); both forms mean the same.
Writing tests
A test class is an ordinary public class. In xUnit each test is a
public method marked with an attribute. A
fact is an xUnit test method
marked [Fact]: it takes no parameters and checks one
thing that should always be true. These are the tests for the
Billing class from above, now in the library
MeterTools.Core:
using MeterTools.Core;
namespace MeterTools.Tests;
public class BillingTests
{
[Fact]
public void CalculateCost_MultipliesUsageByPrice()
{
// Arrange
decimal kwh = 412m;
decimal pricePerKwh = 0.27m;
// Act
decimal cost = Billing.CalculateCost(kwh, pricePerKwh);
// Assert
Assert.Equal(111.24m, cost);
}
[Fact]
public void CalculateCost_RejectsNegativeUsage()
{
var ex = Assert.Throws<ArgumentOutOfRangeException>(
() => Billing.CalculateCost(-5m, 0.27m));
Assert.Equal("kwh", ex.ParamName);
}
}
Some conventions worth copying:
- Arrange, Act, Assert. Set up the input, call the code once, then check the result. The three parts make a test easy to read, even without the comments.
-
Descriptive names. A name such as
CalculateCost_RejectsNegativeUsagesays what broke when it appears in a list of failures. - One behavior per test. Several asserts are fine when they check one outcome; two unrelated behaviors belong in two tests.
- Independent tests. xUnit creates a new instance of the test class for every test and runs test classes in parallel, so tests must not depend on each other's order or shared state.
Assertions
An assertion checks a condition and fails the test with a clear message if it does not hold. The most used ones in xUnit:
| Assertion | Passes when |
|---|---|
Assert.Equal(expected, actual) |
the values are equal (also for collections, element by element) |
Assert.True(condition),
Assert.False
|
the condition is true / false |
Assert.Null(value), Assert.NotNull
|
the value is / is not null |
Assert.Contains(item, collection) |
the collection contains the item |
Assert.Empty(collection) |
the collection has no elements |
Assert.Throws<T>(action) |
the code throws exactly an exception of type
T; returns it for further checks
|
await Assert.ThrowsAsync<T>(func) |
the same for async code |
Put the expected value first: the failure message labels the two arguments "Expected" and "Actual". Testing that code throws is the same as catching the exception yourself and checking it, which this runnable version does by hand:
try
{
Billing.CalculateCost(-5m, 0.27m);
Console.WriteLine("FAIL no exception");
}
catch (ArgumentOutOfRangeException ex)
{
Console.WriteLine($"PASS {ex.GetType().Name} for parameter '{ex.ParamName}'");
}
static class Billing
{
public static decimal CalculateCost(decimal kwh, decimal pricePerKwh)
{
ArgumentOutOfRangeException.ThrowIfNegative(kwh);
return Math.Round(kwh * pricePerKwh, 2, MidpointRounding.AwayFromZero);
}
}
PASS ArgumentOutOfRangeException for parameter 'kwh'
Floating-point results need a tolerance, because a
double is rarely exactly the decimal number you expect
(see
Variables, Types and Operators). xUnit's
Assert.Equal(expected, actual, precision) overload
compares a double to a number of decimal places; the
principle is this:
double average = (0.1 + 0.2) / 3 * 3;
Console.WriteLine(average);
Console.WriteLine($"Exact: {average == 0.3}");
Console.WriteLine($"Within 1e-9: {Math.Abs(average - 0.3) < 1e-9}");
0.30000000000000004 Exact: False Within 1e-9: True
Theories: one test, many cases
A theory is an xUnit test
method marked [Theory] that takes parameters and runs
once for every set of data supplied, for example with
[InlineData] attributes. Each data row is reported as a
separate test, so you see exactly which case fails:
[Theory]
[InlineData(0, 0.27, 0)]
[InlineData(100, 0.27, 27)]
[InlineData(0.5, 0.25, 0.13)] // 0.125 rounds up, away from zero
public void CalculateCost_RoundsToWholeCents(decimal kwh, decimal pricePerKwh, decimal expected)
{
Assert.Equal(expected, Billing.CalculateCost(kwh, pricePerKwh));
}
Attribute arguments must be constants, and C# does not allow
decimal constants in attributes, so the rows are
written as int and double values; xUnit
converts them to the decimal parameters. For data that
cannot be written as constants, such as objects or dates,
[MemberData] takes the rows from a static property.
Running tests with dotnet test
dotnet test builds the test projects and runs every
test it finds, in a solution or a single project. With the v2
project above, a passing run ends like this:
Passed! - Failed: 0, Passed: 5, Skipped: 0, Total: 5, Duration: 131 ms - MeterTools.Tests.dll (net10.0)
When an assertion fails, the report names the test, shows expected and actual values, and points to the line:
Failed MeterTools.Tests.BillingTests.CalculateCost_MultipliesUsageByPrice [3 ms]
Error Message:
Assert.Equal() Failure: Values differ
Expected: 111.25
Actual: 111.24
Stack Trace:
at MeterTools.Tests.BillingTests.CalculateCost_MultipliesUsageByPrice() in tests/MeterTools.Tests/BillingTests.cs:line 18
Failed! - Failed: 1, Passed: 4, Skipped: 0, Total: 5, Duration: 84 ms - MeterTools.Tests.dll (net10.0)
dotnet test returns a non-zero exit code when a test
fails, which is what makes a
pipeline
stop. While you work, run a subset with a filter, for example
dotnet test --filter "FullyQualifiedName~Billing".
VSTest and Microsoft.Testing.Platform
Under the hood dotnet test can use two runners. The
classic VSTest is still the default in .NET 10. The
newer Microsoft.Testing.Platform (MTP) is opted
into per repository with a global.json file:
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
xUnit v3 4.x uses MTP by default, and on the .NET 10 SDK such a project refuses to run under VSTest, so it needs this file. The matching project file is shorter than the v2 one, because the runner packages are no longer needed:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="4.0.1"></PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MeterTools.Core\MeterTools.Core.csproj"></ProjectReference>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"></Using>
</ItemGroup>
</Project>
In MTP mode you name the project with
dotnet test --project tests/MeterTools.Tests, and
because a v3 test project is an executable, plain
dotnet run in its folder runs the tests too. These
details are moving quickly; check the xUnit and .NET documentation
when you set up a new test project.
Writing testable code
Some code is hard to test: a method that reads a file, calls a web API, or asks the clock for the current time gives a different result on every run. The remedy is to separate deciding from doing. Keep the logic in methods that receive everything they need as parameters and return a result, and keep the reading, writing and clock access at the edges.
Time is the classic case. A tariff that depends on the time of day
cannot be tested reliably if the method reads
DateTimeOffset.UtcNow itself. .NET 8 added the abstract
class TimeProvider for this: production code receives
TimeProvider.System, and a test passes a clock that
always says the same time:
var evening = new TariffService(new FixedClock(new DateTimeOffset(2026, 10, 3, 18, 30, 0, TimeSpan.Zero)));
Console.WriteLine($"18:30 UTC: {evening.CurrentPricePerKwh()} EUR/kWh");
var night = new TariffService(new FixedClock(new DateTimeOffset(2026, 10, 3, 2, 0, 0, TimeSpan.Zero)));
Console.WriteLine($"02:00 UTC: {night.CurrentPricePerKwh()} EUR/kWh");
class TariffService(TimeProvider clock)
{
public decimal CurrentPricePerKwh()
{
TimeOnly now = TimeOnly.FromTimeSpan(clock.GetUtcNow().TimeOfDay);
return now.IsBetween(new TimeOnly(17, 0), new TimeOnly(21, 0)) ? 0.34m : 0.27m;
}
}
// In production: new TariffService(TimeProvider.System)
class FixedClock(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
18:30 UTC: 0.34 EUR/kWh 02:00 UTC: 0.27 EUR/kWh
The same technique works for anything outside your program: define
an
interface
such as IMeterReadingSource, give the class an
implementation through its constructor (see
primary constructors), and
pass a simple stand-in in tests (a
test double). Passing
dependencies in like this is called
dependency injection; ASP.NET Core has it built in. Tests that use real files,
databases or services are
integration tests; they are valuable too, but slower, so keep most logic in plain,
fast unit tests.
Compiler warnings and analyzers
Tests check what you thought of. The compiler and its analyzers check the whole code base on every build, including cases you did not think of. Their messages have a prefix that tells you where they come from:
| Prefix | Source | Example |
|---|---|---|
CS |
the C# compiler itself | CS8602, dereference of a possibly null reference |
CA |
code quality analyzers (correctness, security, performance) | CA1305, culture-dependent parsing or formatting |
IDE |
code style analyzers | IDE1006, naming rule violation |
IL |
trimming and Native AOT analyzers | IL2026, code that needs reflection |
A Roslyn analyzer is a plug-in for the C# compiler (whose open-source code name is Roslyn) that inspects your code while it is compiled and reports diagnostics, often with an automatic fix in the IDE. It is a form of static analysis that runs on every build. The .NET code analyzers ship with the SDK, so you get them without installing anything; extra analyzer packages, for example for xUnit or for security rules, are added like any NuGet package.
By default only a small set of CA rules is active. The
AnalysisMode property turns on more:
Recommended or All. In this file the
compiler already warns about the null dereference, and
Recommended adds rules about culture-dependent code,
the kind of bug that only shows on a computer with Dutch settings:
#:property AnalysisMode=Recommended
string? note = args.Length > 0 ? args[0] : null;
Console.WriteLine(note.Length);
decimal price = decimal.Parse("0.27");
Console.WriteLine(price.ToString());
app.cs(3,19): warning CS8602: Dereference of a possibly null reference. app.cs(5,17): warning CA1305: The behavior of 'decimal.Parse(string)' could vary based on the current user's locale settings. Replace this call in '<top-level-statements-entry-point>' with a call to 'decimal.Parse(string, IFormatProvider)'. app.cs(6,19): warning CA1305: The behavior of 'decimal.ToString()' could vary based on the current user's locale settings. Replace this call in '<top-level-statements-entry-point>' with a call to 'decimal.ToString(IFormatProvider)'.
Both CA1305 warnings point at a real bug: with Dutch regional
settings, decimal.Parse("0.27") reads the dot as a
thousands separator and returns 27. The fix is to pass
CultureInfo.InvariantCulture (see
Strings and String Interpolation). Every diagnostic code has its own documentation page on
Microsoft Learn, linked from the full message.
Warnings as errors
Warnings as errors is a
build setting that makes the compiler report warnings as errors, so
the build fails until they are fixed. It is the simplest way to keep
the warning count at zero. Nullable warnings, for example, are only
warnings by default (see
Nullable Reference Types and Null Safety); with TreatWarningsAsErrors the code below does not
build:
#:property TreatWarningsAsErrors=true
string? note = args.Length > 0 ? args[0] : null;
Console.WriteLine(note.Length);
example.cs(3,19): error CS8602: Dereference of a possibly null reference.
For an existing code base with many warnings, start smaller:
WarningsAsErrors takes a list of codes and only turns
those into errors. A forgotten await (warning CS4014,
see
Asynchronous Programming) is a good first candidate:
#:property WarningsAsErrors=CS4014
SaveReadingAsync(412);
Console.WriteLine("Saved?");
static async Task SaveReadingAsync(int kwh) => await Task.Delay(10);
example.cs(2,1): error CS4014: Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.
In a project, set these properties in the .csproj or,
for all projects at once, in Directory.Build.props
(see
Projects, NuGet and Building):
<PropertyGroup>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisMode>Recommended</AnalysisMode>
</PropertyGroup>
When a warning is truly a false alarm, suppress it as narrowly as
possible: for one line with
#pragma warning disable CA1305 and
#pragma warning restore CA1305 around it, with a
comment explaining why; never by switching the whole rule off
because it is inconvenient once.
EditorConfig and dotnet format
EditorConfig is an
editor-independent file format, a text file named
.editorconfig, that stores coding style settings such
as indentation for a folder tree. .NET extends it with C# style
preferences, naming rules and the severity of each analyzer rule, so
Visual Studio, VS Code, Rider and the command-line build all apply
the same rules. dotnet new editorconfig creates a file
with the .NET defaults; a small one looks like this:
root = true [*.cs] indent_style = space indent_size = 4 # Style preference: file-scoped namespaces, reported as a warning csharp_style_namespace_declarations = file_scoped:warning # Raise or lower individual analyzer rules dotnet_diagnostic.CA1305.severity = warning dotnet_diagnostic.IDE1006.severity = warning # Naming: public members in PascalCase dotnet_naming_rule.public_members_pascal_case.symbols = public_members dotnet_naming_rule.public_members_pascal_case.style = pascal_case dotnet_naming_rule.public_members_pascal_case.severity = warning dotnet_naming_symbols.public_members.applicable_kinds = property, method, field, event dotnet_naming_symbols.public_members.applicable_accessibilities = public dotnet_naming_style.pascal_case.capitalization = pascal_case
root = true stops the search for further
.editorconfig files in parent folders. Style rules (IDE
codes) show in the editor; to make the command-line build report
them too, set EnforceCodeStyleInBuild to
true in the project.
The
code formatter
for .NET is built into the SDK: dotnet format fixes
whitespace, style and, where an automatic fix exists, analyzer
findings, following the .editorconfig. With
--verify-no-changes it changes nothing and fails if
anything would change, which is the form to use in a
pipeline:
dotnet format # fix the files in place
dotnet format --verify-no-changes # only check; exit code 2 if changes are needed
src/MeterTools.Core/Tariff.cs(2,1): error WHITESPACE: Fix whitespace formatting. Insert '\n'. src/MeterTools.Core/Tariff.cs(3,3): error WHITESPACE: Fix whitespace formatting. Insert '\s\s'. src/MeterTools.Core/Tariff.cs(5,20): warning IDE1006: Naming rule violation: These words must begin with upper case characters: pricePerKwh
From the .NET 11 SDK, dotnet format also accepts a
single file-based app (dotnet format app.cs); on the
.NET 10 SDK it needs a project or solution. Like every formatter, it
ends discussions about style: the tool decides, and code reviews can
focus on what the code does.
Naming conventions
C# naming conventions are remarkably uniform, because the .NET libraries themselves follow them and Microsoft documents them. Code that follows them reads like the framework it uses:
| Element | Convention | Example |
|---|---|---|
| Namespaces, classes, records, structs, enums | PascalCase |
MeterTools.Core, OutageService
|
| Interfaces | PascalCase with prefix I |
IOutageService |
| Methods, properties, events, public fields | PascalCase | Report, OpenCount |
| Async methods | suffix Async |
ReadMeterAsync |
| Parameters and local variables | camelCase | ticketId, normalizedId |
| Private fields | camelCase with prefix _ |
_openTickets |
| Constants | PascalCase (not ALL_CAPS) | PeakSurcharge |
| Type parameters |
T, or a name starting with T
|
T, TKey |
const decimal PeakSurcharge = 0.07m;
var service = new OutageService();
service.Report("INC-31902");
service.Report("INC-31903");
Console.WriteLine($"Open outages: {service.OpenCount}");
Console.WriteLine($"Peak price: {PriceWithSurcharge(0.27m)} EUR/kWh");
static decimal PriceWithSurcharge(decimal basePrice) => basePrice + PeakSurcharge;
interface IOutageService
{
int OpenCount { get; }
void Report(string ticketId);
}
class OutageService : IOutageService
{
private readonly List<string> _openTickets = [];
public int OpenCount => _openTickets.Count;
public void Report(string ticketId)
{
string normalizedId = ticketId.Trim().ToUpperInvariant();
_openTickets.Add(normalizedId);
}
}
Open outages: 2 Peak price: 0.34 EUR/kWh
Other habits that go with the conventions: one public type per file,
named after the type (OutageService.cs); braces on
their own lines; four spaces of indentation; and
var where the type is obvious from the right-hand side.
The IDEs and dotnet format enforce most of this once it
is in the .editorconfig.