Start Debugging

Исправление: обозреватель тестов Visual Studio зависает на проекте xUnit v3, хотя dotnet test проходит

Обозреватель тестов запускает xUnit v3 через серверный режим Microsoft.Testing.Platform по сокету JSON-RPC, а dotnet test нет. Задайте UseMicrosoftTestingPlatformRunner, чтобы оба пути были одним кодом, либо DisableTestingPlatformServerCapability для возврата к VSTest.

Обозреватель тестов и dotnet test выполняют не один и тот же код. Тестовый проект xUnit v3 собирается в исполняемый файл, чья сгенерированная точка входа ветвится по одному-единственному аргументу: если Visual Studio передаёт --server, процесс отдаёт управление Microsoft.Testing.Platform и пытается открыть сокет JSON-RPC обратно к IDE; иначе запускается собственный внутрипроцессный консольный runner 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. В предварительных версиях .NET 11 механизм не меняется, поскольку он находится в MSBuild-таргетах xUnit и MTP, а не в SDK.

Симптомы, которым посвящена статья

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 генерирует для них Main в файл obj/Debug/net10.0/XunitAutoGeneratedEntryPoint.cs. Откройте этот файл в любом проекте v3, и вся суть проблемы уместится в одну строку:

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

Два runner-а, один бинарный файл. dotnet test, dotnet run и двойной щелчок по exe идут по ветке else и используют внутрипроцессный консольный runner 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-порт на петлевом интерфейсе, а тестовый процесс сам подключается к нему. Обнаружение и выполнение после этого представляют собой запросы JSON-RPC вида testing/discoverTests и testing/runTests, отправляемые по этому сокету. Всё, что мешает процессу завершить рукопожатие или отвечать после него, проявляется как зависание и никогда как упавший тест, потому что ни один тест так и не выполнился.

Visual Studio вообще пробует такой путь только тогда, когда проект объявляет соответствующую возможность. Она берётся из Microsoft.Testing.Platform.targets в пакете microsoft.testing.platform:

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

Проверить, что этот проект объявляет на самом деле, можно одной строкой, и это первое, что стоит выполнить, когда обозреватель тестов ведёт себя странно:

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

Для проекта выше вы получите и TestingPlatformServer, и TestContainer. Это проект, настроенный на серверный режим MTP и одновременно тянущий за собой полный стек VSTest: в выходной папке лежит Microsoft.Testing.Platform.dll рядом с Microsoft.VisualStudio.TestPlatform.Common.dll, testhost.exe и xunit.runner.visualstudio.testadapter.dll. Две тестовые платформы в одной папке bin допустимы, но это значит, что runner, который вы проверяете из командной строки, не обязательно тот, который выбирает IDE.

Исправление 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 относится к MTP, а не к xUnit, и до этого изменения 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, либо получил и заблокировался внутри теста.

Дополните это режимом MTP для dotnet test в global.json, чтобы командная строка перестала проходить через мост 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 исчезает из списка возможностей, TestContainer сохраняется, поскольку его добавляет также Microsoft.NET.Test.Sdk, и Visual Studio возвращается к обнаружению тестов через xunit.runner.visualstudio. Проверяйте, а не предполагайте:

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

Перезапуск обязателен. Возможности проекта считываются при его загрузке, поэтому одна лишь пересборка оставит запущенную IDE со старым решением.

Исправление 3: удалите написанный вручную Main

Эту причину стоит проверять раньше остальных, потому что она даёт в точности описанную картину: командная строка зелёная, обозреватель тестов мёртв. Добавление Program.cs с инструкциями верхнего уровня в проект xUnit v3 заменяет не часть сгенерированной точки входа, а её целиком, и компилятор лишь предупреждает:

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

Сборка с таким предупреждением всё равно успешна. Вот Program.cs, который выглядит вполне разумно и вручную поднимает консольный runner 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);

Запустите его без аргументов, и два теста пройдут. Запустите так, как это делает 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 это runner, который ни к чему не подключился, что проявляется как ошибка подключения или как бесконечное ожидание. Если своя точка входа действительно нужна, задайте <GenerateTestingPlatformEntryPoint>false</GenerateTestingPlatformEntryPoint>, стройте её поверх TestApplication.CreateBuilderAsync(args) из Microsoft.Testing.Platform и передавайте args без изменений. Проглатывание массива аргументов и есть ошибка.

Исправление 4: одна мажорная версия Microsoft.Testing.Platform на решение

xUnit публикует варианты пакетов, закрепляющие разные мажорные версии MTP, и их смешение в одном решении порождает картину “часть проектов запускается, часть случайно нет” при запуске по всему решению. Измеренные разрешения зависимостей для xunit.v3 3.2.2:

Ссылка на пакетРазрешённая Microsoft.Testing.Platform
xunit.v31.9.1
xunit.v3.mtp-v22.0.2
xunit.v3.mtp-offнет, только VSTest

Выберите один вариант и задайте его один раз в Directory.Build.props, а не в каждом проекте. Собственные рекомендации Microsoft по TestingPlatformDotnetTestSupport говорят то же самое про другое свойство: решение, где одни проекты используют одну платформу, а другие вторую, “может работать некорректно и является неподдерживаемым сценарием”. Новый тестовый проект из шаблона и есть обычный способ, которым рассогласование проникает внутрь.

Почему зелёный dotnet test доказывает меньше, чем кажется

Есть вариант и похуже: dotnet test может завершиться с кодом 0, не выполнив вообще ничего. Возьмите проект только с MTP, без Microsoft.NET.Test.Sdk, без xunit.runner.visualstudio и без секции test в global.json. dotnet test по умолчанию использует режим VSTest, не находит ни одного адаптера VSTest и рапортует об успехе:

# .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

Тестов два. Выполнилось ноль. Код возврата 0. Добавьте секцию runner в global.json из исправления 1, и та же команда сообщит total: 2, succeeded: 2. Если ваш CI сейчас зелёный на проекте xUnit v3, проверьте счётчик тестов в журнале, прежде чем ему доверять. Дешёвая страховка это --minimum-expected-tests через TestingPlatformCommandLineArguments:

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

Похожие случаи, которые не являются этой ошибкой

Не всякий застрявший запуск в обозревателе тестов связан с серверным режимом. Эти варианты проверяйте после четырёх исправлений выше:

Связанные материалы

Если вы ещё выбираете фреймворк для стандартизации, измеренное сравнение xUnit v3, NUnit и MSTest разбирает то же разделение пакетов между MTP и VSTest со стороны выбора фреймворка. Когда набор тестов заработает стабильно, отчётность MTP 2.3 для GitHub Actions превратит падения в аннотации прямо в диффе pull request. Для уровня интеграционных тестов есть разбор WebApplicationFactory в ASP.NET Core 11 и сравнение WebApplicationFactory с Testcontainers. Если ваш CI сломался вокруг тестового инструментария по другой причине, удаление Newtonsoft.Json из VSTest это второе изменение, которое зацепило многих в этом году.

Источники

Comments

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

< Назад