Привет, Хабр!
Сегодня разберёмся с юнит‑тестами в 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/
Добавить комментарий