Skip to content

Testing

Targets: net10.0, csharp-14 · Last reviewed: 2026-09-15 · Sources: ms-learn, meziantou, andrew-lock, house, dotnet-blog, mark-seemann

Tests are first-class code: same review bar, same conventions.

Framework and platform

  • Use xUnit v3 on Microsoft.Testing.Platform (MTP) for new test projects, and opt into the MTP runner in global.json. xunit.v3 ships a native MTP runner, so each test project builds to a self-contained executable that dotnet run executes directly, replacing VSTest's slower process model. dotnet test reaches it only through the runner opt-in below. Without it the SDK still routes through VSTest, which xunit.v3 4.0.0 (MTP v2) refuses on .NET 10 with Testing with VSTest target is no longer supported. On xunit.v3 3.x the same misconfiguration is worse than an error: dotnet test runs zero tests and exits 0.
{
  "sdk": { "version": "10.0.400", "rollForward": "latestFeature" },
  "test": { "runner": "Microsoft.Testing.Platform" }
}

Do not use <TestingPlatformDotnetTestSupport> instead. That property is the VSTest bridge, and it is what triggers the error above under MTP v2. MSTest and NUnit also run on MTP but xUnit's constructor-per-test isolation model and ecosystem weight make it the default; TUnit is promising but too young for a track record. (Microsoft Learn: Microsoft.Testing.Platform overview, Microsoft Learn: What's new in .NET 10: SDK)

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <OutputType>Exe</OutputType>
    <UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="xunit.v3" />
  </ItemGroup>
</Project>

The reference carries no version: central package management is mandatory (see project-structure.md), so the pin lives in templates/Directory.Packages.props. No Microsoft.NET.Test.Sdk reference: that is the VSTest world, and the xunit.v3 package is self-sufficient under MTP. Do not pin Microsoft.Testing.Platform yourself either: xunit.v3 4.0.0 dropped MTP v1 and brings its own v2, and a separate pin overrides it (via transitive pinning under CPM) into a runtime TypeLoadException.

  • Moving an existing suite from VSTest to MTP breaks two things the build does not catch. Review by 2027-11-30. This is migration guidance: drop it once VSTest-era workflows are rare, or re-date it.
  • --logger is not an MTP option. VSTest's --logger "console;verbosity=normal" or --logger trx makes the test application exit with code 5, invalid command-line arguments, without running a test. Every CI line that carries it fails the moment global.json opts in. MTP has no central logger switch, because each reporter registers its own option. Use --output Detailed for console verbosity, and --report-xunit-trx for TRX, which xunit.v3 builds in. The --report-trx in Microsoft's migration guide belongs to the Microsoft.Testing.Extensions.TrxReport package, and a test application without it rejects the option the same way. (Microsoft Learn: Migration guide from VSTest to Microsoft.Testing.Platform, Microsoft Learn: Microsoft.Testing.Platform exit codes, xUnit.net: Microsoft Testing Platform)
  • A multi-targeted test project runs its target frameworks at the same time. Each target framework builds its own test executable, and MTP runs test modules in parallel by default, up to Environment.ProcessorCount, where VSTest ran them in turn. A fixture holding a fixed temp directory, port, file or database name now races the other framework's process: one run's Dispose deletes the directory the other is still writing to. Make that state unique per instance, for example with Guid.NewGuid() in the name. Do not set --max-parallel-test-modules 1 instead, which hides the race and gives back the speed MTP was adopted for. (Microsoft Learn: dotnet test command with Microsoft.Testing.Platform)

  • Getting off VSTest gains more speed than switching framework. Meziantou benchmarked xUnit v3 4.0.0, NUnit 4.6.1, MSTest 4.4.0 and TUnit 1.65.68 on the .NET 10 SDK at up to 10,000 tests. The legacy VSTest path ran 4.9× slower than the MTP executable for xUnit v3, 5.1× slower for MSTest and 1.5× slower for NUnit, a gap he puts at three to four times what the choice of framework is worth. Per-test marginal cost separates the frameworks far less: 15µs for MSTest, 42µs for TUnit, 56µs for xUnit v3, 85µs for NUnit. Read those figures as a reason to keep the global.json opt-in above, not as a reason to leave xUnit. (Meziantou: Benchmarking .NET test frameworks: xUnit v3, NUnit, MSTest, and TUnit)

  • If the app ships Native AOT, add a representative native test lane beside the managed suite. The managed run stays the fast-feedback lane on every change. The native lane publishes one C# test project with <PublishAot>true</PublishAot> and runs the resulting executable, because trimming and the reflection-free serialization that comes with it change behaviour a managed run cannot observe, so a green suite can still fail once published. System.Text.Json is the usual first casualty, throwing InvalidOperationException because reflection-based serialization is disabled; the fix is a [JsonSerializable] context on the app side rather than anything wrong with the test. That project references xunit.v3.aot.mtp-v2 in place of xunit.v3: the AOT variant replaces the reflection-based package, so it cannot share a project with the managed suite, and F# test projects stay on the managed lane. NUnit has no Native AOT support at all. (xUnit.net: Testing with Native AOT, .NET Blog: Test what you ship: MSTest and Native AOT, Meziantou: Benchmarking .NET test frameworks: xUnit v3, NUnit, MSTest, and TUnit)

Naming and structure

  • House: name tests UnitOfWork_Scenario_ExpectedBehaviour and structure bodies with explicit Arrange / Act / Assert comments. (HOUSE-OPINIONS.md)
public class BasketTests
{
    [Fact]
    public void Total_MultipleItemsAdded_SumsAllItemPrices()
    {
        // Arrange
        var basket = new Basket();
        basket.Add(10.00m);
        basket.Add(2.50m);

        // Act
        var total = basket.Total;

        // Assert
        Assert.Equal(12.50m, total);
    }
}

Integration testing

Deterministic time

  • Test time-dependent code through an injected TimeProvider with FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing), never with Thread.Sleep or the real clock. How to drive it, its large-Advance caveat, and the awkward instants worth exercising (DST gaps and overlaps, leap days, ISO-week year boundaries) are in datetime.md, along with the analyzer rules that already ban hand-rolled clock abstractions in this repository's templates.
  • Give a test one clock: construct the FakeTimeProvider with an explicit start instant, and derive every other date in the test from it. Fixed dates are not the problem; a test probing an awkward instant has to name it. The rule is where the literal lives: once, as the provider's start, where its choice reads as deliberate. From there, arrange with time.GetUtcNow(), move with Advance, and assert the code's time-derived outputs against time.Start plus the span, so the relationship between cause and expected effect is visible in the test body. Two literal dates that happen to be six minutes apart are correct today and opaque at the next review, and a hand-typed expected value silently re-encodes the arithmetic the code under test is supposed to be doing. The two constructor overloads that break this rule are in datetime.md. (Andrew Lock: Avoiding flaky tests with TimeProvider and ITimer for Advance and GetUtcNow)

Before: two unrelated literals, and the reader diffs them to learn the test's intent.

public class TokenValidatorTests
{
    [Fact]
    public void IsExpired_LifetimeElapsed_ReturnsTrue()
    {
        // Arrange
        var token = new Token(issuedAt: new DateTimeOffset(2026, 10, 25, 0, 57, 0, TimeSpan.Zero), lifetime: TimeSpan.FromMinutes(5));
        var validator = new TokenValidator(new FakeTimeProvider(new DateTimeOffset(2026, 10, 25, 1, 3, 0, TimeSpan.Zero)));

        // Act
        var expired = validator.IsExpired(token);

        // Assert
        Assert.True(expired);
    }
}

After: one clock, one deliberately chosen start, and the elapsed time is the test. The start sits six minutes before the EU fall-back in a zone that observes it, so the local wall clock runs from 02:57 CEST back to 02:03 CET while only six minutes elapse: a validator that compared wall-clock times would call the token unexpired, and this test would catch it.

public class TokenValidatorTests
{
    [Fact]
    public void IsExpired_LifetimeElapsedAcrossFallBack_ReturnsTrue()
    {
        // Arrange
        var time = new FakeTimeProvider(new DateTimeOffset(2026, 10, 25, 0, 57, 0, TimeSpan.Zero));
        time.SetLocalTimeZone(TimeZoneInfo.FindSystemTimeZoneById("Europe/Paris"));
        var lifetime = TimeSpan.FromMinutes(5);
        var token = new Token(issuedAt: time.GetUtcNow(), lifetime);
        var validator = new TokenValidator(time);

        // Act
        time.Advance(TimeSpan.FromMinutes(6));

        // Assert
        Assert.Equal(time.Start.Add(lifetime), token.ExpiresAt);
        Assert.True(validator.IsExpired(token));
    }
}

Output and scale

  • Use snapshot testing for complex serialized output (generated code, API payloads, rendered documents) instead of asserting field-by-field. (Meziantou: Snapshot testing)

Snapshot libraries locate the .verified files from the test's own source path, which deterministic builds rewrite to /_/…, so a project with ContinuousIntegrationBuild/DeterministicSourcePaths on cannot read or write its snapshots. Restore the mapping at startup rather than turning determinism off: an MSBuild target emitting a [ModuleInitializer] that registers SourceRoot's MappedPath against the real directory keeps both properties. (Meziantou: Reproducible builds and snapshot testing)

  • Shard slow CI test suites deterministically across parallel jobs, but measure first: sharding pays off for CPU-bound suites far more than IO-bound ones. (Meziantou: Test sharding)

Coverage

Seeing tests fail

  • See every test fail on its assertion before trusting it to pass. A test that has never failed may hold a tautological assertion or preserve a bug as expected behaviour, and coverage or a green run cannot tell it from a working one. Writing the test first is not enough on its own: a test that failed only because the member did not exist yet may pass afterwards whatever it is given. So once a test passes, sabotage the code under test once per assertion, see the test fail on that assertion's line, then revert and see it pass. (Seemann: Tautological assertion, Seemann: Epistemology of software, Seemann: Empirical Characterization Testing)
  • Critique a passing suite as the Devil's Advocate. Write the simplest wrong implementation that still passes every test, then add the test case that catches it. Stop when producing such an implementation means going out of your way. A round trip is the usual gap: two identity functions pass any test that compares only the output with the input, so assert the intermediate value as well. (Seemann: Devil's advocate)
[Fact]
public void FahrenheitToCelsius_RoundTrip_ReturnsOriginalReading()
{
    // Arrange
    var celsius = 37d;

    // Act
    var fahrenheit = Temperature.CelsiusToFahrenheit(celsius);
    var roundTripped = Temperature.FahrenheitToCelsius(fahrenheit);

    // Assert
    Assert.Equal(98.6d, fahrenheit, 1e-9); // fails if both conversions return their input
    Assert.Equal(celsius, roundTripped, 1e-9);
}

F#

  • F# test projects use the same xUnit v3 stack: [<Fact>] on let-bound functions, no test class needed. See fsharp.md for the stance and an example.