- Регистрация
- 23 Август 2023
- Сообщения
- 3 041
- Лучшие ответы
- 0
- Реакции
- 0
- Баллы
- 51
Offline
Привет, Хабр!
Сегодня разберёмся с юнит‑тестами в 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(mockRep
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(userRep
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 и улучшить качество кода. Также рекомендуем заглянуть в календарь открытых уроков, в котором вы точно сможете найти что-либо полезное для себя.