Migrate a test project from xUnit v2 to xUnit v3 (2.9.3 to 4.0.0)
A step-by-step migration from xunit 2.9.3 to xunit.v3 4.0.0: package swaps, the OutputType Exe change, IAsyncLifetime returning ValueTask, the Xunit.Abstractions removal, and the CI filter syntax that silently stops matching.
Migrating a normal test project from xunit 2.9.3 to xunit.v3 4.0.0 takes about an hour of mechanical work: swap four package references, flip OutputType to Exe, delete every using Xunit.Abstractions;, and change IAsyncLifetime from Task to ValueTask. What actually eats the day is everything around the test project: a third-party package with no v3 build will break the compile with a duplicate FactAttribute error, and your CI dotnet test --filter expression will stop matching anything without failing the build. The migration is worth doing (v3 has been the only line receiving features since 2.9.3 shipped in January 2025), and it is reversible right up until you delete the old branch. Everything below is against xunit.v3 4.0.0, released August 15 2026, on the .NET 10 and .NET 11 SDKs.
Why this is not just a version bump
- v2 is feature-frozen. 2.9.3 (January 8 2025) is the last v2 release.
TestContext, cancellation-aware timeouts, assembly fixtures, dynamic skipping and the query filter language exist only in v3. - Test projects become executables. A v3 project has a generated entry point and runs itself. That removes the runner-version-vs-framework-version mismatch class of bug entirely, and it is what makes Native AOT test builds possible in 4.0.0.
TestContext.Current.CancellationTokenmakes timeouts real. In v2 a[Fact(Timeout = ...)]on a non-async test could not interrupt anything. In v3 the token flows into your code, so a hung HTTP call actually cancels.- Microsoft.Testing.Platform is opt-in but native. The
xunit.v34.0.0 metapackage resolves toxunit.v3.mtp-v2, which pulls MTP v2 in for you. You get--report-trx, CTRF output and much faster startup without a VSTest host process.
What breaks
| Area | Change | Severity |
|---|---|---|
xunit.abstractions | Package and namespace are gone. ITestOutputHelper moved to Xunit | high |
| Project shape | OutputType must be Exe; SDK-style projects only | high |
| Target framework | Minimum is net472 or net8.0. netcoreapp3.1 through net7.0 are out | high |
IAsyncLifetime | Inherits IAsyncDisposable; both methods return ValueTask, not Task | high |
async void tests | Fast-fail at runtime instead of running | high |
| Third-party packages | Any package referencing xunit.core 2.x collides with xunit.v3.core | high |
| CI filters | VSTest --filter expressions are not supported under MTP | high |
MemberDataAttribute | Parameters renamed to Arguments; ConvertDataItem is now ConvertDataRow | medium |
| Orderer / framework attributes | CollectionBehavior, TestCaseOrderer, TestFramework take Type, not strings | medium |
AssemblyTraitAttribute | Removed. Apply [assembly: Trait(...)] instead | low |
PropertyDataAttribute | Removed (deprecated since v1) | low |
| Disposal | When a fixture implements both IDisposable and IAsyncDisposable, only DisposeAsync is called | medium |
The two rows to plan around are the third-party one and the CI one. Everything else the compiler tells you about.
Pre-flight checklist
- .NET 8 SDK or later installed.
xunit.v34.0.0 targetsnet472andnet8.0; there is nonetstandard2.0surface for the core package. - Every test project is SDK-style. Pre-SDK
.csprojfiles are not supported at all. Convert first, in a separate commit. - Inventory your xUnit-adjacent packages. Run
dotnet list package --include-transitive | grep -i xunitin each test project and write the list down. This is the list that decides whether the migration is one hour or one week. - Know which runner your CI uses. Grep your pipeline for
dotnet test,--filter,--logger, andvstest.console.exe. - Branch. Migrate one test project first, all the way through CI, before touching the rest.
Migration steps
-
Retarget the test project and make it an executable.
Bump
TargetFrameworktonet8.0or later and setOutputType. The generated entry point comes from the package; you do not write aMain.<!-- MyApp.Tests.csproj, .NET 10 SDK, xunit.v3 4.0.0 --> <PropertyGroup> <TargetFramework>net10.0</TargetFramework> <OutputType>Exe</OutputType> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> </PropertyGroup>Verify:
dotnet buildfails with missing xUnit types, not with project-shape errors. If you already have top-level statements in the test project, set<XunitAutoGeneratedEntryPoint>false</XunitAutoGeneratedEntryPoint>and own the entry point yourself. -
Swap the package references.
The v2 to v3 mapping is one-for-one except that
xunit.abstractionsdisappears andxunit.consolehas no successor.<!-- before: xunit 2.9.3 --> <ItemGroup> <PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> </ItemGroup> <!-- after: xunit.v3 4.0.0 --> <ItemGroup> <PackageReference Include="xunit.v3" Version="4.0.0" /> <PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> </ItemGroup>xunit.v34.0.0 resolves toxunit.v3.mtp-v2, which brings inxunit.v3.core.mtp-v2,xunit.v3.assertandxunit.analyzers2.0.0. Keepxunit.runner.visualstudio4.0.0 andMicrosoft.NET.Test.Sdkfor now: the runner package handles v1, v2 and v3, so Test Explorer and VSTest keep working while you migrate the rest of the solution. If you are on Central Package Management, do this inDirectory.Packages.propsinstead, which is the whole point of moving a solution to Directory.Packages.props.Verify:
dotnet restoresucceeds with no NU1605 downgrade warnings and no duplicate-type errors. -
Delete every
using Xunit.Abstractions;.ITestOutputHelperlives inXunitnow, alongsideFactandAssert, so in most files the fix is deleting a line.// xunit.v3 4.0.0 - no Xunit.Abstractions anywhere using Xunit; public class OrderServiceTests(ITestOutputHelper output) { [Fact] public void Prices_include_tax() { output.WriteLine("running"); // v3 also adds Write(), not just WriteLine() Assert.Equal(120m, new OrderService().Total(100m)); } }Verify:
grep -rn "Xunit.Abstractions" .returns nothing under your test projects. -
Convert
IAsyncLifetimeimplementations toValueTask.This is the change people get wrong, because the compiler error points at the return type and hides the disposal semantics behind it.
IAsyncLifetimenow inheritsIAsyncDisposable, and both members returnValueTask.// v2: xunit 2.9.3 public class DbFixture : IAsyncLifetime { public Task InitializeAsync() => _container.StartAsync(); public Task DisposeAsync() => _container.DisposeAsync().AsTask(); } // v3: xunit.v3 4.0.0 public class DbFixture : IAsyncLifetime { public ValueTask InitializeAsync() => new(_container.StartAsync()); public ValueTask DisposeAsync() => _container.DisposeAsync(); }The trap: if your fixture implements
IDisposableandIAsyncLifetime, v2 calledDispose()and v3 does not. It callsDisposeAsync()only, following the .NET guidance that you invoke one or the other. Any cleanup that lived exclusively inDispose()silently stops running, which usually shows up as a leaked Testcontainers container or an undeleted temp directory rather than a failing test. Move that cleanup intoDisposeAsync(). This matters most for the container-per-fixture pattern in integration tests against real SQL Server with Testcontainers.Verify: run the suite and confirm no orphaned containers with
docker ps -a. -
Fix
async voidtests and the mechanical attribute renames.v3 fast-fails
async voidtests at runtime rather than running them fire-and-forget, so change the signature toasync Task. This is the same reasoning laid out in async void vs async Task in C#, except now the framework enforces it. Then apply the string-to-Typeattribute conversions:// v2 [assembly: CollectionBehavior("MyTests.MyCollectionFactory", "MyTests")] [assembly: AssemblyTrait("Category", "Integration")] // v3, xunit.v3 4.0.0 [assembly: CollectionBehavior(typeof(MyCollectionFactory))] [assembly: Trait("Category", "Integration")]TestCaseOrdererAttribute,TestCollectionOrdererAttributeandTestFrameworkAttributetake the same treatment.MemberDataAttribute.Parametersis nowArguments, and if you subclassedMemberDataAttributeBase,ConvertDataItembecameConvertDataRowand returnsITheoryDataRowinstead ofobject[].Verify:
dotnet buildis clean except forxUnit1051warnings, which are the subject of the next step. -
Thread
TestContext.Current.CancellationTokenthrough your awaits.xunit.analyzers2.0.0 raisesxUnit1051on every call that accepts aCancellationTokenand does not get one. It is a warning, not an error, and you can migrate without touching it, but the token is most of the reason to be on v3.// xunit.v3 4.0.0 - the token cancels when the test times out or the run is aborted [Fact(Timeout = 5000)] public async Task Fetches_the_order() { var ct = TestContext.Current.CancellationToken; var response = await _client.GetAsync("/orders/1", ct); Assert.Equal(HttpStatusCode.OK, response.StatusCode); }Verify:
dotnet build -warnaserror:xUnit1051passes once you are done, or leave it as a warning and come back. -
Point CI at the new filter syntax.
Then decide whether to enable Microsoft.Testing.Platform. Under MTP, xUnit does not accept VSTest’s
--filterexpression language; it exposes--filter-class,--filter-method,--filter-namespace,--filter-trait, their--filter-not-*counterparts, and--filter-query. On the .NET 8 and 9 SDKs you opt in per project:<!-- .NET 8/9 SDK --> <PropertyGroup> <TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport> </PropertyGroup>On the .NET 10 SDK and later you opt in once for the repository:
// global.json { "test": { "runner": "Microsoft.Testing.Platform" } }And the filter itself changes shape:
# before, VSTest dotnet test --filter "Category!=Integration" # after, MTP with xunit.v3 4.0.0 dotnet test -- --filter-not-trait "Category=Integration"Verify: run the filtered command and confirm the reported test count is lower than the unfiltered count. Do not trust a green build here, because a filter that matches nothing exits zero.
Verify the migration
Run these in order, and treat any surprise in test counts as a failure even when the exit code is zero.
dotnet build -c Releasewith zero warnings other than ones you triaged.dotnet run --project MyApp.Tests -- --listto confirm discovery finds the number of tests you expect.dotnet testand compare the total against the last v2 run. A drop almost always means a filter or a skippedasync voidtest.- Open Test Explorer once. If tests run from the command line but Visual Studio hangs, that is the Test Explorer hang on xUnit v3 projects, not a bad migration.
- Check your coverage numbers. Coverlet attaches differently under MTP, and a coverage report that suddenly reads 0% is a wiring problem, not a regression.
Rollback
This migration is fully reversible: it is package references plus source edits, with no on-disk state and no database schema. git revert the commit and the v2 suite runs again, provided you did not also retarget below net8.0 in the same commit. Keep the retarget separate for exactly this reason. The one-way part is any third-party fork you had to publish (see below), which stays useful either way.
Gotchas worth knowing before you start
The duplicate FactAttribute error. If any package in the graph still references xunit.core 2.x, you get:
error CS0433: The type 'FactAttribute' exists in both
'xunit.core, Version=2.4.2.0, Culture=neutral, PublicKeyToken=8d05b1bb7a6fdb6c' and
'xunit.v3.core, Version=4.0.0.0, Culture=neutral, PublicKeyToken=8d05b1bb7a6fdb6c'
There is no alias trick worth attempting. Either the package has a v3 build or it does not. As of September 2026: Verify.XunitV3 32.0.0, AutoFixture.Xunit3 4.19.0, Xunit.DependencyInjection 12.0.1 and MartinCostello.Logging.XUnit.v3 0.7.1 all reference xunit.v3.* 4.x. Serilog.Sinks.XUnit 3.0.19 still pulls xunit.abstractions 2.0.3 and xunit.extensibility.core 2.9.2, so it is a hard blocker; the usual workaround is a small in-repo sink that writes to ITestOutputHelper directly, which is about thirty lines.
Xunit.SkippableFact is dead weight now. Delete it. v3 has Assert.Skip(reason), Assert.SkipWhen(condition, reason) and Assert.SkipUnless(condition, reason), plus SkipWhen and SkipUnless properties on [Fact] and [Theory] that point at a public static bool property on the test class. Setting both SkipWhen and SkipUnless on one attribute is a runtime failure, not a compile error.
Attribute instances are cached in v3. v2 created a fresh attribute instance per query; v3 caches, matching normal .NET reflection behaviour. Custom attributes that mutated their own state between discovery and execution will behave differently.
Version-pinning across a solution. xunit.v3 4.0.0 pins xunit.v3.mtp-v2 to an exact [4.0.0, 4.0.0] range, so mixed versions across projects surface as restore conflicts rather than runtime weirdness. That is a feature, but it means you upgrade all test projects in one commit or none.
Custom ITestCaseOrderer implementations changed in 4.0.0, not just between v2 and v3. Ordering now runs collection, then class, then method, then case, and there are separate class and method orderer extension points. If you carried a v2 orderer through v3.2.2 unchanged, 4.0.0 is where it stops compiling.
WebApplicationFactory<T> needs no changes. ASP.NET Core integration tests migrate cleanly; the fixture pattern in integration tests with WebApplicationFactory works as written once IAsyncLifetime returns ValueTask.
Related
- xUnit v3 vs NUnit vs MSTest in 2026: which should you pick?
- Fix: Visual Studio Test Explorer hangs on an xUnit v3 project while dotnet test passes
- Microsoft.Testing.Platform 2.3 puts test failures on the PR diff
- How to write integration tests with WebApplicationFactory in ASP.NET Core 11
- Migrate a .NET solution to Central Package Management with Directory.Packages.props
Sources
- Migrating Unit Tests from v2 to v3 — xUnit.net
- What’s New in v3? — xUnit.net
- Microsoft Testing Platform (xUnit.net v3) — xUnit.net
- xUnit.net v3 4.0.0 release notes — xUnit.net
- Migration guide from VSTest to Microsoft.Testing.Platform — Microsoft Learn
- xunit.v3 on NuGet — package metadata and dependency ranges
- Migrating from XUnit v2 to v3: troubleshooting — Bart Wullems
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.