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_RejectsNegativeUsage says 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.