テストプロジェクトを 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 つ入れ替え、OutputType を Exe にし、using Xunit.Abstractions; をすべて削除し、IAsyncLifetime を Task から 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 上で確認しています。
これが単なるバージョン上げではない理由
- v2 は機能凍結されています。 2.9.3 (2025-01-08) が v2 の最後のリリースです。
TestContext、キャンセルが実際に効くタイムアウト、アセンブリ単位のフィクスチャ、動的なテストのスキップ、フィルターのクエリ言語は、すべて v3 にしかありません。 - テストプロジェクトが実行可能ファイルになります。 v3 のプロジェクトは生成されたエントリーポイントを持ち、自分自身を実行します。これによってランナーのバージョンとフレームワークのバージョンの不一致という種類のバグが丸ごとなくなり、4.0.0 での Native AOT テストビルドが可能になっています。
TestContext.Current.CancellationTokenによってタイムアウトが実効的になります。 v2 では非同期でないテストに付けた[Fact(Timeout = ...)]は何も中断できませんでした。v3 ではトークンが自分のコードまで流れてくるので、ハングした HTTP 呼び出しが実際にキャンセルされます。- Microsoft.Testing.Platform はオプトインですがネイティブです。
xunit.v34.0.0 のメタパッケージはxunit.v3.mtp-v2に解決され、MTP v2 を一緒に引き込みます。VSTest のホストプロセスなしで--report-trx、CTRF 出力、そしてはるかに高速な起動が手に入ります。
壊れるもの
| 領域 | 変更点 | 深刻度 |
|---|---|---|
xunit.abstractions | パッケージと名前空間が消滅。ITestOutputHelper は Xunit へ移動 | 高 |
| プロジェクトの形式 | OutputType は Exe 必須。SDK 形式のプロジェクトのみ | 高 |
| ターゲットフレームワーク | 最低要件は net472 または net8.0。netcoreapp3.1 から net7.0 は対象外 | 高 |
IAsyncLifetime | IAsyncDisposable を継承。両メソッドが Task ではなく ValueTask を返す | 高 |
async void のテスト | 実行されず、実行時に即座に失敗する | 高 |
| サードパーティーのパッケージ | xunit.core 2.x を参照するパッケージは xunit.v3.core と衝突する | 高 |
| CI のフィルター | VSTest の --filter 式は MTP ではサポートされない | 高 |
MemberDataAttribute | Parameters が Arguments に改名。ConvertDataItem は ConvertDataRow に | 中 |
| 並び順・フレームワークの属性 | CollectionBehavior、TestCaseOrderer、TestFramework は文字列ではなく Type を取る | 中 |
AssemblyTraitAttribute | 削除。代わりに [assembly: Trait(...)] を使う | 低 |
PropertyDataAttribute | 削除 (v1 の時点で非推奨) | 低 |
| リソースの解放 | フィクスチャが IDisposable と IAsyncDisposable の両方を実装している場合、DisposeAsync だけが呼ばれる | 中 |
計画に織り込む必要があるのは、サードパーティーの行と CI の行の 2 つです。それ以外はコンパイラーが教えてくれます。
事前チェックリスト
- .NET 8 以降の SDK がインストールされていること。
xunit.v34.0.0 はnet472とnet8.0を対象としており、コアパッケージにnetstandard2.0の面はありません。 - すべてのテストプロジェクトが SDK 形式であること。 SDK 形式より前の
.csprojはまったくサポートされません。先に、別のコミットで変換してください。 - xUnit 周辺のパッケージを棚卸しすること。 各テストプロジェクトで
dotnet list package --include-transitive | grep -i xunitを実行し、結果を書き出しておきます。移行が 1 時間で終わるか 1 週間かかるかは、このリストが決めます。 - CI がどのランナーを使っているか把握すること。 パイプラインから
dotnet test、--filter、--logger、vstest.console.exeを探します。 - ブランチを切ること。 残りに手を付ける前に、まず 1 つのテストプロジェクトを CI まで通しきってください。
移行手順
-
テストプロジェクトのターゲットを変更し、実行可能ファイルにします。
TargetFrameworkをnet8.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>を設定し、エントリーポイントを自分で持ってください。 -
パッケージ参照を入れ替えます。
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.v34.0.0 はxunit.v3.mtp-v2に解決され、xunit.v3.core.mtp-v2、xunit.v3.assert、xunit.analyzers2.0.0 を引き込みます。xunit.runner.visualstudio4.0.0 とMicrosoft.NET.Test.Sdkは当面残しておいてください。このランナーパッケージは v1、v2、v3 を扱えるので、ソリューションの残りを移行する間もテストエクスプローラーと VSTest が動き続けます。Central Package Management を使っている場合は、代わりにDirectory.Packages.propsで行います。それこそが ソリューションを Directory.Packages.props に移行する 目的です。確認方法:
dotnet restoreが NU1605 のダウングレード警告も型の重複エラーも出さずに成功すること。 -
using Xunit.Abstractions;をすべて削除します。ITestOutputHelperはFactやAssertと並んで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" .がテストプロジェクト配下で何も返さないこと。 -
IAsyncLifetimeの実装をValueTaskに変換します。ここは多くの人が間違える変更です。コンパイラーのエラーが戻り値の型を指すため、その裏にある解放処理のセマンティクスが見えなくなるからです。
IAsyncLifetimeはIAsyncDisposableを継承するようになり、どちらのメンバーも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で孤立したコンテナーが残っていないことを確認します。 -
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")]TestCaseOrdererAttribute、TestCollectionOrdererAttribute、TestFrameworkAttributeも同じ扱いです。MemberDataAttribute.ParametersはArgumentsになり、MemberDataAttributeBaseを派生させていた場合はConvertDataItemがConvertDataRowになってobject[]ではなくITheoryDataRowを返します。確認方法:
dotnet buildが、次の手順で扱うxUnit1051の警告を除いてクリーンであること。 -
TestContext.Current.CancellationTokenをawaitに通します。xunit.analyzers2.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が通ること。あるいは警告のままにして後で戻ってきても構いません。 -
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 であってもテスト数に想定外があれば失敗とみなしてください。
dotnet build -c Releaseが、仕分け済みのもの以外の警告なしで通ること。dotnet run --project MyApp.Tests -- --listで、検出されるテスト数が想定どおりであること。dotnet testを実行し、合計を v2 での最後の実行と比べること。減っている場合、ほぼ必ずフィルターか、スキップされたasync voidのテストが原因です。- テストエクスプローラーを一度開くこと。コマンドラインでは動くのに Visual Studio がハングする場合、それは xUnit v3 プロジェクトでテストエクスプローラーがハングする問題 であって、移行の失敗ではありません。
- カバレッジの数値を確認すること。Coverlet は MTP では接続のしかたが異なり、カバレッジレポートが突然 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 プロパティを指す SkipWhen と SkipUnless のプロパティがあります。1 つの属性に SkipWhen と SkipUnless を両方指定すると、コンパイルエラーではなく実行時の失敗になります。
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 を使った統合テスト のフィクスチャのパターンは、IAsyncLifetime が ValueTask を返すようにすれば書いたとおりに動きます。
関連記事
- 2026 年の xUnit v3 と NUnit と MSTest: どれを選ぶべきか
- Fix: dotnet test は通るのに Visual Studio のテストエクスプローラーが xUnit v3 プロジェクトでハングする
- Microsoft.Testing.Platform 2.3 がテストの失敗を PR の diff に表示する
- ASP.NET Core 11 で WebApplicationFactory を使って統合テストを書く方法
- .NET ソリューションを Directory.Packages.props で Central Package Management に移行する
参考資料
- 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 リリースノート — xUnit.net
- VSTest から Microsoft.Testing.Platform への移行ガイド — Microsoft Learn
- NuGet 上の xunit.v3 — パッケージのメタデータと依存関係の範囲
- 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.