Start Debugging

Миграция тестового проекта с xUnit v2 на xUnit v3 (с 2.9.3 на 4.0.0)

Пошаговая миграция с xunit 2.9.3 на xunit.v3 4.0.0: замена пакетов, перевод OutputType в Exe, IAsyncLifetime с возвратом ValueTask, удаление Xunit.Abstractions и синтаксис фильтров в CI, который молча перестаёт совпадать.

Миграция обычного тестового проекта с xunit 2.9.3 на xunit.v3 4.0.0 занимает около часа механической работы: заменить четыре ссылки на пакеты, перевести OutputType в Exe, удалить каждый using Xunit.Abstractions; и сменить IAsyncLifetime с Task на ValueTask. День съедает всё, что находится вокруг тестового проекта: сторонний пакет без сборки под v3 сломает компиляцию ошибкой о дублирующемся FactAttribute, а выражение dotnet test --filter в CI перестанет совпадать с чем-либо, при этом сборка не упадёт. Миграцию стоит делать (v3 остаётся единственной линией, получающей новые возможности с момента выхода 2.9.3 в январе 2025 года), и она обратима вплоть до момента, когда вы удалите старую ветку. Всё изложенное ниже проверено на xunit.v3 4.0.0, выпущенном 2026-08-15, на SDK .NET 10 и .NET 11.

Почему это не просто смена версии

Что ломается

ОбластьИзменениеСерьёзность
xunit.abstractionsПакет и пространство имён исчезли. ITestOutputHelper переехал в Xunitвысокая
Форма проектаOutputType должен быть Exe; только проекты в формате SDKвысокая
Целевая платформаМинимум — net472 или net8.0. От netcoreapp3.1 до net7.0 исключенывысокая
IAsyncLifetimeНаследует IAsyncDisposable; оба метода возвращают ValueTask, а не Taskвысокая
Тесты async voidНемедленно падают во время выполнения вместо запускавысокая
Сторонние пакетыЛюбой пакет, ссылающийся на xunit.core 2.x, конфликтует с xunit.v3.coreвысокая
Фильтры в CIВыражения --filter из VSTest не поддерживаются под MTPвысокая
MemberDataAttributeParameters переименован в Arguments; ConvertDataItem теперь ConvertDataRowсредняя
Атрибуты сортировки и фреймворкаCollectionBehavior, TestCaseOrderer и TestFramework принимают Type, а не строкисредняя
AssemblyTraitAttributeУдалён. Используйте [assembly: Trait(...)]низкая
PropertyDataAttributeУдалён (устарел ещё с v1)низкая
Освобождение ресурсовКогда фикстура реализует и IDisposable, и IAsyncDisposable, вызывается только DisposeAsyncсредняя

Две строки, под которые нужно планировать, — про сторонние пакеты и про CI. Обо всём остальном скажет компилятор.

Предварительная проверка

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

  1. Смените целевую платформу тестового проекта и сделайте его исполняемым.

    Поднимите 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> и возьмите точку входа на себя.

  2. Замените ссылки на пакеты.

    Соответствие v2 и v3 один к одному, кроме того что 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-v2, xunit.v3.assert и xunit.analyzers 2.0.0. Пока оставьте xunit.runner.visualstudio 4.0.0 и Microsoft.NET.Test.Sdk: пакет runner работает с v1, v2 и v3, поэтому Test Explorer и VSTest продолжат работать, пока вы мигрируете остаток решения. Если у вас Central Package Management, делайте это в Directory.Packages.props — ровно в этом и состоит смысл перевода решения на Directory.Packages.props.

    Проверка: dotnet restore проходит без предупреждений NU1605 о понижении версии и без ошибок дублирующихся типов.

  3. Удалите каждый using Xunit.Abstractions;.

    ITestOutputHelper теперь живёт в Xunit, рядом с Fact и Assert, поэтому в большинстве файлов исправление сводится к удалению одной строки.

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

    Именно здесь чаще всего ошибаются, потому что ошибка компилятора указывает на тип возвращаемого значения и прячет за собой семантику освобождения ресурсов. 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 не вызывает. Она вызывает только DisposeAsync(), следуя рекомендации .NET вызывать одно или другое. Любая очистка, жившая исключительно в Dispose(), молча перестаёт выполняться, и обычно это проявляется как утёкший контейнер Testcontainers или неудалённый временный каталог, а не как упавший тест. Перенесите эту очистку в DisposeAsync(). Особенно это важно для схемы “контейнер на фикстуру” из интеграционных тестов против настоящего SQL Server с Testcontainers.

    Проверка: запустите набор тестов и убедитесь командой docker ps -a, что осиротевших контейнеров не осталось.

  5. Исправьте тесты async void и механические переименования атрибутов.

    v3 немедленно роняет тесты async void во время выполнения вместо запуска “выстрелил и забыл”, поэтому смените сигнатуру на async Task. Это те же рассуждения, что изложены в async void против async Task в C#, только теперь их обеспечивает сам фреймворк. Затем примените переход атрибутов со строк на 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 и возвращает ITheoryDataRow вместо object[].

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

  6. Пропустите TestContext.Current.CancellationToken через ваши await.

    xunit.analyzers 2.0.0 выдаёт xUnit1051 на каждый вызов, который принимает CancellationToken и не получает его. Это предупреждение, а не ошибка, и мигрировать можно, не трогая его, но токен — это большая часть смысла перехода на 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 не принимает язык выражений --filter из VSTest; он предоставляет --filter-class, --filter-method, --filter-namespace, --filter-trait, их варианты --filter-not-* и --filter-query. На SDK .NET 8 и 9 включение делается для каждого проекта:

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

    На SDK .NET 10 и новее включение делается один раз для всего репозитория:

    // 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"

    Проверка: выполните команду с фильтром и убедитесь, что количество отчитанных тестов меньше, чем без фильтра. Зелёной сборке здесь верить нельзя: фильтр, не совпавший ни с чем, завершается с нулевым кодом.

Проверка миграции

Выполните это по порядку и считайте любой сюрприз в количестве тестов провалом, даже если код возврата равен нулю.

Откат

Эта миграция полностью обратима: это ссылки на пакеты плюс правки исходного кода, без состояния на диске и без схемы базы данных. 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 года: 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, поэтому это жёсткий блокер; обычный обходной путь — небольшой собственный sink в репозитории, пишущий напрямую в ITestOutputHelper, примерно тридцать строк.

Xunit.SkippableFact теперь лишний. Удалите его. В v3 есть Assert.Skip(reason), Assert.SkipWhen(condition, reason) и Assert.SkipUnless(condition, reason), а также свойства SkipWhen и SkipUnless на [Fact] и [Theory], указывающие на публичное статическое свойство типа bool в классе теста. Задать SkipWhen и SkipUnless одновременно на одном атрибуте — это сбой во время выполнения, а не ошибка компиляции.

В v3 экземпляры атрибутов кешируются. v2 создавала новый экземпляр на каждый запрос; v3 кеширует, что соответствует обычному поведению рефлексии в .NET. Пользовательские атрибуты, менявшие собственное состояние между обнаружением и выполнением, будут вести себя иначе.

Фиксация версий по всему решению. xunit.v3 4.0.0 закрепляет xunit.v3.mtp-v2 в точном диапазоне [4.0.0, 4.0.0], поэтому смешанные версии в разных проектах всплывают как конфликты восстановления, а не как странности во время выполнения. Это плюс, но означает, что все тестовые проекты вы обновляете одним коммитом либо не обновляете вовсе.

Пользовательские реализации ITestCaseOrderer изменились в 4.0.0, а не только между v2 и v3. Сортировка теперь идёт по коллекции, затем классу, затем методу, затем случаю, и появились отдельные точки расширения для классов и методов. Если вы протащили orderer из v2 без изменений через v3.2.2, то на 4.0.0 он перестанет компилироваться.

WebApplicationFactory<T> менять не нужно. Интеграционные тесты ASP.NET Core мигрируют без трения; схема с фикстурой из интеграционных тестов с WebApplicationFactory работает как есть, как только IAsyncLifetime начинает возвращать ValueTask.

Похожие статьи

Источники

Comments

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

< Назад