База про юнит-тесты в C# на xUnit v3

от автора

Привет, Хабр!

Сегодня разберёмся с юнит‑тестами в C# на основе xUnit v3 — библиотеки, которая стала практически стандартом де‑факто в.NET‑среде.

Почему именно xUnit? Всё просто: его создали Джим Ньюкирк и Брэд Уилсон — разработчики NUnit. Они решили выкинуть всю архаику вроде [SetUp], [TearDown] и прочих рудиментов и построили фреймворк с нуля, строго под TDD. Весной вышла xUnit v3 2.0.2, в которой завезли Assert.MultipleAsync, полностью обновили сериализацию. А в.NET 9 уже штатно продвигается Microsoft.Testing.Platform (MTP) — сверхлёгкий тестовый рантайм, с которым xUnit v3 работает прямо из коробки. Короче говоря, это самый нативный выбор под.NET 9 на сегодня.

Устанавливаем инструменты

# обновляем темплейты до v3 dotnet new install xunit.v3.templates dotnet new update  # создаём решение dotnet new sln -n DemoSolution dotnet new console -n DemoApp -o src/DemoApp dotnet new xunit3   -n DemoApp.Tests -o test/DemoApp.Tests dotnet sln add src/DemoApp/DemoApp.csproj dotnet sln add test/DemoApp.Tests/DemoApp.Tests.csproj

Шаблон xunit3 уже подтянет:

  • xunit.v3 — ядро;

  • xunit.v3.assert — библиотеку ассертов;

  • xunit.runner.visualstudio — адаптер под dotnet test.

Всё это появляется благодаря новому набора темплейтов, представленному в v3.

Анатомия теста: [Fact] и [Theory]

[Fact] — один сценарий, один результат

public class CalculatorTests {     [Fact]     public void Add_ReturnsSum_WhenNumbersArePositive()     {         // Arrange         var calc = new Calculator();          // Act         var result = calc.Add(2, 3);          // Assert         Assert.Equal(5, result);     } }

[Theory] — даешь параметризацию

[Theory] [InlineData(2, 3, 5)] [InlineData(-2, -3, -5)] public void Add_Works_ForMultiplePairs(int a, int b, int expected) {     var calc = new Calculator();     Assert.Equal(expected, calc.Add(a, b)); }

InlineData — самый быстрый путь. Если данных много — MemberData или ClassData (ленивое перечисление, так что памяти не жалко).

AAA

AAA — это структура написания юнит‑теста, аббревиатура от:

  • Arrange — подготовка тестового окружения;

  • Act — выполнение тестируемого действия;

  • Assert — проверка результата.

Представим сценарий, где сервис начисляет бонусные баллы пользователю при покупке. Если покупка больше 500₽ — начисляется 10% от суммы. Если меньше — 5%. Баллы отправляются в хранилище.

Код сервиса:

public interface IBonusRepository {     void AddPoints(int userId, int points); }  public class BonusService {     private readonly IBonusRepository _repository;      public BonusService(IBonusRepository repository)         => _repository = repository;      public void ProcessPurchase(int userId, decimal amount)     {         if (amount <= 0)             throw new ArgumentException("Amount must be positive");          int points = amount >= 500             ? (int)(amount * 0.10m)             : (int)(amount * 0.05m);          _repository.AddPoints(userId, points);     } }

Один мощный, самодостаточный тест с AAA:

public class BonusServiceTests {     [Fact]     public void ProcessPurchase_AmountOverThreshold_AddsTenPercentBonus()     {         // Arrange         var mockRepo = new Mock<IBonusRepository>();         var service = new BonusService(mockRepo.Object);          int userId = 42;         decimal purchaseAmount = 600m;         int expectedPoints = 60; // 10% от 600          // Act         service.ProcessPurchase(userId, purchaseAmount);          // Assert         mockRepo.Verify(r => r.AddPoints(userId, expectedPoints), Times.Once);     } }

Дружим xUnit с Moq

var userRepo = new Mock<IUserRepository>(); userRepo.Setup(r => r.GetByIdAsync(42))         .ReturnsAsync(new User { Id = 42, Name = "Neo" });  var service = new UserService(userRepo.Object);  var result = await service.GetName(42);  Assert.Equal("Neo", result); userRepo.Verify(r => r.GetByIdAsync(42), Times.Once);

Moq остаётся самым популярным. Еще можно свичнуться на NSubstitute, API почти 1-в-1.

Делим тяжелый сетап между тестами

Иногда конструктор тест‑класса (в xUnit это Setup) перегревается. Тогда берем IClassFixture<T>:

public class DatabaseFixture : IDisposable {     public SqliteConnection Connection { get; }      public DatabaseFixture()     {         Connection = new SqliteConnection("DataSource=:memory:");         Connection.Open();         new Schema().Create(Connection); // миграции     }      public void Dispose() => Connection.Dispose(); }  public class UserRepositoryTests : IClassFixture<DatabaseFixture> {     private readonly DatabaseFixture _fixture;      public UserRepositoryTests(DatabaseFixture fixture)         => _fixture = fixture;      [Fact]     public async Task Save_And_Load_Roundtrip()     {         var repo = new UserRepository(_fixture.Connection);         var user = new User { Name = "Trinity" };          await repo.Save(user);         var loaded = await repo.Load(user.Id);          Assert.Equal("Trinity", loaded.Name);     } }

Фикстура создаётся один раз на класс, чистится в Dispose — никакой условной логики в тестах. Для шаринга между классами — CollectionFixture, а если нужен DI‑style startup (Kafka, Redis) — смотрите Testcontainers.

Асинхронность

xUnit изначально проектировался с поддержкой async/await, поэтому он не требует никаких танцев с бубном, чтобы писать асинхронные тесты. Достаточно вернуть Task (или ValueTask в.NET 7+) из метода, и фреймворк дождется завершения всей цепочки.

Базовый синтаксис прост и идентичен обычному коду:

[Fact] public async Task GetDataAsync_ReturnsExpectedResult() {     var sut = new DataService();      var result = await sut.GetDataAsync();      Assert.Equal("expected", result); }

xUnit полностью поддерживает await в теле теста — можно спокойно писать асинхронную подготовку, действия и проверки без .Result и .Wait(), которые часто становятся причиной deadlock’ов.

Для проверки выбрасываемых исключений в асинхронных методах есть метод Assert.ThrowsAsync<T>():

await Assert.ThrowsAsync<InvalidOperationException>(() =>     sut.DoWeirdStuffAsync());

Не забывайте возвращать Task из теста — иначе xUnit не дождется await, и исключение просто проглотится фреймворком. Т.е конструкция Assert.ThrowsAsync(...).Wait() — это плохой тон.

В асинхронных тестах можно использовать Moq и его ReturnsAsync, CallbackAsync, SetupSequence() для симуляции разных сценариев:

mockRepo.Setup(x => x.LoadAsync(It.IsAny<int>()))         .ReturnsAsync(new User { Id = 1 });

Можно строить сложные тестовые ситуации с имитацией задержек, сбоев, переопределением ответов от внешних зависимостей.

А если используете фикстуры, инициализация которых зависит от async, xUnit не позволяет использовать async в конструкторах или IDisposableAsync. В таких случаях рекомендуется использовать IAsyncLifetime, который дает два метода: InitializeAsync() и DisposeAsync() — они вызываются до и после выполнения всех тестов в классе:

public class MyFixture : IAsyncLifetime {     public async Task InitializeAsync() { ... }     public async Task DisposeAsync() { ... } }

Фичи v3

Динамический skip

Новая пара атрибутов [SkipWhen] и [SkipUnless] позволяет принимать решение о пропуске во время исполнения, а не на момент компиляции. Типовой сценарий — отключить тест в Windows‑агенте, но запустить в Linux‑контейнере:

[Fact(SkipUnless = nameof(IsLinux))] static bool IsLinux => OperatingSystem.IsLinux();

Если выражение возвращает false, фреймворк помечает тест как Skipped. Внутри атрибут вызывает Assert.Skip("…"), так что причину можно формировать динамически. На CI поведение можно ужесточить: dotnet test --fail-skips переведет любой skip в Fail, чтобы случайно не прятать важные проверки.

Explicit‑тесты

Теперь можно объявить тест «явным» и запускать его только по требованию. Делается одной строкой:

[Fact(Explicit = true, Reason = "Долго гоняет внешнюю БД")] public async Task Migration_EndToEnd() { … }

По умолчанию такие проверки пропускаются. Чтобы их выполнить, передайте --explicit on (или включите галку «Run explicit tests» в IDE).

Query filter — язык выборки

Старое --filter FullyQualifiedName~Calculate осталось, но рядом появился декларативный DSL:

dotnet test -filter "class==*Order* && trait!=Slow"

Можно комбинировать условия по имени, пространству имен, Trait, категории, времени выполнения, и это читается куда понятнее, чем регулярные выражения. DSL поддерживается как в CLI, так и в VS Test Explorer.

TestContext и CancellationToken

В v3 каждый тест получает безопасный канал к окружению:

await Task.Delay(30_000, TestContext.Current.CancellationToken); TestContext.Current.AddResultFile("out.log");

CancellationToken позволяет прерывать долгие операции, когда раннер останавливает сессию. AddResultFile прикрепляет артефакты (логи, скриншоты) к отчету, и их можно скачать прямо из сборки в CI.

Для асинхронных фикстур используется IAsyncLifetime, который теперь тоже видит тот же CancellationToken, поэтому сетап/тиардаун завершаются аккуратно.


xUnit v3 закрывает полный цикл юнит‑тестов под.NET 9: понятная модель [Fact]/[Theory], строгая структура AAA, поддержка async/await, DI‑фикс­туры и свежие возможности — динамический skip, explicit‑запуски, query‑фильтры и TestContext с токеном отмены.

Внедряйте шаблон xunit3, держите покрытие на уровне полезных сценариев, а flaky‑тесты изолируйте через SkipWhen и --fail-skips. Так вы получите быстрые прогонки, воспроизводимые баг‑фильтры и артефакты рана прямо в отчётах.

Если есть интересный опыт и кейсы — делитесь в комментариях.


Хотите освоить юнит‑тестирование в C# с xUnit v3? Рекомендуем ознакомиться с программой курса «C# Developer. Basic» — на нем можно научиться использовать все возможности xUnit и улучшить качество кода. Также рекомендуем заглянуть в календарь открытых уроков, в котором вы точно сможете найти что-либо полезное для себя.


ссылка на оригинал статьи https://habr.com/ru/articles/918942/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *