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 thatdotnet runexecutes directly, replacing VSTest's slower process model.dotnet testreaches 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 withTesting with VSTest target is no longer supported. On xunit.v3 3.x the same misconfiguration is worse than an error:dotnet testruns 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.
--loggeris not an MTP option. VSTest's--logger "console;verbosity=normal"or--logger trxmakes the test application exit with code 5, invalid command-line arguments, without running a test. Every CI line that carries it fails the momentglobal.jsonopts in. MTP has no central logger switch, because each reporter registers its own option. Use--output Detailedfor console verbosity, and--report-xunit-trxfor TRX, which xunit.v3 builds in. The--report-trxin Microsoft's migration guide belongs to theMicrosoft.Testing.Extensions.TrxReportpackage, 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'sDisposedeletes the directory the other is still writing to. Make that state unique per instance, for example withGuid.NewGuid()in the name. Do not set--max-parallel-test-modules 1instead, 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.jsonopt-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.Jsonis the usual first casualty, throwingInvalidOperationExceptionbecause reflection-based serialization is disabled; the fix is a[JsonSerializable]context on the app side rather than anything wrong with the test. That project referencesxunit.v3.aot.mtp-v2in place ofxunit.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_ExpectedBehaviourand 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¶
- Integration-test ASP.NET Core apps with
WebApplicationFactory<TEntryPoint>instead of unit-testing controllers or endpoint handlers. It boots the real app in-memory (routing, model binding, filters, DI, middleware all live) viaMicrosoft.AspNetCore.Mvc.Testing, so a handful of factory-based tests catch the wiring bugs that controller unit tests structurally cannot. Unit-test the domain logic the endpoints call, not the endpoints themselves. (Microsoft Learn: Integration tests in ASP.NET Core, Andrew Lock: Should you unit-test API/MVC controllers?) - Override services for tests through
ConfigureTestServices, scoped to the test withWithWebHostBuilder. (Microsoft Learn: Integration tests in ASP.NET Core)
Deterministic time¶
- Test time-dependent code through an injected
TimeProviderwithFakeTimeProvider(Microsoft.Extensions.TimeProvider.Testing), never withThread.Sleepor the real clock. How to drive it, its large-Advancecaveat, 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
FakeTimeProviderwith 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 withtime.GetUtcNow(), move withAdvance, and assert the code's time-derived outputs againsttime.Startplus 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 forAdvanceandGetUtcNow)
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¶
- Collect line coverage on every CI run, and read it as a pointer to untested code, not a quality score. Coverage shows which lines ran, not whether a test asserted anything about them, so a high number is no proof of good tests. (Microsoft Learn: Use code coverage for unit testing, Meziantou: Is the code coverage a sufficient metric?)
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>]onlet-bound functions, no test class needed. See fsharp.md for the stance and an example.