Привет, Хабр!
Сегодня разберёмся с юнит‑тестами в 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 и улучшить качество кода. Также рекомендуем заглянуть в календарь открытых уроков, в котором вы точно сможете найти что-либо полезное для себя.