Start Debugging

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

What breaks

AreaChangeSeverity
xunit.abstractionsPackage and namespace are gone. ITestOutputHelper moved to Xunithigh
Project shapeOutputType must be Exe; SDK-style projects onlyhigh
Target frameworkMinimum is net472 or net8.0. netcoreapp3.1 through net7.0 are outhigh
IAsyncLifetimeInherits IAsyncDisposable; both methods return ValueTask, not Taskhigh
async void testsFast-fail at runtime instead of runninghigh
Third-party packagesAny package referencing xunit.core 2.x collides with xunit.v3.corehigh
CI filtersVSTest --filter expressions are not supported under MTPhigh
MemberDataAttributeParameters renamed to Arguments; ConvertDataItem is now ConvertDataRowmedium
Orderer / framework attributesCollectionBehavior, TestCaseOrderer, TestFramework take Type, not stringsmedium
AssemblyTraitAttributeRemoved. Apply [assembly: Trait(...)] insteadlow
PropertyDataAttributeRemoved (deprecated since v1)low
DisposalWhen a fixture implements both IDisposable and IAsyncDisposable, only DisposeAsync is calledmedium

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

Migration steps

  1. Retarget the test project and make it an executable.

    Bump TargetFramework to net8.0 or later and set OutputType. The generated entry point comes from the package; you do not write a Main.

    <!-- 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 build fails 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.

  2. Swap the package references.

    The v2 to v3 mapping is one-for-one except that xunit.abstractions disappears and xunit.console has 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.v3 4.0.0 resolves to xunit.v3.mtp-v2, which brings in xunit.v3.core.mtp-v2, xunit.v3.assert and xunit.analyzers 2.0.0. Keep xunit.runner.visualstudio 4.0.0 and Microsoft.NET.Test.Sdk for 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 in Directory.Packages.props instead, which is the whole point of moving a solution to Directory.Packages.props.

    Verify: dotnet restore succeeds with no NU1605 downgrade warnings and no duplicate-type errors.

  3. Delete every using Xunit.Abstractions;.

    ITestOutputHelper lives in Xunit now, alongside Fact and Assert, 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.

  4. Convert IAsyncLifetime implementations to ValueTask.

    This is the change people get wrong, because the compiler error points at the return type and hides the disposal semantics behind it. IAsyncLifetime now inherits IAsyncDisposable, and both members return ValueTask.

    // 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 IDisposable and IAsyncLifetime, v2 called Dispose() and v3 does not. It calls DisposeAsync() only, following the .NET guidance that you invoke one or the other. Any cleanup that lived exclusively in Dispose() 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 into DisposeAsync(). 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.

  5. Fix async void tests and the mechanical attribute renames.

    v3 fast-fails async void tests at runtime rather than running them fire-and-forget, so change the signature to async 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-Type attribute 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, TestCollectionOrdererAttribute and TestFrameworkAttribute take the same treatment. MemberDataAttribute.Parameters is now Arguments, and if you subclassed MemberDataAttributeBase, ConvertDataItem became ConvertDataRow and returns ITheoryDataRow instead of object[].

    Verify: dotnet build is clean except for xUnit1051 warnings, which are the subject of the next step.

  6. Thread TestContext.Current.CancellationToken through your awaits.

    xunit.analyzers 2.0.0 raises xUnit1051 on every call that accepts a CancellationToken and 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:xUnit1051 passes once you are done, or leave it as a warning and come back.

  7. Point CI at the new filter syntax.

    Then decide whether to enable Microsoft.Testing.Platform. Under MTP, xUnit does not accept VSTest’s --filter expression 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.

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.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back