Mert Özen Further With Every Line
Testing & Quality Unit Testing — #13

From xUnit v2 to v3 and 4.0: Reading and Migrating Old Test Projects

Mert Özen Nov 8, 2026 9 min 2 views
From xUnit v2 to v3 and 4.0: Reading and Migrating Old Test Projects

Migrating an xUnit v2 project to v3 means collapsing the packages into one, making the project an executable and changing a few signatures; the real risk when moving to 4.0 is extensibility code that compiles but behaves differently. A custom ITestCaseOrderer written for 3.x compiled without warnings on 4.0.2 and silently stopped ordering methods. A step-by-step migration with real build errors, dotnet new xunit still producing v2, xunit-console-tool and a checklist.

Version note: The code in this post was built and run with .NET 10, xUnit.net 2.9.3, xUnit.net v3 3.2.2 and 4.0.2; the console runner is xunit-console-tool 4.0.2.

Predict First

A team wrote a custom test case orderer on the 3.x line of xUnit v3 to run their tests in alphabetical order (we discussed in post 05 why order-dependent tests are bad, but this team has an old integration scenario):

using Xunit;
using Xunit.Sdk;
using Xunit.v3;

[assembly: TestCaseOrderer(typeof(Orderer.Tests.AlphabeticalOrderer))]

namespace Orderer.Tests;

// A custom test case orderer written for xUnit v3 3.x
public sealed class AlphabeticalOrderer : ITestCaseOrderer
{
    public IReadOnlyCollection<TTestCase> OrderTestCases<TTestCase>(IReadOnlyCollection<TTestCase> testCases)
        where TTestCase : notnull, ITestCase =>
        testCases.OrderBy(tc => tc.TestMethodName, StringComparer.Ordinal).ToArray();
}

public sealed class OrderedTests
{
    [Fact]
    public void D_fourth() => Console.WriteLine("D_fourth");

    [Fact]
    public void B_second() => Console.WriteLine("B_second");

    [Fact]
    public void C_third() => Console.WriteLine("C_third");

    [Fact]
    public void A_first() => Console.WriteLine("A_first");
}

They upgrade the package from xunit.v3 3.2.2 to 4.0.2. Does the project compile? If it does, in which order do the tests run?

The Answer: It Compiles, but the Order Is Gone

The build passes without warnings. The output of the two versions with dotnet run:

== 3.2.2
A_first
B_second
C_third
D_fourth
   Orderer.Tests  Total: 4, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0, Time: 0.044s
== 4.0.2
B_second
A_first
D_fourth
C_third
   Orderer.Tests  Total: 4, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0, Time: 0.045s

No errors, no warnings; the ordering silently disappeared. 4.0 split ordering into layers: first collection, then class, then method, and finally test case. ITestCaseOrderer now orders only the test cases of the same method (the rows of a theory); ordering methods is the job of the new ITestMethodOrderer. The 4.0 release notes warn that existing ITestCaseOrderer implementations are likely affected. The fixed version:

[assembly: TestMethodOrderer(typeof(Orderer.Tests.AlphabeticalMethodOrderer))]

namespace Orderer.Tests;

// 4.0: ordering methods is now the job of a separate orderer
public sealed class AlphabeticalMethodOrderer : ITestMethodOrderer
{
    public IReadOnlyCollection<TTestMethod?> OrderTestMethods<TTestMethod>(IReadOnlyCollection<TTestMethod?> testMethods)
        where TTestMethod : notnull, ITestMethod =>
        testMethods.OrderBy(m => m?.MethodName, StringComparer.Ordinal).ToArray();
}

In my first attempt I wrote the return and parameter types as TTestMethod instead of TTestMethod?, and the compiler reminded me of the interface's nullable signature with CS8614. After the fix, the order is A, B, C, D again. This post walks through reading old test projects and migrating them from v2 to v3 and from 3.x to 4.0 step by step, silent breaks like this one included.

Recognizing a v2 Project

You can tell a test project is v2 from its .csproj. Post 29 of the site's ASP.NET Core Web API (Application Programming Interface) series uses xunit 2.9.3, xunit.runner.visualstudio 3.0.2 and Microsoft.NET.Test.Sdk 17.13.0; post 14 of the C# İleri (Advanced C#) series uses xunit 2.9.3, xunit.runner.visualstudio 2.8.2 and Microsoft.NET.Test.Sdk 17.12.0. Both are v2: a library project, the VSTest adapter and Xunit.Abstractions. They were the right choice when those posts were written, and v2 still works; but all the new features are in v3.

A notable detail: the .NET 10 SDK's (Software Development Kit, 10.0.402 here) own dotnet new xunit template still produces v2. When I tried it, the generated project had xunit 2.9.3, xunit.runner.visualstudio 3.1.4, Microsoft.NET.Test.Sdk 17.14.1 and coverlet.collector 6.0.4. For v3 you need the xunit3 template from the xunit.v3.templates package in post 02. Someone who starts a new project with "dotnet new xunit" starts with v2 without noticing.

Step by Step: Packages, Project and global.json

The migration example is a typical test file from the v2 era: ITestOutputHelper from Xunit.Abstractions, an IAsyncLifetime fixture returning Task, MemberData with IEnumerable<object[]>, a CollectionBehavior turning off parallelism at assembly level, and an async void test left over from 2019. In v2 there are five tests, four pass and one is skipped; the only warning is xUnit1048: "Support for 'async void' unit tests is being removed from xUnit.net v3."

The first step is the project file. Before and after:

<!-- v2 -->
<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>

<!-- v3 / 4.0 -->
<PropertyGroup>
  <OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
  <PackageReference Include="xunit.v3.mtp-v2" Version="4.0.2" />
</ItemGroup>

Three packages become one, the project becomes an executable, and the "test": { "runner": "Microsoft.Testing.Platform" } section from post 02 is added to the solution's global.json. If the team wants to stay on VSTest, there is the combination xunit.v3.mtp-off + xunit.runner.visualstudio 4.x + Microsoft.NET.Test.Sdk; but with 4.0, MTP (Microsoft Testing Platform) v2 is the default and new development happens there.

Build Errors and Their Fixes

The errors I got after changing the packages and building (paths shortened):

CatalogTests.cs(2,13): error CS0234: The type or namespace name 'Abstractions' does not exist in the namespace 'Xunit' (are you missing an assembly reference?)
CatalogTests.cs(4,31): error CS0619: 'CollectionBehaviorAttribute.DisableTestParallelization' is obsolete: 'Please set ParallelizationAttribute.Mode instead. This property will be removed in the next major version.'
CatalogTests.cs(8,40): error CS0738: 'PriceListFixture' does not implement interface member 'IAsyncDisposable.DisposeAsync()'. ...
CatalogTests.cs(8,40): error CS0738: 'PriceListFixture' does not implement interface member 'IAsyncLifetime.InitializeAsync()'. ...

The second round, after fixing those:

CatalogTests.cs(46,23): error xUnit1049: Support for 'async void' unit tests has been removed from xUnit.net v3. Convert the test to 'async Task' or 'async ValueTask' instead.
CatalogTests.cs(48,15): warning xUnit1051: Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive.

The migrated file:

using Xunit;
using Xunit.Sdk;
using Xunit.v3;

[assembly: Parallelization(Mode = ParallelMode.None)]

namespace Bookstore.Legacy.Tests;

public sealed class PriceListFixture : IAsyncLifetime
{
    public Dictionary<string, decimal> Prices { get; } = [];

    public ValueTask InitializeAsync()
    {
        Prices["978-0321125217"] = 55m;
        return ValueTask.CompletedTask;
    }

    public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}

public sealed class CatalogTests(PriceListFixture prices, ITestOutputHelper output) : IClassFixture<PriceListFixture>
{
    public static TheoryData<decimal, decimal> Discounts =>
    [
        (100m, 10m),
        (99.99m, 0m),
    ];

    [Fact]
    public void Known_book_has_a_price()
    {
        output.WriteLine($"Prices loaded: {prices.Prices.Count}");
        Assert.Equal(55m, prices.Prices["978-0321125217"]);
    }

    [Theory]
    [MemberData(nameof(Discounts))]
    public void Volume_discount(decimal subtotal, decimal expected) =>
        Assert.Equal(expected, subtotal >= 100m ? subtotal * 0.10m : 0m);

    [Fact(Skip = "Waiting for the new tax rules")]
    public void Vat_is_added() { }

    [Fact]
    public async Task Async_test_from_2019()
    {
        await Task.Delay(10, TestContext.Current.CancellationToken);
        Assert.True(true);
    }
}

Let us go through the changes one by one. ITestOutputHelper now lives in the Xunit namespace; using Xunit.Abstractions; is deleted. IAsyncLifetime's methods return ValueTask, and the interface derives from IAsyncDisposable. CollectionBehavior(DisableTestParallelization = true) is obsolete in 4.0, at error level; it is replaced by Parallelization(Mode = ParallelMode.None), with the two namespaces we saw in post 05. async void becomes async Task. IEnumerable<object[]> still works in v3, but I took the opportunity to move to the type-safe TheoryData. The dotnet test output: 5 tests, 4 passed, 1 skipped.

What v3 Brings, Briefly

Features that become available once the migration is done, which we saw in this series: TestContext.Current for every test (cancellation token, attachments, warnings, key-value storage; posts 02 and 05), run-time Assert.Skip, SkipUnless/SkipWhen and Explicit tests (04), TheoryDataRow carrying per-row metadata and MatrixTheoryData for combinations (04), and AssemblyFixture (05). There are two more features we did not use in the series: pinning the tests' culture with culture in xunit.runner.json or on the command line, and ITestPipelineStartup, registered with [assembly: TestPipelineStartup(typeof(...))], which runs at the start and end of discovery and execution; for example, to keep an OpenTelemetry tracer open for the whole test run.

4.0: The End of MTP v1, Native AOT, Parallelism and the Console Tool

4.0, released on 14 August 2026, is a migration step for projects coming from 3.x as well. The highlights:

ChangeImpact
MTP v1 support ended, MTP v2 is the default3.x projects running on MTP v1 move to MTP v2 when upgraded to 4.0; xUnit's MTP report options got the --report-xunit- prefix
Native AOT (Ahead-of-Time)xunit.v3.core.aot packages; the template's --native-aot option (net9.0 and net10.0)
Full parallelism (ParallelMode.All)The default did not change (Collections); the old CollectionBehavior parallelism settings are obsolete
New orderer layersCollection, class, method and case orderers; existing ITestCaseOrderers do not order methods
xunit-console-toolThe console runner as a .NET tool; requires the .NET 10 SDK
Mono support endedMono-specific issues are no longer accepted
Extensibility APIsMany APIs obsoleted in 3.x were removed; Xunit.SimpleRunner replaces Xunit.Runners

The release notes show installing the console tool as a global tool; to avoid installing anything globally on the machine, I installed it into the work folder with a local tool manifest (dotnet new tool-manifest, then dotnet tool install xunit-console-tool --version 4.0.2). Running the migrated project through its compiled DLL (Dynamic Link Library):

xUnit.net v3 Console Runner v4.0.2+0c7aa4991f [osx-arm64]
  Discovering: Bookstore.Legacy.Tests
  Discovered:  Bookstore.Legacy.Tests
  Starting:    Bookstore.Legacy.Tests
    Bookstore.Legacy.Tests.CatalogTests.Vat_is_added [SKIP]
      Waiting for the new tax rules
  Finished:    Bookstore.Legacy.Tests (ID = 'b571046623dd405aeffede3699e8cad800434520a800752aecec6ccec7cac447')
=== TEST EXECUTION SUMMARY ===
   Bookstore.Legacy.Tests  Total: 5, Errors: 0, Failed: 0, Skipped: 1, Not Run: 0, Time: 0.080s

The console runner is handy for running several assemblies with a single command, or for running tests in an environment without dotnet test (such as a test agent that only receives compiled output).

Migration Checklist

  • Packages: remove xunit, xunit.runner.visualstudio 2.x/3.x and Microsoft.NET.Test.Sdk, add xunit.v3.mtp-v2 (or xunit.v3.mtp-off + adapter 4.x for VSTest); set OutputType to Exe; centralize the version in Directory.Packages.props.
  • Runner: add the MTP runner to the solution's global.json; migrate all test projects at once, do not mix VSTest and MTP.
  • Namespaces and signatures: delete Xunit.Abstractions; turn IAsyncLifetime into ValueTask; make async void tests async Task; pass TestContext.Current.CancellationToken for the analyzer's xUnit1051 warnings.
  • Parallelism: move CollectionBehavior's parallelism properties to the Parallelization attribute or to parallelMode in xunit.runner.json; look for shared state before switching to All.
  • Ordering and extensibility: if you have custom orderers, discoverers or attributes, read the 4.0 release notes line by line and verify the behavior with a test; compiling does not mean it works.
  • Dependent packages: check the v3 counterparts of xUnit integration packages such as Verify, AutoFixture and FsCheck (Verify.XunitV3, AutoFixture.Xunit3, FsCheck.Xunit.v3) and their version constraints.
  • CI (Continuous Integration): update the test step for dotnet test's MTP mode (--project/--solution, --report-xunit-trx), and verify that the test count is the same as before the migration.

In Production

Compare test counts before and after the migration. A theory's rows being discovered differently, an attribute silently ignored or a filter interpreted differently can let tests "pass" while fewer of them run. Setting a lower bound in CI with --minimum-expected-tests closes this risk; exit code 8 from post 02 serves the same purpose.

Migrate large solutions not project by project but in stages, in a coordinated way. First make the package and namespace changes in all projects (even in VSTest mode, with the -mtp-off packages), then switch the runner to MTP in a single commit. This keeps the "one runner per solution" rule throughout the migration.

A migration is an opportunity for refactoring, but it is not refactoring. Small improvements like turning IEnumerable<object[]> into TheoryData are reasonable; changing test logic in the same commit makes it hard to tell whether a red test comes from the migration or from the change.

Common Pitfalls

Custom ITestCaseOrderers breaking in 4.0. Predict First itself: the build passes, the tests pass, the order is gone. If a group of tests relies on order, write a test that verifies the order after the migration or, better, remove the order dependency.

Carrying over old parallelism settings. Settings like CollectionBehavior(MaxParallelThreads = 4) are obsolete in 4.0; DisableTestParallelization was at error level in our example. When moving the setting to the new attribute, also question why the old setting was added: usually to hide shared state.

Trusting dotnet new xunit. As we saw above, the SDK's built-in template produces v2. Use the xunit3 template for new projects and do not forget the template's net8.0 default.

Migrating by silencing warnings. Adding warnings like xUnit1051 to NoWarn speeds up the migration but throws away one of v3's most useful gains: cancellable tests.

A Small Experiment

★☆☆ Look at the .csproj of a test project in your own codebase: v2 or v3, which runner? Note the test count from the dotnet test --list-tests output.

★★☆ Without deleting the AlphabeticalOrderer from Predict First in 4.0, turn it into a test case orderer that puts a theory's rows in reverse order, and use it together with AlphabeticalMethodOrderer. Do methods run alphabetically and rows in reverse?

★★★ Migrate the test project from post 29 of the ASP.NET Core Web API series to v3 4.0 with this post's checklist. How many build errors did you get, and which one was not on the checklist? Are the test counts before and after the migration the same?

This post completes the Unit Test series. The question we started with, what a "unit" is, now has a more concrete answer: tests that check behavior, survive refactoring, and are fast and readable; mocks only at the system boundary; design that makes the test's job easier. The next step is the red-green-refactor cycle: writing the test before the code is the topic of the TDD (Test-Driven Development) series; tests that work with a real database and HTTP (HyperText Transfer Protocol) live in the Integration ve E2E (end-to-end) Test series.