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

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

Комментарии (0)