Tests with xUnit v3
Write xUnit v3 facts and theories, share fixtures at the right scope, pass the test cancellation token and run them with dotnet test.
[Theory]
[InlineData("100.00", "10.00")]
public void Discount(string subtotal, string expected) { }
[Fact(Timeout = 5_000)]
public async Task Reserves() => await stock.ReserveAsync(TestContext.Current.CancellationToken);An xUnit.net v3 test project is a console application. The xunit.v3 package (4.0.2) generates an entry point, so the project builds to an executable that discovers and runs its own tests in its own process. dotnet test, the IDE and a plain dotnet run all just start that executable. Test syntax barely changed from v2; what matters is the lifecycle, the data attributes, cancellation and how the runner is wired.
Facts, theories and data
[Fact] marks a test without parameters; [Theory] runs once per data row. [InlineData] takes attribute constants only, so decimal, DateTimeOffset and records cannot appear in it: pass strings and parse them with CultureInfo.InvariantCulture, or point [MemberData(nameof(Orders))] at a static TheoryData<T1, T2> property, which the compiler type-checks row by row. Each row is reported as its own case, so one wrong discount band fails one row, not the whole theory.
Lifecycle and sharing
| Scope | Mechanism | Typical content |
|---|---|---|
| One test | Constructor, IDisposable, IAsyncLifetime | Fresh objects: xUnit creates a new class instance for every test |
| One class | IClassFixture<T> | A WebApplicationFactory |
| Several classes | [CollectionDefinition] and ICollectionFixture<T> | A database the classes take turns using |
| Whole run | [assembly: AssemblyFixture(typeof(T))] | One container for every test |
In v3 IAsyncLifetime declares ValueTask InitializeAsync() and inherits IAsyncDisposable. Fixtures and ITestOutputHelper (now in the Xunit namespace) arrive through the test class constructor.
Parallelism
By default each test class is its own collection. Collections run in parallel; tests inside one collection run one after another. Two classes that share a static field, a file or the same database rows therefore collide only on some runs, the classic suite that fails one build in ten. Put them in one named collection or give every test its own data.
Cancellation and TestContext
TestContext.Current.CancellationToken is cancelled when the test exceeds its Timeout or the run is aborted. Pass it to every async call; the bundled analyzer warns (xUnit1051) when you forget. Without it, a test awaiting a hung HTTP call holds the CI job until the job's own timeout. v3 rejects async void tests, and Assert.Skip("reason") lets a test skip itself at run time.
VSTest or Microsoft.Testing.Platform
VSTest, the older runner, needs Microsoft.NET.Test.Sdk and the xunit.runner.visualstudio adapter. Microsoft.Testing.Platform (MTP) is lighter: set UseMicrosoftTestingPlatformRunner in the project and opt dotnet test into MTP mode in global.json, which the .NET 10 SDK supports. Choose MTP for new solutions and use one mode for the whole solution.
dotnet test # every test project in the solution
dotnet test --project tests/Orders.UnitTests # one project
dotnet run --project tests/Orders.UnitTests # the test executable on its ownExample
using System.Globalization;
using Xunit;
public sealed record OrderLine(string Sku, int Quantity, decimal UnitPrice);
public static class OrderPricing
{
public static decimal Subtotal(IEnumerable<OrderLine> lines) => lines.Sum(l => l.Quantity * l.UnitPrice);
// 10 percent off from 100.00, rounded half away from zero as finance expects.
public static decimal Discount(decimal subtotal) =>
subtotal >= 100m ? Math.Round(subtotal * 0.10m, 2, MidpointRounding.AwayFromZero) : 0m;
}
public interface IStockClient
{
Task<int> AvailableAsync(string sku, CancellationToken ct);
}
public sealed class StockCheck(IStockClient stock)
{
public async Task<bool> CanFulfilAsync(IReadOnlyList<OrderLine> lines, CancellationToken ct)
{
foreach (var line in lines)
{
if (await stock.AvailableAsync(line.Sku, ct) < line.Quantity) return false;
}
return true;
}
}
public sealed class OrderPricingTests
{
[Fact]
public void Subtotal_of_an_empty_order_is_zero() => Assert.Equal(0m, OrderPricing.Subtotal([]));
[Theory]
[InlineData("99.99", "0")]
[InlineData("100.00", "10.00")]
[InlineData("100.05", "10.01")] // 10.005 rounds up here; banker's rounding would give 10.00
public void Discount_starts_at_one_hundred(string subtotal, string expected)
{
var actual = OrderPricing.Discount(decimal.Parse(subtotal, CultureInfo.InvariantCulture));
Assert.Equal(decimal.Parse(expected, CultureInfo.InvariantCulture), actual);
}
public static TheoryData<OrderLine[], decimal> Orders => new()
{
{ [new("KETTLE-BLUE", 2, 34.50m)], 69.00m },
{ [new("KETTLE-BLUE", 1, 34.50m), new("MUG-WHITE", 4, 7.25m)], 63.50m },
};
[Theory]
[MemberData(nameof(Orders))]
public void Subtotal_adds_every_line(OrderLine[] lines, decimal expected) =>
Assert.Equal(expected, OrderPricing.Subtotal(lines));
}
public sealed class StockCheckTests
{
private sealed class FixedStock(int available) : IStockClient
{
public Task<int> AvailableAsync(string sku, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
return Task.FromResult(available);
}
}
[Fact(Timeout = 5_000)]
public async Task Order_larger_than_stock_cannot_be_fulfilled()
{
var check = new StockCheck(new FixedStock(available: 3));
var canFulfil = await check.CanFulfilAsync(
[new OrderLine("MUG-WHITE", 4, 7.25m)], TestContext.Current.CancellationToken);
Assert.False(canFulfil);
}
}When to use it
- Cover every discount band of the pricing rules with one theory whose rows are listed in a TheoryData property, so a new band is one added line.
- Load a 2 MB price list once per test run with an assembly fixture instead of once per test class.
- Stop a test that awaits a stuck inventory call after 5 seconds with Timeout and TestContext.Current.CancellationToken instead of hanging the pipeline.
More examples
The test project file
<code>OutputType</code> is <code>Exe</code> because a v3 test project is a self-hosting executable; the package generates the <code>Main</code> method, so do not write one. <code>UseMicrosoftTestingPlatformRunner</code> selects Microsoft.Testing.Platform instead of VSTest, which is why neither <code>Microsoft.NET.Test.Sdk</code> nor the Visual Studio adapter is referenced. The global <code>Using</code> saves a <code>using Xunit;</code> line in every file. With Central Package Management the <code>Version</code> attributes move to <code>Directory.Packages.props</code>.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="4.0.2" />
<PackageReference Include="NSubstitute" Version="6.2.0" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.11.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Orders.Domain\Orders.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>Per-test state, assembly fixtures and output
The fixture loads a file once for the whole run, while <code>_cart</code> is rebuilt for each test because xUnit never reuses a test class instance. That is why the second test can rely on an empty cart regardless of execution order. <code>ITestOutputHelper</code> output is attached to the test result and only shown for that test, unlike <code>Console.WriteLine</code>, which interleaves output from parallel tests. Fixture data must be read-only or thread-safe, because classes using it run in parallel.
using System.Text.Json;
using Xunit;
[assembly: AssemblyFixture(typeof(PriceListFixture))]
// Created once for the whole run, before the first test that asks for it.
public sealed class PriceListFixture : IAsyncLifetime
{
public Dictionary<string, decimal> Prices { get; private set; } = [];
public async ValueTask InitializeAsync()
{
await using var file = File.OpenRead(Path.Combine(AppContext.BaseDirectory, "pricelist.json"));
Prices = await JsonSerializer.DeserializeAsync<Dictionary<string, decimal>>(file) ?? [];
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
public sealed class CartTests(PriceListFixture priceList, ITestOutputHelper output) : IDisposable
{
// A new CartTests instance, and so a new empty cart, exists for every test.
private readonly List<string> _cart = [];
[Fact]
public void Known_sku_has_a_price()
{
_cart.Add("KETTLE-BLUE");
output.WriteLine($"{priceList.Prices.Count} prices loaded");
Assert.Equal(34.50m, priceList.Prices[_cart[0]]);
}
[Fact]
public void Every_test_starts_with_an_empty_cart() => Assert.Empty(_cart);
public void Dispose() => output.WriteLine($"cart held {_cart.Count} item(s)");
}global.json that opts dotnet test into MTP mode
The <code>sdk</code> section pins the SDK so every machine and CI agent builds with the same compiler and analyzers; <code>latestFeature</code> still accepts newer patch and feature bands. The <code>test</code> section switches <code>dotnet test</code> from VSTest to Microsoft.Testing.Platform for the whole repository. In that mode <code>dotnet test</code> starts each test executable directly, so runner options such as xUnit's trait filters go straight to the test app instead of through a VSTest adapter.
{
"sdk": {
"version": "10.0.402",
"rollForward": "latestFeature"
},
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
Discussion