A Test Project with xUnit v3: Microsoft Testing Platform, dotnet test and First Tests
An xUnit.net v3 test project is an executable and, since 4.0, runs on Microsoft Testing Platform v2 by default; once global.json sets the test runner on the .NET 10 SDK, dotnet test rejects VSTest projects. The xunit3 template, the package chain, dotnet test vs dotnet run, Assert.Throws/ThrowsAsync, TestContext.Current.CancellationToken and xUnit1051, filters, TRX reports and exit code 8.
Version note: The code in this post was built and run with .NET 10, xUnit.net v3 4.0.2 and Microsoft Testing Platform v2; the .NET SDK (Software Development Kit) version used is 10.0.402.
Predict First
You are adding a test project to a new .NET 10 solution. The global.json at the root of the solution, following a team decision, has switched dotnet test to Microsoft Testing Platform mode. You copied the project's .csproj from a popular xUnit tutorial on the internet:
// global.json
{
"test": { "runner": "Microsoft.Testing.Platform" }
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
</Project>
It contains a single [Fact], Assert.Equal(4, 2 + 2). What happens when you run dotnet test: does the test pass, fail, or not run at all?
The Answer: The Test Never Runs
global.json defines test runner to be Microsoft.Testing.Platform. All projects must use that test runner.
The following test projects are using VSTest test runner:
V2Copy.Tests.csproj
See https://aka.ms/dotnet-test/mtp for more information.
The copied packages are xUnit.net v2 packages: xunit, the VSTest adapter xunit.runner.visualstudio, and Microsoft.NET.Test.Sdk, which brings the VSTest infrastructure. This is a VSTest project; the new MTP (Microsoft Testing Platform) mode of the .NET 10 SDK only runs MTP projects and rejects a mixed solution up front. In this post we set up the right project: the xUnit.net v3 template, the files it generates, the two modes of dotnet test, and the first tests.
In xUnit v3 a Test Project Is an Executable
The v3 templates ship as a separate package. Installing them and creating a project:
dotnet new install xunit.v3.templates
dotnet new xunit3 -o Bookstore.Ordering.Tests -f net10.0
dotnet add Bookstore.Ordering.Tests reference Bookstore.Ordering
The template still defaults to net8.0; without -f net10.0 the project targets .NET 8. The generated .csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="xunit.v3.mtp-v2" Version="4.0.2" />
</ItemGroup>
</Project>
Three things differ from v2. The first is OutputType: a v3 test project is not a library but an executable with its own Main. The test runner is not an external process that loads the project; it is the project itself. The test assembly runs with its own dependencies in its own process, which removes most of the app domain and assembly loading problems of v2.
The second is the package. The package chain on NuGet looks like this: xunit.v3 is a meta package and in 4.0.2 depends only on xunit.v3.mtp-v2; that one brings xunit.v3.core.mtp-v2 (the framework and its MTP v2 integration), xunit.v3.assert and xunit.analyzers 2.2.0. So today xunit.v3 and xunit.v3.mtp-v2 end up in the same place; the template prefers the explicit name. Supported targets are .NET 8 and later and .NET Framework 4.7.2 and later.
The third is MTP. With 4.0 (2026-08-14), MTP v1 support ended and MTP v2 became the default. For teams that want to stay on VSTest there are -mtp-off packages. The template's --test-runner vstest option produces this:
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1" />
<PackageReference Include="xunit.v3.mtp-off" Version="4.0.2" />
</ItemGroup>
The template also touches global.json. If there is no global.json in the folder tree, it creates one next to the project containing "test": { "runner": "Microsoft.Testing.Platform" }; if one exists in a parent folder, it adds this section to it and prints "Successfully modified global.json." When you run the template inside a solution, it is worth noticing that the root global.json changed before you commit.
The Two Modes of dotnet test: VSTest and MTP
Until the .NET 10 SDK, dotnet test only knew VSTest: it called the VSTest MSBuild target, which ran vstest.console. MTP projects could run in this mode only with the TestingPlatformDotnetTestSupport property and by passing arguments after an extra --. The .NET 10 SDK introduced a new mode; when test.runner in global.json is Microsoft.Testing.Platform, dotnet test runs MTP applications directly. The Microsoft documentation says the extra -- is not required in this mode and that projects and solutions are passed with --project and --solution. According to Microsoft, running MTP through VSTest mode is now legacy; that support is removed for MTP v2 on the .NET 10 SDK.
When you create the template with net8.0, TestingPlatformDotnetTestSupport is added to the .csproj; with net10.0 it is not; the template assumes the new mode for .NET 10. The output of the test run:
Running tests from …/p02/Bookstore.Ordering.Tests/bin/Debug/net10.0/Bookstore.Ordering.Tests.dll (net10.0|arm64)
…/p02/Bookstore.Ordering.Tests/bin/Debug/net10.0/Bookstore.Ordering.Tests.dll (net10.0|arm64) passed (128ms)
Test run summary: Passed!
total: 5
failed: 0
succeeded: 5
skipped: 0
duration: 374ms
Because projects are executables, dotnet run runs the tests too. With the template's default --command-line xunit option, it uses xUnit's own command line in this case:
xUnit.net v3 In-Process Runner v4.0.2+0c7aa4991f (64-bit .NET 10.0.13)
Discovering: Bookstore.Ordering.Tests
Discovered: Bookstore.Ordering.Tests
Starting: Bookstore.Ordering.Tests
Finished: Bookstore.Ordering.Tests (ID = '921d85dda4575876526c166eee2ad6cf591728ea365371a65bbcc300f6a8f040')
=== TEST EXECUTION SUMMARY ===
Bookstore.Ordering.Tests Total: 5, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0, Time: 0.054s
The same executable speaks the MTP protocol when called through dotnet test and shows its own runner when run directly. dotnet run while debugging, dotnet test in CI and day-to-day use is a practical split.
First Tests: Fact, Assert.Equal and Exceptions
The code under test is a slightly extended version of the Order from the previous post, plus a cancellation service:
namespace Bookstore.Ordering;
public enum OrderStatus { Draft, Placed, Shipped, Cancelled }
public sealed class Order(Guid id)
{
private readonly List<OrderLine> lines = [];
public Guid Id { get; } = id;
public OrderStatus Status { get; internal set; } = OrderStatus.Draft;
public IReadOnlyList<OrderLine> Lines => lines;
public decimal Subtotal => lines.Sum(l => l.Total);
public void AddLine(Book book, int quantity)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
lines.Add(new OrderLine(book, quantity));
}
}
public interface IOrderRepository
{
Task<Order?> FindAsync(Guid id, CancellationToken ct);
Task SaveAsync(Order order, CancellationToken ct);
}
public sealed class OrderCancellation(IOrderRepository orders)
{
public async Task CancelAsync(Guid orderId, CancellationToken ct = default)
{
Order order = await orders.FindAsync(orderId, ct)
?? throw new KeyNotFoundException($"Order {orderId} was not found.");
if (order.Status == OrderStatus.Shipped)
{
throw new InvalidOperationException("A shipped order cannot be cancelled.");
}
order.Status = OrderStatus.Cancelled;
await orders.SaveAsync(order, ct);
}
}
Synchronous tests:
public sealed class OrderTests
{
private static readonly Book Refactoring = new("978-0134757599", "Refactoring", 48m);
[Fact]
public void Subtotal_sums_price_times_quantity_of_each_line()
{
Order order = new(Guid.NewGuid());
order.AddLine(Refactoring, 2);
Assert.Equal(96m, order.Subtotal);
}
[Fact]
public void AddLine_rejects_zero_quantity()
{
Order order = new(Guid.NewGuid());
ArgumentOutOfRangeException ex =
Assert.Throws<ArgumentOutOfRangeException>(() => order.AddLine(Refactoring, 0));
Assert.Equal("quantity", ex.ParamName);
}
}
The first argument of Assert.Equal is the expected value and the second the actual one; if you swap them the test still runs, but the failure message shows "Expected" and "Actual" the wrong way round. Assert.Throws<T> expects exactly type T: had we written ArgumentException, the test would fail even though the thrown ArgumentOutOfRangeException is a subtype of it. If you want to accept subtypes too, there is Assert.ThrowsAny<T>. Capturing the returned exception and checking ParamName guarantees that the right argument was rejected.
Async tests return async Task and use Assert.ThrowsAsync:
public sealed class OrderCancellationTests
{
[Fact]
public async Task Draft_order_becomes_cancelled_and_is_saved()
{
Order order = new(Guid.NewGuid());
InMemoryOrderRepository orders = new(order);
OrderCancellation sut = new(orders);
await sut.CancelAsync(order.Id, TestContext.Current.CancellationToken);
Assert.Equal(OrderStatus.Cancelled, order.Status);
Assert.Equal(1, orders.SaveCount);
}
[Fact]
public async Task Shipped_order_cannot_be_cancelled()
{
Order order = new(Guid.NewGuid()) { Status = OrderStatus.Shipped };
OrderCancellation sut = new(new InMemoryOrderRepository(order));
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.CancelAsync(order.Id, TestContext.Current.CancellationToken));
Assert.Equal("A shipped order cannot be cancelled.", ex.Message);
}
[Fact]
public async Task Unknown_order_id_throws_key_not_found()
{
OrderCancellation sut = new(new InMemoryOrderRepository());
await Assert.ThrowsAsync<KeyNotFoundException>(
() => sut.CancelAsync(Guid.NewGuid()));
}
}
InMemoryOrderRepository is a twenty-line class in the test project: a Dictionary plus a counter of how many times something was saved. We will discuss the kinds of test doubles in detail in post 06. Forgetting to await Assert.ThrowsAsync is a classic mistake: if the returned Task is not awaited, the test passes whether an exception is thrown or not. The xUnit analyzers catch this at compile time with error xUnit2021: "Assert.ThrowsAsync is async. The resulting task should be awaited".
TestContext.Current.CancellationToken and xUnit1051
In the last test we did not pass a token to CancelAsync. The build output:
…/p02/Bookstore.Ordering.Tests/OrderCancellationTests.cs(38,19): warning xUnit1051: Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)
v3 provides a TestContext for every test; TestContext.Current gives the context of the currently running test. Its CancellationToken is triggered when the test is cancelled or times out: when you click "Stop" in Test Explorer, when the runner shuts down, or when the time of [Fact(Timeout = 2000)] runs out. If you pass the token down to the calls, a long-running test stops without waiting. The xUnit1051 warning flags every call that accepts this token but does not get it. Passing the token rather than silencing the warning is the right habit; it makes a big difference especially in the timer and background service tests we will see in post 11.
TestContext carries more than the token: SendDiagnosticMessage, AddAttachment, AddWarning, and KeyValueStorage for sharing data between tests are there too. The xUnit documentation's warning matters: the context is a point-in-time snapshot, so do not store it for later; read it from TestContext.Current when you need it. In post 05 we will see how these APIs (Application Programming Interface) relate to the lifecycle.
Filtering, Reports and CI
In MTP mode, filters are passed directly to dotnet test. xUnit's "simple" filters exist for class, method, namespace, trait and display name, each with a --filter-not-... counterpart. For example:
dotnet test --filter-class "*OrderCancellationTests"
dotnet test --filter-method "*Shipped_order_cannot_be_cancelled"
dotnet test --filter-trait "Category=Smoke"
dotnet test --filter "FullyQualifiedName~Shipped"
dotnet test --list-tests
The first command ran 3 tests, the fourth ran 1. --filter is the VSTest filter syntax that came with 4.0, a convenience for pipelines migrating from VSTest. There is also xUnit's query language with --filter-query. These three kinds of filter cannot be combined; the help output says so explicitly under every option.
Knowing what happens when a filter matches no tests matters for CI:
Exit code: 8
Test run summary: Zero tests ran
error: 1
total: 0
failed: 0
succeeded: 0
skipped: 0
duration: 228ms
Test run completed with non-success exit code: 8 (see: https://aka.ms/testingplatform/exitcodes)
MTP does not count zero tests as success and exits with code 8. This is a good default: it stops a pipeline from staying silently green because of a mistyped filter. For a step that may legitimately be empty, --ignore-exit-code 8 can be used. For a stricter guard, --minimum-expected-tests sets the minimum number of tests expected to run.
CI systems usually read test results from a file. In 4.0, xUnit's MTP reports got the --report-xunit- prefix; for a TRX (Visual Studio Test Results) file:
dotnet test --report-xunit-trx --report-xunit-trx-filename unit-tests.trx --results-directory ../TestResults
In process file artifacts produced:
- …/p02/TestResults/unit-tests.trx
The same family includes JUnit, NUnit, CTRF (Common Test Report Format), HTML (HyperText Markup Language) and Markdown reports. Without a file name, the TRX name is made of the user name, machine name and a timestamp; giving it a fixed name makes it easier to collect as a CI artifact. The order of the test step in a pipeline and flaky test management are the subject of the CI/CD Temelleri (CI/CD Fundamentals) series.
A Short Comparison with MSTest, NUnit and TUnit
There are four common test frameworks in .NET, and all four support MTP. The concepts are largely the same; the differences are in names and a few defaults:
| Topic | xUnit.net v3 4.0.2 | MSTest 4.5.2 | NUnit 5.0.0 | TUnit 1.81.10 |
|---|---|---|---|---|
| Test | [Fact] | [TestMethod] | [Test] | [Test] |
| Data-driven | [Theory] + [InlineData] | [DataRow] | [TestCase] | [Arguments] |
| Instance per test | New for every test | New for every test | Default: one per fixture | New for every test |
| Setup | Constructor, IAsyncLifetime | [TestInitialize] | [SetUp] | [Before(Test)] |
| Discovery | Reflection; separate package for Native AOT (Ahead-of-Time) | Reflection | Reflection | Source generator |
NUnit's default deserves attention: tests in the same fixture share the same instance, so state left in fields can carry over from one test to the next. Because xUnit creates a new instance for every test, this class of bugs is prevented from the start; that is the topic of post 05. TUnit is the youngest; it discovers tests at compile time with a source generator and runs only on MTP. The choice is largely a matter of team habit; this series uses xUnit because it is one of the most widely used in the .NET ecosystem, and the test suites of ASP.NET Core and Entity Framework Core (EF Core) themselves are written with xUnit.
In Production
One runner per solution. The Microsoft documentation describes mixing VSTest and MTP projects in the same solution as an unsupported scenario; the MTP mode of the .NET 10 SDK rejects it outright with the error message above. If you migrate project by project, you either stay in VSTest mode and run MTP projects with TestingPlatformDotnetTestSupport until the migration is done, or move all projects at once.
Manage versions centrally. With several test projects, keep the xunit.v3.mtp-v2 version in Directory.Packages.props (Central Package Management). One project left on an old 3.x version brings MTP v1 and v2 together in the same solution.
Trust the exit codes. MTP's exit codes are meaningful: 0 is success, 2 means at least one test failed, 8 means no tests ran. Checking these codes in a CI script is more robust than parsing the output.
IDE (Integrated Development Environment) support. 4.0 sets the IsTestingPlatformApplication MSBuild property to true in the project; tools can tell from this property that the project is an MTP application. If tests do not show up in Test Explorer, first check that your IDE is on a version with MTP support.
Common Pitfalls
Copying packages from a v2 tutorial. The trio xunit + xunit.runner.visualstudio 3.x + Microsoft.NET.Test.Sdk is v2. The names of v3 packages always contain v3. If the line you copied has no v3 in it, you are setting the project up with v2.
Adding the VSTest packages to a v3 project as well. I tried a project that adds Microsoft.NET.Test.Sdk and xunit.runner.visualstudio 4.0.1 next to xunit.v3: in MTP mode the tests ran, but the two packages sit in the dependency list doing nothing and make the question "which runner does this project use?" ambiguous in the future. If you want VSTest, use the -mtp-off package; if you want MTP, use only -mtp-v2.
Not noticing the template's net8.0 default. A project created without -f net10.0 targets .NET 8 while the rest of the solution is on .NET 10, and comes with a property like TestingPlatformDotnetTestSupport that is unnecessary in .NET 10 mode.
Writing async void tests. async void test methods that worked in v2 are not supported in v3; the analyzer stops the build with error xUnit1049 ("Support for 'async void' unit tests has been removed from xUnit.net v3"). The return type of an async test must be Task or ValueTask.
A Small Experiment
★☆☆ Create a project with dotnet new xunit3 without -f and compare its .csproj with the one above. Then create another with --test-runner vstest; with both projects in the same solution, what does dotnet test say?
★★☆ In the Shipped_order_cannot_be_cancelled test, delete the await in front of Assert.ThrowsAsync and try to build. Then put the await back, comment out the throw line in CancelAsync and read the test's failure message: which line, which expectation?
★★★ Add [Fact(Timeout = 500)] to a test and call await Task.Delay(5000, TestContext.Current.CancellationToken) in it. Then remove the token and run the same test. Compare the duration and the result of the two runs.
In the next post we return to the tests themselves: the limits of Arrange/Act/Assert, naming, the one-behavior rule and assertions with readable failure messages; Shouldly, AwesomeAssertions and the license change of FluentAssertions 8.