Start Debugging

修正: dotnet test は通るのに Visual Studio のテスト エクスプローラーが xUnit v3 プロジェクトでハングする

テスト エクスプローラーは Microsoft.Testing.Platform のサーバーモードを使い JSON-RPC ソケット経由で xUnit v3 を実行しますが、dotnet test は違います。UseMicrosoftTestingPlatformRunner で両方の経路を同じコードに揃えるか、DisableTestingPlatformServerCapability で VSTest に戻してください。

テスト エクスプローラーと dotnet test は同じコードを実行していません。xUnit v3 のテストプロジェクトは実行可能ファイルとしてビルドされ、その自動生成されたエントリポイントはたった 1 つの引数で分岐します。Visual Studio が --server を渡した場合、プロセスは制御を Microsoft.Testing.Platform に渡し、IDE へ向けて JSON-RPC ソケットを開こうとします。そうでない場合は xUnit 自身のインプロセス コンソールランナーが動きます。コマンドラインで緑になっても、サーバーモード側の経路については何も保証されません。最も手早い修正は <UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner> を追加して両方の経路を同一にすることです。今すぐテスト エクスプローラーを動かしたい場合は <DisableTestingPlatformServerCapability>true</DisableTestingPlatformServerCapability> を追加し、Visual Studio を再起動して VSTest アダプターに戻してください。

以下の内容はすべて .NET SDK 10.0.201 (ランタイム 10.0.5)、xunit.v3 3.2.2、xunit.runner.visualstudio 3.1.5、Microsoft.NET.Test.Sdk 17.14.1、Microsoft.Testing.Platform 1.9.1 で計測しました。この仕組みは SDK ではなく xUnit と MTP の MSBuild ターゲット側にあるため、.NET 11 のプレビューでも変わりません。

この記事が扱う症状

Unable to connect to testing platform runner process [MyProject.Tests.dll]

あるいはエラーがまったく出ない場合もあります。テスト エクスプローラーのスピナーが回り続け、テスト数がゼロのままか、ソリューション全体の実行の途中で止まり、MyProject.Tests.exe プロセスが実行を停止するまでタスク マネージャーに居座ります。その一方で、次のようになります。

> dotnet test
Test run summary: Passed!
  total: 2
  failed: 0
  succeeded: 2

テスト エクスプローラーと dotnet test が同じコードを実行しない理由

xUnit v3 のテストプロジェクトは実行可能ファイル (<OutputType>Exe</OutputType>) であり、xunit.v3.msbuildtasks がその Mainobj/Debug/net10.0/XunitAutoGeneratedEntryPoint.cs に生成します。任意の v3 プロジェクトでこのファイルを開けば、問題のすべてが 1 行に収まっていることがわかります。

// Generated by xunit.v3.msbuildtasks 3.2.2, .NET SDK 10.0.201.
// obj/Debug/net10.0/XunitAutoGeneratedEntryPoint.cs, reformatted for width.
public static int Main(string[] args)
{
    if (Enumerable.Any(args, arg => arg == "--server" || arg == "--internal-msbuild-node"))
        return TestPlatformTestFramework
            .RunAsync(args, SelfRegisteredExtensions.AddSelfRegisteredExtensions)
            .GetAwaiter().GetResult();
    else
        return ConsoleRunner.Run(args).GetAwaiter().GetResult();
}

ランナーは 2 つ、バイナリは 1 つです。dotnet testdotnet run、そして exe のダブルクリックはいずれも else の分岐に進み、xUnit のインプロセス コンソールランナーを使います。Visual Studio は if の分岐に進みます。

Visual Studio が渡す内容は次のようなもので、手動でも実行できます。

# .NET SDK 10.0.201, xunit.v3 3.2.2. Port number is chosen by the IDE.
> MyProject.Tests.exe --server jsonrpc --client-host 127.0.0.1 --client-port 59999
xUnit.net v3 Microsoft.Testing.Platform v1 Runner v3.2.2+728c1dce01 (64-bit .NET 10.0.5)

Connecting to client host '127.0.0.1' port '59999'
[ServerTestHost.OnCurrentDomainUnhandledException] System.Net.Sockets.SocketException (10061):
No connection could be made because the target machine actively refused it.
   at Microsoft.Testing.Platform.ServerMode.ServerModeManager.MessageHandlerFactory
      .CreateMessageHandlerAsync(CancellationToken cancellationToken)
   at Microsoft.Testing.Platform.Hosts.ServerTestHost.InternalRunAsync()

向きが重要です。Visual Studio がループバックの TCP ポートで待ち受け、テストプロセスの側からそこへ接続しにいきます。以降の検出と実行は、そのソケット上でやり取りされる testing/discoverTeststesting/runTests といった JSON-RPC リクエストです。プロセスがこのハンドシェイクを完了できない、あるいはその後の応答ができない場合、それはハングとして現れ、テストの失敗としては決して現れません。テストが 1 つも実行されていないからです。

そもそも Visual Studio がこれを試みるのは、プロジェクトが機能 (capability) を宣言している場合だけです。この宣言は microsoft.testing.platform パッケージ内の Microsoft.Testing.Platform.targets に由来します。

<!-- microsoft.testing.platform 2.3.3, buildMultiTargeting/Microsoft.Testing.Platform.targets -->
<ItemGroup Condition=" '$(DisableTestingPlatformServerCapability)' != 'true'
                       AND '$(IsTestingPlatformApplication)' == 'true' ">
  <ProjectCapability Include="TestingPlatformServer" />
  <ProjectCapability Include="TestContainer" />
</ItemGroup>

スイッチは TestingPlatformServer です。現在の Visual Studio は Microsoft.Testing.Platform 版のテスト エクスプローラー体験を既定で有効にして出荷しているため、この機能を持つプロジェクトは、こちらが望んだかどうかに関係なくサーバーモードで動かされます。

最小再現

ごく普通の xUnit v3 プロジェクトで、特別なものは何もありません。

<!-- .NET SDK 10.0.201. Reproduces on net10.0 and net11.0. -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <OutputType>Exe</OutputType>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="xunit.v3" Version="3.2.2" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
  </ItemGroup>
</Project>

そのプロジェクトが実際に何を宣言しているかは 1 行で確認できます。テスト エクスプローラーの挙動がおかしいとき、最初に実行すべきコマンドです。

# .NET SDK 10.0.201
> dotnet msbuild -getItem:ProjectCapability

上のプロジェクトでは TestingPlatformServerTestContainer の両方が得られます。これは MTP のサーバーモード向けに配線されていながら、同時に VSTest のスタック一式も抱えているプロジェクトです。出力フォルダーには Microsoft.Testing.Platform.dllMicrosoft.VisualStudio.TestPlatform.Common.dlltesthost.exexunit.runner.visualstudio.testadapter.dll と並んでいます。1 つの bin フォルダーに 2 つのテストプラットフォームがあること自体は問題ありませんが、コマンドラインから動かしているランナーが IDE の選ぶランナーとは限らない、ということを意味します。

修正 1: 両方のコード経路を同一にする、これが本当の修正

プロパティを 1 つ設定すると、生成されるエントリポイントが反転します。

<!-- Directory.Build.props, .NET SDK 10.0.201, xunit.v3 3.2.2 -->
<Project>
  <PropertyGroup>
    <UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
  </PropertyGroup>
</Project>

リビルドして XunitAutoGeneratedEntryPoint.cs をもう一度読んでみてください。

// Generated with UseMicrosoftTestingPlatformRunner=true. The branch is reversed.
public static int Main(string[] args)
{
    if (Enumerable.Any(args, arg => arg == "-automated" || arg == "@@"))
        return ConsoleRunner.Run(args).GetAwaiter().GetResult();
    else
        return TestPlatformTestFramework
            .RunAsync(args, SelfRegisteredExtensions.AddSelfRegisteredExtensions)
            .GetAwaiter().GetResult();
}

これで Microsoft.Testing.Platform がすべての呼び出しにおける既定の経路になり、ローカルで成功した実行がテスト エクスプローラーの使うコードを実際に通ったことになります。あわせて MTP のコマンドラインも使えるようになり、診断機能はそこにあります。--diagnostic は xUnit ではなく MTP のオプションで、この変更前は exe が error: unknown option: --diagnostic として拒否します。

# .NET SDK 10.0.201, with UseMicrosoftTestingPlatformRunner=true
> MyProject.Tests.exe --diagnostic
Test run summary: Passed!
  total: 2
> ls bin/Debug/net10.0/TestResults/
log_260813111551948.diag

サーバーモードが止まったときに読むべきなのが、この .diag ファイルです。プラットフォームの起動シーケンスとすべての JSON-RPC メッセージが記録されるため、プロセスがそもそも接続しなかったのか、接続はしたが testing/runTests を受け取らなかったのか、受け取ったうえでテスト内部でブロックしたのかを見分けられます。

これを global.json での dotnet test の MTP モードと組み合わせれば、コマンドラインが VSTest ブリッジを経由しなくなります。

{
  "test": { "runner": "Microsoft.Testing.Platform" }
}

MTP モードでは TestingPlatformDotnetTestSupport は不要になり、MTP の引数に追加の -- 区切りも必要なくなります。

修正 2: 機能をオフにして VSTest に戻す

今すぐ動くテスト エクスプローラーが必要な場合は、代わりに機能そのものを取り除きます。

<!-- Directory.Build.props. Restart Visual Studio after changing this. -->
<PropertyGroup>
  <DisableTestingPlatformServerCapability>true</DisableTestingPlatformServerCapability>
</PropertyGroup>

これは xUnit が文書化している回避策で、ターゲットの記述どおりに動きます。TestingPlatformServer は機能一覧から消え、TestContainerMicrosoft.NET.Test.Sdk も提供しているため残り、Visual Studio は xunit.runner.visualstudio 経由のテスト検出に戻ります。推測せず確認してください。

> dotnet msbuild -p:DisableTestingPlatformServerCapability=true -getItem:ProjectCapability

再起動は省略できません。プロジェクトの機能はプロジェクト読み込み時に読まれるため、リビルドだけでは起動中の IDE は古い判断のままです。

修正 3: 手書きの Main を削除する

この原因は他より先に確認する価値があります。報告される症状そのもの、つまりコマンドラインは緑でテスト エクスプローラーは沈黙、という形をぴったり作り出すからです。xUnit v3 プロジェクトにトップレベル ステートメントの Program.cs を追加すると、生成されたエントリポイントの一部ではなく全体が置き換わり、コンパイラーは警告を出すだけです。

warning CS7022: The entry point of the program is global code;
ignoring 'XunitAutoGeneratedEntryPoint.Main(string[])' entry point.

この警告が出てもビルドは成功します。次の Program.cs はまったく妥当に見えるもので、xUnit のコンソールランナーを手作業で組み立てています。

// .NET SDK 10.0.201, xunit.v3 3.2.2. This builds, and it breaks Test Explorer.
using Xunit.Runner.InProc.SystemConsole;

return await ConsoleRunner.Run(args);

引数なしで実行すれば 2 件のテストが通ります。Visual Studio と同じ形で実行すると、--server の処理そのものが存在しません。

> MyProject.Tests.exe
=== TEST EXECUTION SUMMARY ===
   MyProject.Tests  Total: 2, Errors: 0, Failed: 0, Skipped: 0

> MyProject.Tests.exe --server jsonrpc --client-host 127.0.0.1 --client-port 59999
error: unknown option: --server

プロセスは stdout に出力してソケットを一度も開かないまま終了コード 3 で終わります。Visual Studio 側から見れば、これはどこにも接続しなかったランナーであり、接続エラーとして、あるいは終わらない待機として現れます。どうしても独自のエントリポイントが必要なら、<GenerateTestingPlatformEntryPoint>false</GenerateTestingPlatformEntryPoint> を設定し、Microsoft.Testing.PlatformTestApplication.CreateBuilderAsync(args) を使って組み立て、args をそのまま渡してください。引数配列を握りつぶすことがバグの正体です。

修正 4: ソリューションごとに Microsoft.Testing.Platform のメジャーバージョンを 1 つに揃える

xUnit は異なる MTP メジャーバージョンを固定するバリアント パッケージを公開しており、それらを 1 つのソリューション内で混在させると、ソリューション全体の実行で「一部のプロジェクトは動き、一部はランダムに動かない」というパターンが生まれます。xunit.v3 3.2.2 で計測した解決結果は次のとおりです。

パッケージ参照解決された Microsoft.Testing.Platform
xunit.v31.9.1
xunit.v3.mtp-v22.0.2
xunit.v3.mtp-offなし、VSTest のみ

どれか 1 つを選び、プロジェクトごとではなく Directory.Build.props で一度だけ設定してください。Microsoft 自身の TestingPlatformDotnetTestSupport に関する指針も、別のプロパティについて同じことを述べています。一部のプロジェクトが一方のプラットフォームを、残りがもう一方を使うソリューションは「正しく動作しない可能性があり、サポートされないシナリオです」。テンプレートから新しいテストプロジェクトを追加することが、こうした不一致が紛れ込む典型的な経路です。

緑の dotnet test が証明することは思ったより少ない

これにはもっと厄介な変種があります。dotnet test が何も実行しないまま終了コード 0 を返すことがあるのです。MTP のみのプロジェクトで、Microsoft.NET.Test.Sdkxunit.runner.visualstudio もなく、global.jsontest セクションもない状態を考えてください。dotnet test は既定で VSTest モードになり、VSTest アダプターを 1 つも見つけられず、成功を報告します。

# .NET SDK 10.0.201, xunit.v3 3.2.2 only, no global.json test runner section
> dotnet test
  Determining projects to restore...
  All projects are up-to-date for restore.
> echo $LASTEXITCODE
0

テストは 2 件存在します。実行されたのは 0 件です。終了コードは 0 です。修正 1 の global.json のランナー セクションを追加すれば、同じコマンドが total: 2, succeeded: 2 を報告します。xUnit v3 プロジェクトで CI が今まさに緑なら、それを信用する前にログのテスト件数を確認してください。安価な防御策は TestingPlatformCommandLineArguments 経由の --minimum-expected-tests です。

<PropertyGroup>
  <TestingPlatformCommandLineArguments>--minimum-expected-tests 1</TestingPlatformCommandLineArguments>
</PropertyGroup>

このバグではないよく似たケース

止まったテスト エクスプローラーの実行がすべてサーバーモード由来とは限りません。以下は上記 4 つの修正の後に確認してください。

関連記事

標準化するフレームワークをまだ検討中なら、xUnit v3、NUnit、MSTest の計測にもとづく比較が、同じ MTP と VSTest のパッケージ分割をフレームワーク選定の観点から扱っています。テスト一式が安定して動くようになったら、MTP 2.3 の GitHub Actions レポートが失敗を pull request の差分上の注釈に変えてくれます。結合テストの層については、ASP.NET Core 11 における WebApplicationFactory の解説と、WebApplicationFactory と Testcontainers の比較があります。無関係な理由でテスト ツール周りの CI が壊れたなら、VSTest からの Newtonsoft.Json の削除が今年多くの人を悩ませたもう 1 つの変更です。

参照元

Comments

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

< 戻る