Testing

This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

NET treats testing as part of the toolchain rather than an add-on: a test project is an ordinary project with

a test SDK reference, dotnet test runs it, and the same command works in every IDE and in CI. Three frameworks dominate — xUnit, NUnit and MSTest — and they differ far less than their syntax suggests. This page uses xUnit as the primary example, since it is the default in most modern templates, and gives the NUnit and MSTest equivalents alongside.

A Test Project

dotnet new xunit -o Sample.Tests
dotnet add Sample.Tests/Sample.Tests.csproj reference Sample/Sample.csproj
dotnet test
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
    <PackageReference Include="xunit" Version="2.9.2" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
    <PackageReference Include="coverlet.collector" Version="6.0.2" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="../Sample/Sample.csproj" />
  </ItemGroup>

</Project>

The conventions the ecosystem expects: one test project per production project, named <Project>.Tests; <IsPackable>false</IsPackable> so it is never published; and [InternalsVisibleTo] on the production project when tests need its internals — see Namespaces, Assemblies and Projects.

Running Tests

dotnet test                                              # the whole solution
dotnet test Sample.Tests/Sample.Tests.csproj
dotnet test --filter "FullyQualifiedName~Geometry"       # by name
dotnet test --filter "Category=Integration"              # by trait
dotnet test --filter "FullyQualifiedName!~Slow"
dotnet test --logger "trx;LogFileName=results.trx"       # a CI-readable report
dotnet test --collect:"XPlat Code Coverage"
dotnet test -- xunit.parallelizeAssembly=true            # runner settings after --
dotnet watch test                                        # re-run on every save

Writing Tests with xUnit

Facts

A [Fact] is a test with no parameters. The convention for the body is arrange, act, assert, and for the name MethodUnderTest_Scenario_ExpectedResult:

using Xunit;

public sealed class BasketTests
{
    [Fact]
    public void Add_WithNewItem_IncreasesCount()
    {
        // Arrange
        var basket = new Basket();

        // Act
        basket.Add("apple");

        // Assert
        Assert.Equal(1, basket.Count);
    }

    [Fact]
    public void Add_WithNullItem_Throws()
    {
        var basket = new Basket();

        ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() => basket.Add(null!));

        Assert.Equal("item", exception.ParamName);
    }
}

public sealed class Basket
{
    private readonly List<string> _items = [];

    public int Count => _items.Count;

    public void Add(string item)
    {
        ArgumentNullException.ThrowIfNull(item);
        _items.Add(item);
    }
}

Two xUnit specifics worth knowing immediately: there is no [SetUp] method — the constructor is the setup, and IDisposable.Dispose is the teardown — and a new instance of the test class is created for every test, which is what makes tests independent by construction.

Theories

A [Theory] is a parameterised test; each data row is reported as a separate test:

using Xunit;

public sealed class MathTests
{
    // Inline constants: the simplest form.
    [Theory]
    [InlineData(0, 0)]
    [InlineData(1, 1)]
    [InlineData(2, 4)]
    [InlineData(-3, 9)]
    public void Square_ReturnsProduct(int input, int expected) =>
        Assert.Equal(expected, input * input);

    // MemberData: rows from a property, field or method -- for anything not a constant.
    [Theory]
    [MemberData(nameof(DivisionCases))]
    public void Divide_ReturnsQuotient(decimal numerator, decimal denominator, decimal expected) =>
        Assert.Equal(expected, numerator / denominator);

    public static TheoryData<decimal, decimal, decimal> DivisionCases =>
        new()
        {
            { 10m, 2m, 5m },
            { 9m, 3m, 3m },
            { 1m, 4m, 0.25m },
        };

    // ClassData: rows from a type, for reuse across test classes.
    [Theory]
    [ClassData(typeof(EmptyStrings))]
    public void IsBlank_ReturnsTrue(string candidate) =>
        Assert.True(string.IsNullOrWhiteSpace(candidate));

    private sealed class EmptyStrings : TheoryData<string>
    {
        public EmptyStrings()
        {
            Add(string.Empty);
            Add(" ");
            Add("\t\n");
        }
    }
}

TheoryData<…> is strongly typed, so a mismatched row is a compile error rather than a run-time surprise — prefer it to IEnumerable<object[]>.

Fixtures: Sharing Expensive Setup

Because xUnit constructs the test class per test, anything expensive goes in a fixture, whose lifetime is managed by the framework:

using Xunit;

// One instance shared by every test in ONE test class.
public sealed class DatabaseFixture : IDisposable
{
    public DatabaseFixture() => Connection = "opened";

    public string Connection { get; }

    public void Dispose()
    {
        // close the connection
    }
}

public sealed class RepositoryTests : IClassFixture<DatabaseFixture>
{
    private readonly DatabaseFixture _fixture;

    public RepositoryTests(DatabaseFixture fixture) => _fixture = fixture;

    [Fact]
    public void Connection_IsOpen() => Assert.Equal("opened", _fixture.Connection);
}

// One instance shared across SEVERAL test classes, via a collection.
[CollectionDefinition("database")]
public sealed class DatabaseCollection : ICollectionFixture<DatabaseFixture>
{
}

[Collection("database")]
public sealed class OrderRepositoryTests
{
    private readonly DatabaseFixture _fixture;

    public OrderRepositoryTests(DatabaseFixture fixture) => _fixture = fixture;

    [Fact]
    public void Connection_IsShared() => Assert.Equal("opened", _fixture.Connection);
}

Note the second effect of a collection: xUnit runs test classes in parallel by default, but never two classes in the same collection — which is exactly what you want for tests sharing a database.

IAsyncLifetime

When setup or teardown needs await, implement IAsyncLifetime rather than blocking in a constructor:

using Xunit;

public sealed class ApiTests : IAsyncLifetime
{
    private HttpClient _client = null!;

    public async Task InitializeAsync()
    {
        _client = new HttpClient();
        await Task.Yield();             // e.g. start a container, run migrations, seed data
    }

    public Task DisposeAsync()
    {
        _client.Dispose();
        return Task.CompletedTask;
    }

    [Fact]
    public void Client_IsReady() => Assert.NotNull(_client);
}

NUnit and MSTest, in Brief

The same test in all three frameworks:

Concept xUnit NUnit MSTest

Test class

(no attribute)

[TestFixture]

[TestClass]

Test

[Fact]

[Test]

[TestMethod]

Parameterised

[Theory] + [InlineData]

[TestCase(…)]

[DataTestMethod] + [DataRow]

External data

[MemberData] / [ClassData]

[TestCaseSource]

[DynamicData]

Per-test setup

constructor

[SetUp]

[TestInitialize]

Per-test teardown

Dispose

[TearDown]

[TestCleanup]

Per-class setup

IClassFixture<T>

[OneTimeSetUp]

[ClassInitialize]

Skip

[Fact(Skip = "reason")]

[Ignore("reason")]

[Ignore]

Categorise

[Trait("Category", "…")]

[Category("…")]

[TestCategory("…")]

Expected exception

Assert.Throws<T>(…)

Assert.Throws<T>(…)

Assert.ThrowsException<T>(…)

// NUnit
using System.Collections.Generic;
using NUnit.Framework;

[TestFixture]
public sealed class NUnitExample
{
    private List<string> _basket = null!;

    [SetUp]
    public void SetUp() => _basket = [];

    [Test]
    public void Add_IncreasesCount()
    {
        _basket.Add("apple");
        Assert.That(_basket.Count, Is.EqualTo(1));
    }

    [TestCase(2, 4)]
    [TestCase(3, 9)]
    public void Square_ReturnsProduct(int input, int expected) =>
        Assert.That(input * input, Is.EqualTo(expected));
}
// MSTest
using System.Collections.Generic;
using Microsoft.VisualStudio.TestTools.UnitTesting;

[TestClass]
public sealed class MsTestExample
{
    private List<string> _basket = null!;

    [TestInitialize]
    public void Initialize() => _basket = [];

    [TestMethod]
    public void Add_IncreasesCount()
    {
        _basket.Add("apple");
        Assert.AreEqual(1, _basket.Count);
    }

    [DataTestMethod]
    [DataRow(2, 4)]
    [DataRow(3, 9)]
    public void Square_ReturnsProduct(int input, int expected) =>
        Assert.AreEqual(expected, input * input);
}

NUnit’s constraint model (Assert.That(actual, Is.EqualTo(expected))) reads well and composes (Is.GreaterThan(0).And.LessThan(10)). MSTest is the most conservative of the three and ships with Visual Studio. xUnit’s per-test instance and constructor-as-setup design is the most opinionated, and the reason many teams choose it.

The Microsoft Testing Platform

Microsoft.Testing.Platform is the newer test host that replaces VSTest. Instead of a separate runner process, the test project builds into a self-contained executable that runs the tests directly. The benefits are faster startup, a smaller dependency set, proper exit codes, and support for Native AOT and trimmed test projects:

<PropertyGroup>
  <OutputType>Exe</OutputType>
  <UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
  <TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
</PropertyGroup>
dotnet run --project Sample.Tests            # run the tests as an ordinary executable
dotnet test                                  # still works, now through the new platform
./Sample.Tests --filter "Geometry" --report-trx

All three frameworks now support it (xUnit v3, NUnit and MSTest), and it is the direction the .NET testing tooling is moving.

Assertions

xUnit’s built-in assertions cover the common cases:

using Xunit;

public sealed class AssertionExamples
{
    [Fact]
    public void Examples()
    {
        // Equality -- uses IEquatable/Equals, and structural equality for collections.
        Assert.Equal(4, 2 + 2);
        Assert.Equal(0.1 + 0.2, 0.3, precision: 10);         // floating point: always with a tolerance
        Assert.NotEqual("a", "b");
        Assert.Same(string.Empty, string.Empty);             // reference identity

        // Booleans and null.
        Assert.True(1 < 2);
        Assert.False(string.IsNullOrEmpty("x"));
        Assert.Null(null);
        Assert.NotNull("x");

        // Collections.
        int[] values = [1, 2, 3];
        Assert.Contains(2, values);
        Assert.DoesNotContain(9, values);
        Assert.Empty(Array.Empty<int>());
        Assert.Single(new[] { 1 });
        Assert.Equal([1, 2, 3], values);
        Assert.All(values, static v => Assert.True(v > 0));
        Assert.Collection(
            values,
            static first => Assert.Equal(1, first),
            static second => Assert.Equal(2, second),
            static third => Assert.Equal(3, third));

        // Strings.
        Assert.StartsWith("He", "Hello");
        Assert.Matches("^H.*o$", "Hello");

        // Ranges and types.
        Assert.InRange(5, 1, 10);
        object value = "text";
        string text = Assert.IsType<string>(value);
        Assert.NotEmpty(text);
    }

    [Fact]
    public async Task Exceptions()
    {
        // Synchronous. Pass a method group (or an Action): a throw-only lambda is
        // convertible to Func<Task> too, and xUnit makes that overload an error.
        InvalidOperationException sync = Assert.Throws<InvalidOperationException>(Boom);
        Assert.Equal("boom", sync.Message);

        // Asynchronous -- note ThrowsAsync, and that it must be awaited.
        await Assert.ThrowsAsync<InvalidOperationException>(BoomAsync);

        // Any derived type.
        Assert.ThrowsAny<ArgumentException>(BoomArgument);
    }

    private static void Boom() => throw new InvalidOperationException("boom");

    private static async Task BoomAsync()
    {
        await Task.Yield();
        throw new InvalidOperationException("async boom");
    }

    private static void BoomArgument() => throw new ArgumentNullException("p");
}

A fluent assertion library (for example FluentAssertions or Shouldly) is a common addition; the trade-off is a nicer failure message and a more readable chain against another dependency and another dialect for readers to learn. Either way, the rule that matters is one logical assertion per test — a test asserting five unrelated things tells you far less when it fails.

Test Doubles

Hand-Written Fakes

Where the interface is small, a hand-written fake is often clearer than a mocking framework, and it cannot go stale:

public interface IClock
{
    DateTimeOffset UtcNow { get; }
}

public sealed class FixedClock(DateTimeOffset now) : IClock
{
    public DateTimeOffset UtcNow { get; } = now;
}

public sealed class SessionService(IClock clock)
{
    public bool IsExpired(DateTimeOffset issuedAt, TimeSpan lifetime) =>
        clock.UtcNow - issuedAt > lifetime;
}
using Xunit;

public sealed class SessionServiceTests
{
    [Fact]
    public void IsExpired_AfterLifetime_ReturnsTrue()
    {
        var clock = new FixedClock(new DateTimeOffset(2026, 9, 11, 12, 0, 0, TimeSpan.Zero));
        var service = new SessionService(clock);

        bool expired = service.IsExpired(clock.UtcNow.AddHours(-2), TimeSpan.FromHours(1));

        Assert.True(expired);
    }
}

Note that .NET’s own TimeProvider (and FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing) now fills this specific role, including for Task.Delay and timers — which makes time-dependent code testable without a Thread.Sleep anywhere.

Mocking Frameworks

NSubstitute has the least ceremonial syntax:

// Sketch: NSubstitute usage (the package is not referenced by this documentation build).
//
// var repository = Substitute.For<IOrderRepository>();
// repository.FindAsync(42, Arg.Any<CancellationToken>()).Returns(new Order(42));
//
// var service = new OrderService(repository);
// Order? order = await service.GetAsync(42, CancellationToken.None);
//
// Assert.Equal(42, order!.Id);
// await repository.Received(1).FindAsync(42, Arg.Any<CancellationToken>());
// await repository.DidNotReceive().DeleteAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
public interface IOrderRepository
{
    Task<Order?> FindAsync(int id, CancellationToken cancellationToken);

    Task DeleteAsync(int id, CancellationToken cancellationToken);
}

public sealed record Order(int Id);

public sealed class OrderService(IOrderRepository repository)
{
    public Task<Order?> GetAsync(int id, CancellationToken cancellationToken) =>
        repository.FindAsync(id, cancellationToken);
}

Moq is the long-established alternative, with an explicit Setup/Verify vocabulary (mock.Setup(r ⇒ r.Find(42)).Returns(order); mock.Verify(r ⇒ r.Find(42), Times.Once);) and a .Object property to get the instance.

Guidance that applies to both: mock only what you own and what is genuinely a boundary (a repository, a clock, an HTTP client). Do not mock types you do not control, do not mock value objects, and be wary of tests that assert on a long sequence of interactions — they test the implementation rather than the behaviour, and they break on every refactor.

Test Data Builders

For an aggregate with many fields, a builder keeps each test’s relevant data visible and everything else out of the way:

public sealed record Customer(string Name, string Email, int LoyaltyPoints, bool IsActive);

public sealed class CustomerBuilder
{
    private string _name = "Test Customer";
    private string _email = "test@example.com";
    private int _points;
    private bool _active = true;

    public CustomerBuilder WithName(string name)
    {
        _name = name;
        return this;
    }

    public CustomerBuilder WithPoints(int points)
    {
        _points = points;
        return this;
    }

    public CustomerBuilder Inactive()
    {
        _active = false;
        return this;
    }

    public Customer Build() => new(_name, _email, _points, _active);
}
using Xunit;

public sealed class LoyaltyTests
{
    [Fact]
    public void Inactive_CustomerEarnsNothing()
    {
        // Only the two things this test cares about are stated.
        Customer customer = new CustomerBuilder().WithPoints(500).Inactive().Build();

        Assert.False(customer.IsActive);
        Assert.Equal(500, customer.LoyaltyPoints);
    }
}

A record’s `with expression achieves much the same for simple cases, starting from a shared default instance — see Records.

Testing Asynchronous Code

An asynchronous test returns Task and is awaited by the framework. Never block:

using Xunit;

public sealed class AsyncTests
{
    [Fact]
    public async Task LoadAsync_ReturnsValue()
    {
        var service = new Loader();

        string value = await service.LoadAsync("key", CancellationToken.None);

        Assert.Equal("KEY", value);
    }

    [Fact]
    public async Task LoadAsync_WhenCancelled_Throws()
    {
        var service = new Loader();
        using var cts = new CancellationTokenSource();
        await cts.CancelAsync();

        // The framework-standard exception for cancellation.
        await Assert.ThrowsAsync<TaskCanceledException>(() => service.LoadAsync("key", cts.Token));
    }

    [Fact]
    public async Task LoadAsync_RespectsATimeout()
    {
        var service = new Loader();

        // Never let a hanging test hang the suite: bound it.
        string value = await service.LoadAsync("key", CancellationToken.None)
                                    .WaitAsync(TimeSpan.FromSeconds(5));

        Assert.NotEmpty(value);
    }
}

public sealed class Loader
{
    public async Task<string> LoadAsync(string key, CancellationToken cancellationToken)
    {
        await Task.Delay(1, cancellationToken);
        return key.ToUpperInvariant();
    }
}

Three rules: an async test returns Task, never void (an async void test cannot be awaited and its failures are lost); never call .Result or .Wait(); and assert the cancellation path explicitly, since a token that is accepted and ignored is a common bug. See Async and Await.

Code Coverage

Coverlet is the standard cross-platform coverage collector, and ships in the test templates:

dotnet test --collect:"XPlat Code Coverage"
# writes TestResults/<guid>/coverage.cobertura.xml

dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:"**/coverage.cobertura.xml" -targetdir:coveragereport -reporttypes:Html

Thresholds can fail the build:

dotnet test --collect:"XPlat Code Coverage" \
  -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura
<PropertyGroup>
  <CollectCoverage>true</CollectCoverage>
  <CoverletOutputFormat>cobertura</CoverletOutputFormat>
  <Threshold>80</Threshold>
  <ThresholdType>line,branch</ThresholdType>
</PropertyGroup>

The caveat that always bears repeating: coverage measures which lines ran, not whether anything was verified. It is a good tool for finding untested code and a poor one for judging test quality. Branch coverage is more informative than line coverage, and an uncovered catch block is usually worth more attention than an uncovered property getter.

Integration Tests

Unit tests exercise one unit with its collaborators faked. Integration tests exercise the real wiring, and in ASP.NET Core the framework provides WebApplicationFactory<TEntryPoint>, which starts the whole application in-memory and hands back an HttpClient — no ports, no process. Combined with Testcontainers for a real database in Docker, this gives high-fidelity tests that still run on a laptop.

That material belongs with the framework: see the ASP.NET Reference, and in particular its ASP.NET Core integration-testing pages, rather than being duplicated here.

Practical Guidance

  • One assertion concept per test, and a name that states scenario and expectation.

  • Arrange-act-assert, with blank lines between the three; no logic in a test.

  • Keep tests independent and order-independent — xUnit’s per-test instance enforces this; do not defeat it with static state.

  • Prefer [Theory] to copy-pasted near-identical tests.

  • Fake at boundaries you own; do not mock what you do not control.

  • Test the cancellation and failure paths, not only the happy one.

  • Treat test code as production code: it is refactored, reviewed and held to the same conventions.

  • Use coverage to find gaps, never as a target to hit.

See Also