Start Debugging

Миграция с VSTest на Microsoft.Testing.Platform в SDK .NET 11

Пошаговая миграция с VSTest на Microsoft.Testing.Platform 2.3.3: подключение через OutputType Exe, переключение runner в global.json, логгеры, ставшие репортерами, замена .runsettings на testconfig.json и коды выхода, которые делают зелёную задачу CI красной.

Перевод решения с VSTest на Microsoft.Testing.Platform (MTP) занимает полдня на файлы проектов и целый день на CI. Со стороны проекта это три строки на каждый тестовый проект: <OutputType>Exe</OutputType>, одно свойство для включения вашего тестового фреймворка и global.json, в котором задано "runner": "Microsoft.Testing.Platform". Время съедает всё остальное: каждый флаг --logger, --collect и --blame в вашем конвейере отображается на другую опцию, которая существует только при добавлении соответствующего пакета NuGet, файл .runsettings теряет почти весь смысл, а тестовый проект, выполнивший ноль тестов, теперь роняет сборку с кодом выхода 8 вместо того, чтобы пройти. Это руководство написано для SDK .NET 11 (Preview 7, август 2026), Microsoft.Testing.Platform 2.3.3, MSTest 4.3.3, NUnit3TestAdapter 6.3.0 и xunit.v3 4.0.0.

Почему переходить стоит сейчас

Что ломается

ОбластьИзменениеСерьёзность
Форма проектаТестовые проекты должны задавать <OutputType>Exe</OutputType>высокая
Согласованность решенияПри включённом в global.json MTP каждый тестовый проект обязан использовать MTP. Смешанное решение это ошибка, а не предупреждениевысокая
--loggerПереименовано в “репортеры”. --logger trx становится --report-trx и требует Microsoft.Testing.Extensions.TrxReportвысокая
--collect "Code Coverage"Становится --coverage, требует Microsoft.Testing.Extensions.CodeCoverage, а IncludeTestAssembly теперь по умолчанию falseвысокая
--blame-crash / --blame-hangСтановятся --crashdump / --hangdump из отдельных пакетов. У --blame-crash-collect-always эквивалента нетсредняя
Выполнено ноль тестовVSTest возвращает 0. MTP возвращает код выхода 8высокая
.runsettingsПоддерживается только через мосты VSTest у MSTest и NUnit. Сама платформа читает testconfig.jsonсредняя
dotnet test MyTests.csprojПозиционные пути к проекту исчезли. Используйте --project, --solution или --test-modulesсредняя
Фильтры xUnit--filter не реализован. Используйте --filter-class, --filter-method, --filter-namespace, --filter-trait, --filter-queryвысокая (только xUnit)
RunConfiguration.TargetPlatform=x86Становится --arch x86низкая
Кодировка консолиMTP всегда устанавливает UTF-8. Режим изоляции VSTest по умолчанию этого не делалнизкая

Сроки работ определяют две строки: согласованность решения и --logger. Об остальном инструментарий сообщит сам.

Подготовительный список

Шаги миграции

  1. Зафиксируйте SDK и выберите runner в global.json.

    Выбор runner это решение уровня репозитория, а не отдельного проекта.

    // global.json - .NET 11 SDK
    {
      "sdk": {
        "version": "11.0.100",
        "rollForward": "latestFeature"
      },
      "test": {
        "runner": "Microsoft.Testing.Platform"
      }
    }

    VSTest это второе допустимое значение, и оно остаётся значением по умолчанию, когда раздел test отсутствует. В SDK .NET 11 это можно переопределить на уровне оболочки переменной окружения DOTNET_TEST_RUNNER, и это самый быстрый способ сравнить два варианта задачи CI, не трогая версионируемый файл.

    Проверка: dotnet test --help теперь перечисляет --project, --solution и --test-modules. Если там по-прежнему --logger и --collect, переключение runner не сработало.

  2. Сделайте каждый тестовый проект исполняемым.

    Это универсальное подключение, независимо от фреймворка. Поместите его в Directory.Build.props рядом с тестовыми проектами, а не повторяйте в каждом.

    <!-- tests/Directory.Build.props - .NET 11 SDK, MTP 2.3.3 -->
    <Project>
      <PropertyGroup>
        <OutputType>Exe</OutputType>
      </PropertyGroup>
    </Project>

    Писать Main не нужно. Microsoft.Testing.Platform.MSBuild, который каждый совместимый с MTP фреймворк подтягивает транзитивно, генерирует TestingPlatformEntryPoint за вас.

    Проверка: dotnet build создаёт исполняемый файл MyApp.Tests (или .exe) в выходной папке, и его прямой запуск выполняет набор тестов.

  3. Включите runner своего тестового фреймворка.

    У каждого фреймворка своё свойство, и минимальные версии различаются.

    <!-- tests/Directory.Build.props - pick the one that matches your framework -->
    <PropertyGroup>
      <!-- MSTest 3.2.0+, current 4.3.3 -->
      <EnableMSTestRunner>true</EnableMSTestRunner>
    
      <!-- NUnit3TestAdapter 5.0.0+, current 6.3.0 -->
      <EnableNUnitRunner>true</EnableNUnitRunner>
    
      <!-- xunit.v3 1.0.1+, current 4.0.0 -->
      <UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
    </PropertyGroup>

    Проекты MSTest могут вовсе обойтись без этого свойства, переключив SDK проекта на MSTest.Sdk, где MTP включён по умолчанию. xunit.v3 4.0.0 разрешается в вариант пакета для MTP v2; линейка 3.x по умолчанию использовала MTP v1, от которого 4.0.0 отказалась. Если вы всё ещё на xUnit v2, официального пути к MTP нет, поэтому сначала выполните миграцию с v2 на v3.

    Проверка: запустите исполняемый файл тестов с --help. Вы должны увидеть опции платформы (--filter-uid, --timeout, --list-tests) плюс всё, что регистрирует ваш фреймворк.

  4. Удалите переходные свойства эпохи .NET 9.

    Многие статьи в блогах и даже части страницы MSTest на MS Learn всё ещё их показывают. В SDK .NET 10 или .NET 11 с выбором runner через global.json они устарели и должны быть удалены:

    <!-- delete these from every test project and Directory.Build.props -->
    <TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
    <TestingPlatformShowTestsFailure>true</TestingPlatformShowTestsFailure>

    Разделитель --, который они требовали, тоже становится необязательным, хотя в CI его стоит сохранить по причине, описанной в шаге 6.

    Проверка: dotnet test по-прежнему выполняется, а вывод консоли показывает терминальный репортер MTP, а не VSTest.

  5. Верните логгеры и коллекторы в виде пакетов расширений.

    Ядро MTP не содержит ни одного из них. Если конвейер передаёт опцию, пакет которой отсутствует, запуск падает с кодом выхода 5, потому что опция не распознана.

    <!-- tests/Directory.Build.props - MTP 2.3.3 extensions -->
    <ItemGroup>
      <PackageReference Include="Microsoft.Testing.Extensions.TrxReport" Version="2.3.3" />
      <PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.10.0" />
      <PackageReference Include="Microsoft.Testing.Extensions.HangDump" Version="2.3.3" />
      <PackageReference Include="Microsoft.Testing.Extensions.CrashDump" Version="2.3.3" />
    </ItemGroup>

    Расширение покрытия кода версионируется независимо от платформы: оно следует нумерации тестовой платформы Visual Studio, поэтому текущий выпуск 18.10.0, тогда как остальные находятся на 2.3.3. Документированная таблица совместимости сопоставляет линейку 18.1.x с MTP 2.0.x, 18.0.x с 1.8.x и 17.14.x с 1.6.2, а рекомендация состоит в том, чтобы держать обе стороны на последних версиях. Если вы используете Central Package Management, им место в Directory.Packages.props, и это ещё один аргумент за то, чтобы перевести решение на Directory.Packages.props до начала работ.

    Проверка: dotnet test --help перечисляет --report-trx, --coverage, --hangdump и --crashdump.

  6. Переведите командную строку CI.

    Здесь и находится основной объём работы. Соответствие:

    # before - VSTest, .NET 9 SDK
    dotnet test MyApp.sln \
      --logger "trx;LogFileName=results.trx" \
      --collect "Code Coverage" \
      --blame-hang-timeout 5m \
      --results-directory ./artifacts/tests \
      --filter "TestCategory=Integration"
    # after - MTP 2.3.3, .NET 11 SDK
    dotnet test --solution MyApp.sln \
      --results-directory ./artifacts/tests \
      -- --report-trx --report-trx-filename results.trx \
         --coverage --coverage-output-format cobertura \
         --hangdump --hangdump-timeout 5m \
         --filter "TestCategory=Integration"

    Обратите внимание на три вещи. Позиционный MyApp.sln превратился в --solution, потому что dotnet test в режиме MTP больше не принимает голый путь. Разделитель -- формально необязателен начиная с SDK .NET 10, но dotnet test передаёт нераспознанные токены тестовому приложению, и распознанная опция SDK, оказавшаяся между именем нераспознанной опции и её значением, меняет привязку оставшихся токенов. Поместите аргументы тестового приложения после --, и неоднозначность исчезнет. Наконец, --results-directory понимают и SDK, и платформа, поэтому он может стоять с любой стороны.

    Для решения, в котором смешаны фреймворки или наборы расширений, маршрутизируйте аргументы по проектам, а не глобально:

    <!-- only the projects that reference HangDump get the option -->
    <PropertyGroup Condition="'$(MSBuildProjectName)' == 'MyApp.Integration.Tests'">
      <TestingPlatformCommandLineArguments>
        $(TestingPlatformCommandLineArguments) --hangdump --hangdump-timeout 5m
      </TestingPlatformCommandLineArguments>
    </PropertyGroup>

    Проверка: запуск создаёт results.trx и файл Cobertura в ./artifacts/tests, а код выхода равен 0.

  7. Замените .runsettings на testconfig.json.

    MSTest и NUnit продолжают учитывать --settings config.runsettings через свои мосты VSTest, так что этот шаг можно отложить. xUnit v3 так не умеет, а сама платформа runsettings не читает никогда. Замена:

    // testconfig.json at the repo root - MTP 2.3.3
    {
      "platformOptions": {
        "resultDirectory": "./artifacts/tests",
        "exitProcessOnUnhandledException": false
      },
      "environmentVariables": {
        "DOTNET_ENVIRONMENT": "Testing"
      },
      "mstest": {
        "parallelism": { "enabled": true, "workers": 4, "scope": "method" },
        "timeout": { "test": 30000 }
      }
    }

    Соответствие не один к одному. RunConfiguration/ResultsDirectory становится platformOptions.resultDirectory. У RunConfiguration/MaxCpuCount эквивалента нет, потому что параллелизм на уровне процессов теперь задаётся через --max-parallel-test-modules. LoggerRunSettings/Loggers и всё, что находится под DataCollectionRunSettings, превращается в опции CLI из шага 5. TestRunParameters становится --test-parameter key=value. Начиная с MTP 2.3.0 сами опции CLI тоже можно помещать в testconfig.json, включая опции расширений, и именно так --coverage-output-format cobertura не попадает в каждый файл конвейера; раздел environmentVariables также доступен с 2.3.0.

    Направьте все проекты на один общий файл через Directory.Build.props:

    <PropertyGroup>
      <TestingPlatformCommandLineArguments>
        $(TestingPlatformCommandLineArguments) --config-file $(MSBuildThisFileDirectory)testconfig.json
      </TestingPlatformCommandLineArguments>
    </PropertyGroup>

    Проверка: удалите ссылку на .runsettings из CI и убедитесь, что результаты по-прежнему попадают в настроенный каталог.

  8. Замените саму задачу CI.

    В Azure DevOps замените задачу VSTest@2 на DotNetCoreCLI@2. Это обычный вызов dotnet test, поэтому правила шага 6 применяются дословно:

    # azure-pipelines.yml - .NET 11 SDK, MTP 2.3.3
    - task: DotNetCoreCLI@2
      inputs:
        command: 'test'
        arguments: '--solution MyApp.sln -- --report-trx --results-directory $(Agent.TempDirectory)'

    В GitHub Actions пакет Microsoft.Testing.Extensions.GitHubActionsReport вместе с --report-gh помещает падения прямо в diff pull request, и это та самая история с отчётами, которая стала стабильной в MTP 2.3. Обратите внимание на почти совпадение: сторонний пакет GitHubActionsTestLogger использует --report-github, отличающийся от официальной опции на один символ.

    Проверка: намеренно падающий тест даёт красную задачу, где падение видно в сводке запуска, а не только в сыром журнале.

Проверьте миграцию

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

Откат

Эта миграция откатывается одним коммитом до тех пор, пока Microsoft.NET.Test.Sdk и пакет адаптера VSTest вашего фреймворка остаются в ссылках. Удалите раздел test из global.json, и runner вернётся к VSTest; OutputType=Exe и свойства подключения под VSTest не действуют. Именно поэтому не стоит удалять xunit.runner.visualstudio и Microsoft.NET.Test.Sdk в том же pull request. Проведите очистку через неделю, когда CI и IDE каждого разработчика поработают на MTP.

Подводные камни, о которых стоит знать заранее

Код выхода 8 делает зелёную задачу красной. Проект, выполнивший ноль тестов, завершается с 8 под MTP и с 0 под VSTest. Это бьёт по решениям с проектом-заглушкой или с фильтром, который ни с чем не совпадает. Либо исправьте фильтр, либо явно откажитесь от такого поведения:

<PropertyGroup>
  <TestingPlatformCommandLineArguments>
    $(TestingPlatformCommandLineArguments) --ignore-exit-code 8
  </TestingPlatformCommandLineArguments>
</PropertyGroup>

--ignore-exit-code принимает список через точку с запятой (--ignore-exit-code 2;8), а TESTINGPLATFORM_EXITCODE_IGNORE делает то же самое через окружение. Отдельно MTP 2.3.0 изменил случай, когда пропущены все тесты: запуск, в котором каждый тест пропущен, теперь по умолчанию считается успешным, а --zero-tests-policy strict возвращает поведение до 2.3.0.

Смешанное решение это ошибка, а не предупреждение. Как только global.json выбирает MTP, dotnet test ожидает, что каждый тестовый проект в графе является проектом MTP. Один отставший на VSTest роняет весь запуск. Сначала переводите листовые проекты, а global.json переключайте последним.

Код выхода 5 означает отсутствующий пакет, а не опечатку. Если половина проектов ссылается на Microsoft.Testing.Extensions.HangDump, а половина нет, --hangdump для одних допустима, а для других неизвестна, и запуск падает с 5. Используйте условные TestingPlatformCommandLineArguments по проектам из шага 6.

xUnit игнорирует --filter. MSTest и NUnit сохраняют под MTP синтаксис выражений VSTest (FullyQualifiedName~UnitTest1|TestCategory=CategoryA). xUnit v3 не реализует его вообще: нужны --filter-class, --filter-method, --filter-namespace, --filter-trait или --filter-query плюс их отрицательные варианты. Фильтр CI, который молча ни с чем не совпадает, затем срабатывает кодом выхода 8, и именно так это проявляется на практике. Тот же класс проблем с молчаливыми фильтрами стоит понимать, если вы заодно сравниваете xUnit v3 с NUnit и MSTest.

Цифры покрытия сдвинутся. IncludeTestAssembly по умолчанию равен false в Microsoft.Testing.Extensions.CodeCoverage, а в VSTest был true. Ваш общий процент покрытия изменится на коммите миграции по причинам, не связанным с вашим кодом. Предупредите того, кто следит за порогом покрытия, до отправки изменений.

Сгенерированная точка входа даёт две странные ошибки компиляции. Microsoft.Testing.Platform.MSBuild помещает TestingPlatformEntryPoint и SelfRegisteredExtensions внутрь $(RootNamespace), который по умолчанию равен имени проекта. Проект с именем Contoso.Serialization.Tests, который заодно ссылается на пакет Contoso.Serialization, может выдать CS0118: 'Serialization' is a namespace but is used like a type; задайте <RootNamespace>Contoso.SerializationTests</RootNamespace> или очистите его через <RootNamespace />. Отдельно нетестовый проект, ссылающийся на тестовый, упирается в CS8892, потому что сгенерированная точка входа конфликтует с его Main; задайте <IsTestingPlatformApplication>false</IsTestingPlatformApplication> в ссылающемся проекте или <GenerateTestingPlatformEntryPoint>false</GenerateTestingPlatformEntryPoint> в тестовом.

У странностей Test Explorer есть собственный переключатель. Если обнаружение тестов ведёт себя некорректно в IDE, <DisableTestingPlatformServerCapability>true</DisableTestingPlatformServerCapability> отключает серверный режим MTP, и IDE возвращается к адаптеру VSTest. Это обходной путь, а не решение, и это другая проблема, нежели зависание Test Explorer при проходящем dotnet test.

SDK .NET 11 делает момент удачным: --timeout и --maximum-failed-tests на уровне запуска, --no-dependencies, --use-current-runtime, шаблоны исключения с префиксом ! для --test-modules, поддержка Microsoft.Build.Traversal и живое отображение выполняющихся тестов в интерактивных терминалах. Ничего из этого на пути VSTest нет.

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

Источники

Comments

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

< Назад