Start Debugging

テストプロジェクトを xUnit v2 から xUnit v3 に移行する (2.9.3 から 4.0.0 へ)

xunit 2.9.3 から xunit.v3 4.0.0 への段階的な移行手順です。パッケージの入れ替え、OutputType を Exe にする変更、ValueTask を返す IAsyncLifetime、Xunit.Abstractions の廃止、そして何も一致しなくなったことを黙って隠す CI のフィルター構文を扱います。

ごく普通のテストプロジェクトを xunit 2.9.3 から xunit.v3 4.0.0 に移行する作業は、機械的な作業でおよそ 1 時間です。パッケージ参照を 4 つ入れ替え、OutputTypeExe にし、using Xunit.Abstractions; をすべて削除し、IAsyncLifetimeTask から ValueTask に変えるだけです。実際に 1 日を消費するのは、テストプロジェクトの周辺にあるものです。v3 向けのビルドがないサードパーティーのパッケージは FactAttribute の重複エラーでコンパイルを止めますし、CI の dotnet test --filter の式はビルドを失敗させないまま何にも一致しなくなります。この移行を行う価値はあります (2.9.3 が 2025 年 1 月にリリースされて以降、機能追加を受けているのは v3 だけです)。そして古いブランチを削除するまでは元に戻せます。以下の内容はすべて、2026-08-15 にリリースされた xunit.v3 4.0.0 に対して、.NET 10 と .NET 11 の SDK 上で確認しています。

これが単なるバージョン上げではない理由

壊れるもの

領域変更点深刻度
xunit.abstractionsパッケージと名前空間が消滅。ITestOutputHelperXunit へ移動
プロジェクトの形式OutputTypeExe 必須。SDK 形式のプロジェクトのみ
ターゲットフレームワーク最低要件は net472 または net8.0netcoreapp3.1 から net7.0 は対象外
IAsyncLifetimeIAsyncDisposable を継承。両メソッドが Task ではなく ValueTask を返す
async void のテスト実行されず、実行時に即座に失敗する
サードパーティーのパッケージxunit.core 2.x を参照するパッケージは xunit.v3.core と衝突する
CI のフィルターVSTest の --filter 式は MTP ではサポートされない
MemberDataAttributeParametersArguments に改名。ConvertDataItemConvertDataRow
並び順・フレームワークの属性CollectionBehaviorTestCaseOrdererTestFramework は文字列ではなく Type を取る
AssemblyTraitAttribute削除。代わりに [assembly: Trait(...)] を使う
PropertyDataAttribute削除 (v1 の時点で非推奨)
リソースの解放フィクスチャが IDisposableIAsyncDisposable の両方を実装している場合、DisposeAsync だけが呼ばれる

計画に織り込む必要があるのは、サードパーティーの行と CI の行の 2 つです。それ以外はコンパイラーが教えてくれます。

事前チェックリスト

移行手順

  1. テストプロジェクトのターゲットを変更し、実行可能ファイルにします。

    TargetFrameworknet8.0 以降に上げ、OutputType を設定します。生成されるエントリーポイントはパッケージ側から来るので、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>

    確認方法: dotnet build が、プロジェクト形式のエラーではなく xUnit の型が見つからないというエラーで失敗すること。テストプロジェクトにすでにトップレベルステートメントがある場合は <XunitAutoGeneratedEntryPoint>false</XunitAutoGeneratedEntryPoint> を設定し、エントリーポイントを自分で持ってください。

  2. パッケージ参照を入れ替えます。

    v2 から v3 への対応は 1 対 1 ですが、xunit.abstractions は消滅し、xunit.console には後継がありません。

    <!-- 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 は xunit.v3.mtp-v2 に解決され、xunit.v3.core.mtp-v2xunit.v3.assertxunit.analyzers 2.0.0 を引き込みます。xunit.runner.visualstudio 4.0.0 と Microsoft.NET.Test.Sdk は当面残しておいてください。このランナーパッケージは v1、v2、v3 を扱えるので、ソリューションの残りを移行する間もテストエクスプローラーと VSTest が動き続けます。Central Package Management を使っている場合は、代わりに Directory.Packages.props で行います。それこそが ソリューションを Directory.Packages.props に移行する 目的です。

    確認方法: dotnet restore が NU1605 のダウングレード警告も型の重複エラーも出さずに成功すること。

  3. using Xunit.Abstractions; をすべて削除します。

    ITestOutputHelperFactAssert と並んで Xunit に置かれるようになったので、ほとんどのファイルでは 1 行消すだけで済みます。

    // 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));
        }
    }

    確認方法: grep -rn "Xunit.Abstractions" . がテストプロジェクト配下で何も返さないこと。

  4. IAsyncLifetime の実装を ValueTask に変換します。

    ここは多くの人が間違える変更です。コンパイラーのエラーが戻り値の型を指すため、その裏にある解放処理のセマンティクスが見えなくなるからです。IAsyncLifetimeIAsyncDisposable を継承するようになり、どちらのメンバーも 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();
    }

    落とし穴があります。フィクスチャが IDisposable IAsyncLifetime の両方を実装している場合、v2 は Dispose() を呼びましたが、v3 は呼びません。両方を実装している場合はどちらか一方だけを呼ぶという .NET のガイダンスに従い、DisposeAsync() だけを呼びます。Dispose() だけに書かれていた後始末は黙って実行されなくなり、多くの場合、失敗するテストとしてではなく、後片付けされない Testcontainers のコンテナーや削除されない一時ディレクトリーとして現れます。その後始末は DisposeAsync() に移してください。これが特に効いてくるのは、実際の SQL Server に対する Testcontainers を使った統合テスト にあるフィクスチャごとにコンテナーを立てるパターンです。

    確認方法: テストスイートを実行し、docker ps -a で孤立したコンテナーが残っていないことを確認します。

  5. async void のテストと、機械的な属性の改名に対応します。

    v3 は async void のテストを撃ちっぱなしで実行するのではなく、実行時に即座に失敗させます。シグネチャを async Task に変えてください。C# における async void と async Task で述べたのと同じ理屈ですが、今度はフレームワークがそれを強制します。続いて、文字列から Type への属性の書き換えを適用します。

    // v2
    [assembly: CollectionBehavior("MyTests.MyCollectionFactory", "MyTests")]
    [assembly: AssemblyTrait("Category", "Integration")]
    
    // v3, xunit.v3 4.0.0
    [assembly: CollectionBehavior(typeof(MyCollectionFactory))]
    [assembly: Trait("Category", "Integration")]

    TestCaseOrdererAttributeTestCollectionOrdererAttributeTestFrameworkAttribute も同じ扱いです。MemberDataAttribute.ParametersArguments になり、MemberDataAttributeBase を派生させていた場合は ConvertDataItemConvertDataRow になって object[] ではなく ITheoryDataRow を返します。

    確認方法: dotnet build が、次の手順で扱う xUnit1051 の警告を除いてクリーンであること。

  6. TestContext.Current.CancellationTokenawait に通します。

    xunit.analyzers 2.0.0 は、CancellationToken を受け取れるのに渡していない呼び出しすべてに xUnit1051 を出します。これはエラーではなく警告なので、触らずに移行することもできますが、このトークンこそが 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);
    }

    確認方法: 対応が終わったら dotnet build -warnaserror:xUnit1051 が通ること。あるいは警告のままにして後で戻ってきても構いません。

  7. CI を新しいフィルター構文に向けます。

    その上で、Microsoft.Testing.Platform を有効にするかを決めます。MTP では xUnit は VSTest の --filter 式言語を受け付けません。代わりに --filter-class--filter-method--filter-namespace--filter-trait、それらの --filter-not-* 版、そして --filter-query を提供します。.NET 8 と 9 の SDK ではプロジェクト単位でオプトインします。

    <!-- .NET 8/9 SDK -->
    <PropertyGroup>
      <TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
    </PropertyGroup>

    .NET 10 以降の SDK では、リポジトリー全体に対して一度だけオプトインします。

    // global.json
    {
      "test": { "runner": "Microsoft.Testing.Platform" }
    }

    フィルターそのものも形が変わります。

    # before, VSTest
    dotnet test --filter "Category!=Integration"
    
    # after, MTP with xunit.v3 4.0.0
    dotnet test -- --filter-not-trait "Category=Integration"

    確認方法: フィルター付きのコマンドを実行し、報告されるテスト数がフィルターなしの件数より少ないことを確認します。ここでビルドが緑であることを信用してはいけません。何にも一致しないフィルターは終了コード 0 で終わるからです。

移行結果の確認

以下を順に実行し、終了コードが 0 であってもテスト数に想定外があれば失敗とみなしてください。

ロールバック

この移行は完全に元に戻せます。パッケージ参照とソースコードの編集だけで、ディスク上の状態もデータベーススキーマも関係しません。コミットを git revert すれば v2 のテストスイートが再び動きます。ただし同じコミットでターゲットフレームワークを net8.0 より下に下げていないことが条件です。フレームワークの変更を別コミットに分けるのは、まさにこのためです。一方通行なのは、公開せざるを得なかったサードパーティーのフォークだけで (下記参照)、それはどちらに転んでも役に立ちます。

始める前に知っておきたい点

FactAttribute の重複エラー。 依存グラフのどこかにまだ xunit.core 2.x を参照するパッケージがあると、次のエラーになります。

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'

エイリアスを使った小技で解決しようとする価値はありません。そのパッケージに v3 向けビルドがあるか、ないかのどちらかです。2026 年 9 月時点では、Verify.XunitV3 32.0.0、AutoFixture.Xunit3 4.19.0、Xunit.DependencyInjection 12.0.1、MartinCostello.Logging.XUnit.v3 0.7.1 はいずれも xunit.v3.* 4.x を参照しています。Serilog.Sinks.XUnit 3.0.19 は依然として xunit.abstractions 2.0.3 と xunit.extensibility.core 2.9.2 を引き込むため、完全なブロッカーです。よくある回避策は、ITestOutputHelper に直接書き込む小さなシンクをリポジトリー内に用意することで、30 行程度で済みます。

Xunit.SkippableFact はもう不要です。 削除してください。v3 には Assert.Skip(reason)Assert.SkipWhen(condition, reason)Assert.SkipUnless(condition, reason) があり、さらに [Fact][Theory] に、テストクラスの public static な bool プロパティを指す SkipWhenSkipUnless のプロパティがあります。1 つの属性に SkipWhenSkipUnless を両方指定すると、コンパイルエラーではなく実行時の失敗になります。

v3 では属性のインスタンスがキャッシュされます。 v2 は問い合わせごとに新しいインスタンスを作っていましたが、v3 はキャッシュします。これは .NET の通常のリフレクションの挙動と一致します。検出時と実行時の間で自身の状態を書き換えるようなカスタム属性は、挙動が変わります。

ソリューション全体でのバージョン固定。 xunit.v3 4.0.0 は xunit.v3.mtp-v2[4.0.0, 4.0.0] という厳密な範囲に固定するため、プロジェクト間でバージョンが混在すると、実行時の不可解な挙動ではなく復元時の競合として表面化します。これは利点ですが、テストプロジェクトは 1 コミットですべて上げるか、まったく上げないかのどちらかになるということでもあります。

カスタムの ITestCaseOrderer 実装は 4.0.0 で変わりました。 v2 と v3 の間だけの話ではありません。並び順はコレクション、クラス、メソッド、ケースの順に実行されるようになり、クラス用とメソッド用の拡張ポイントが別に用意されました。v2 の orderer をそのまま v3.2.2 まで持ち込んでいた場合、コンパイルが通らなくなるのは 4.0.0 です。

WebApplicationFactory<T> は変更不要です。 ASP.NET Core の統合テストはそのまま移行できます。WebApplicationFactory を使った統合テスト のフィクスチャのパターンは、IAsyncLifetimeValueTask を返すようにすれば書いたとおりに動きます。

関連記事

参考資料

Comments

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

< 戻る