C# / .NET Тестирование в .NET: xUnit, моки, property-based, Testcontainers и CI
0%

Тестирование в .NET: xUnit, моки, property-based, Testcontainers и CI

Тестирование в .NET

Тесты — это не «дополнительная работа», а то, что позволяет менять код без страха. В этой статье соберём практичную стратегию: от быстрых юнит-тестов до интеграционных с реальной базой в контейнере, и как всё это гонять в CI.

Тест-пирамида

Классическая эвристика распределения усилий:

  • Юнит (основание) — проверяют одну единицу логики в изоляции. Их должно быть много, они мгновенные (миллисекунды), запускаются на каждой сборке.
  • Интеграционные — проверяют связку компонентов с реальными зависимостями (БД, очередь). Медленнее, но ловят то, что юниты не видят: SQL, миграции, сериализацию.
  • E2E — прогон через всю систему. Дорогие и хрупкие; держите минимум критических сценариев.

Антипаттерн — «перевёрнутая пирамида» или «песочные часы»: много медленных e2e и мало юнитов. CI становится долгим, тесты — флаки.

xUnit — фреймворк по умолчанию

Три основных фреймворка: xUnit (де-факто стандарт новых проектов), NUnit, MSTest. Возьмём xUnit.

dotnet new xunit -o tests/MyApp.UnitTests
dotnet add tests/MyApp.UnitTests reference src/MyApp.Domain/MyApp.Domain.csproj
using Xunit;
using FluentAssertions;

public class PriceCalculatorTests
{
    // [Fact] — тест без параметров
    [Fact]
    public void ApplyDiscount_ForGoldCustomer_Gives10PercentOff()
    {
        // Arrange
        var calculator = new PriceCalculator();
        var order = new Order(Total: 100m);
        var customer = new Customer(Tier.Gold, IsActive: true);

        // Act
        var result = calculator.ApplyDiscount(order, customer);

        // Assert (FluentAssertions — читаемые проверки)
        result.Should().Be(90m);
    }

    // [Theory] + [InlineData] — параметризованный тест (data-driven)
    [Theory]
    [InlineData(Tier.Regular, 100)]
    [InlineData(Tier.Silver, 95)]
    [InlineData(Tier.Gold, 90)]
    public void ApplyDiscount_ByTier(Tier tier, decimal expected)
    {
        var result = new PriceCalculator()
            .ApplyDiscount(new Order(100m), new Customer(tier, IsActive: true));

        result.Should().Be(expected);
    }
}

Ключевые атрибуты xUnit: [Fact], [Theory] + [InlineData]/[MemberData]/ [ClassData]. Конструктор класса — это «setup» перед каждым тестом (xUnit создаёт новый экземпляр класса на каждый тест — тесты изолированы), IDisposable.Dispose — teardown. Для дорогого общего ресурса — IClassFixture<T>/ICollectionFixture<T>.

FluentAssertions

FluentAssertions делает проверки читаемыми и даёт понятные сообщения об ошибках:

result.Should().Be(90m);
list.Should().HaveCount(3).And.Contain(x => x.Id == 1);
order.Should().BeEquivalentTo(expected);       // сравнение по значению, глубокое
action.Should().Throw<OrderNotFoundException>()
      .WithMessage("*not found*");
await asyncAction.Should().ThrowAsync<TimeoutException>();

Проверка асинхронного кода — через ThrowAsync, а сами тесты делайте async Task:

[Fact]
public async Task GetOrder_WhenMissing_Throws()
{
    var repo = new InMemoryOrderRepository();
    var act = async () => await repo.GetAsync(999, CancellationToken.None);
    await act.Should().ThrowAsync<OrderNotFoundException>();
}

Моки: изоляция зависимостей

Чтобы тестировать единицу в изоляции, её внешние зависимости (репозитории, HTTP, время) заменяют тест-дублёрами. Библиотеки — NSubstitute (лаконичный синтаксис) и Moq. Возьмём NSubstitute:

using NSubstitute;

[Fact]
public async Task PlaceOrder_SavesAndReturnsId()
{
    // Arrange: подставной репозиторий
    var repo = Substitute.For<IOrderRepository>();
    repo.SaveAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>())
        .Returns(Task.FromResult(42));

    var sut = new OrderService(repo);

    // Act
    var id = await sut.PlaceOrderAsync(new Cart(), CancellationToken.None);

    // Assert
    id.Should().Be(42);
    // проверяем взаимодействие
    await repo.Received(1).SaveAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>());
}

Важные принципы:

  • Мокайте абстракции, которыми вы владеете (свои интерфейсы), а не сторонние сложные типы. HttpClient не мокают напрямую — подменяют HttpMessageHandler или тестируют через WebApplicationFactory.
  • Не мокайте то, что можно взять по-настоящему — простые value-объекты, чистые функции. Избыточное мокинг делает тесты хрупкими к рефакторингу.
  • Проверяйте поведение (результат), а не реализацию. Чрезмерная проверка «был ли вызван метод X» связывает тест с внутренностями. Verify — когда взаимодействие само является контрактом (например, «событие опубликовано»).

Тестируемость закладывается в дизайне: инжектируйте зависимости (DI), абстрагируйте время через TimeProvider (встроен с .NET 8) вместо DateTime.Now, случайность — через инъекцию. Тогда моки не нужны там, где достаточно фейка.

Property-based тестирование

Вместо перечисления конкретных примеров вы описываете свойство, которое должно выполняться для любых входов, а фреймворк генерирует сотни случаев и ищет контрпример (с «усадкой» — shrinking — до минимального). Инструмент — FsCheck (есть интеграция с xUnit — FsCheck.Xunit).

using FsCheck;
using FsCheck.Xunit;

public class SerializationProperties
{
    // Свойство: сериализация -> десериализация возвращает исходный объект
    [Property]
    public bool RoundTrip_PreservesValue(int amount, NonNull<string> currency)
    {
        var original = new Money(amount, currency.Get);
        var json = JsonSerializer.Serialize(original);
        var restored = JsonSerializer.Deserialize<Money>(json);
        return restored == original;
    }
}

Property-based отлично ловит краевые случаи (пустые строки, отрицательные, переполнения, Unicode), о которых вы не подумали в примерах. Особенно ценно для парсеров, сериализации, математики, инвариантов доменной модели.

Интеграционные тесты с реальными зависимостями: Testcontainers

Мок базы данных не проверит ваш SQL, миграции и особенности провайдера. Правильный интеграционный тест поднимает настоящую БД в Docker-контейнере — это делает библиотека Testcontainers for .NET. Контейнер создаётся перед тестами и удаляется после, тесты изолированы и воспроизводимы.

using Testcontainers.PostgreSql;

public class OrderRepositoryTests : IAsyncLifetime
{
    private readonly PostgreSqlContainer _db = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .Build();

    // Поднять контейнер перед тестами класса
    public async Task InitializeAsync()
    {
        await _db.StartAsync();
        // применить миграции EF Core к свежей базе
        await using var ctx = CreateContext();
        await ctx.Database.MigrateAsync();
    }

    // Погасить после
    public Task DisposeAsync() => _db.DisposeAsync().AsTask();

    private AppDbContext CreateContext()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseNpgsql(_db.GetConnectionString())
            .Options;
        return new AppDbContext(options);
    }

    [Fact]
    public async Task SaveAndLoad_RoundTrips()
    {
        await using var ctx = CreateContext();
        var repo = new OrderRepository(ctx);

        var id = await repo.SaveAsync(new Order(Total: 50m), CancellationToken.None);
        var loaded = await repo.GetAsync(id, CancellationToken.None);

        loaded.Total.Should().Be(50m);
    }
}

IAsyncLifetime — xUnit-хук для асинхронного setup/teardown. Testcontainers умеет не только Postgres, но и Redis, Kafka, RabbitMQ, MongoDB, MS SQL и т.д. Единственное требование — доступный Docker на машине разработчика и на CI.

Тестирование ASP.NET Core: WebApplicationFactory

Для проверки веб-приложения целиком (маршрутизация, middleware, DI, сериализация, фильтры) есть WebApplicationFactory<T> из Microsoft.AspNetCore.Mvc.Testing. Она поднимает приложение в памяти (без реального порта) и даёт HttpClient к нему.

public class OrdersApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public OrdersApiTests(WebApplicationFactory<Program> factory) =>
        // подменяем реальные зависимости на тестовые
        _factory = factory.WithWebHostBuilder(builder =>
            builder.ConfigureServices(services =>
            {
                // например, заменить внешний платёжный сервис на фейк
                services.AddSingleton<IPaymentGateway, FakePaymentGateway>();
            }));

    [Fact]
    public async Task Post_Order_Returns201()
    {
        var client = _factory.CreateClient();

        var response = await client.PostAsJsonAsync("/orders", new { Total = 100m });

        response.StatusCode.Should().Be(HttpStatusCode.Created);
        var body = await response.Content.ReadFromJsonAsync<OrderDto>();
        body!.Total.Should().Be(100m);
    }
}

Чтобы Program был виден тесту, в Minimal API добавляют public partial class Program; в конец Program.cs (или используют InternalsVisibleTo). Комбинируйте WebApplicationFactory с Testcontainers — получите полноценный end-to-end тест API с настоящей БД. Каноничный разбор — Andrew Lock (andrewlock.net).

Покрытие кода

Собирается через Coverlet (интегрирован в dotnet test):

dotnet test --collect:"XPlat Code Coverage"
# отчёт: reportgenerator превращает cobertura.xml в HTML
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:**/coverage.cobertura.xml -targetdir:coverage-report

Важное замечание: покрытие — метрика необходимая, но не достаточная. 100% покрытия не означает отсутствие багов — можно исполнить строку, ничего толком не проверив. Не гонитесь за цифрой ради цифры; цельтесь в покрытие поведения и краевых случаев. Разумный порог для бизнес-логики — 70–85%, но воспринимайте его как сигнал, а не самоцель.

Встраивание в CI

Тесты бесполезны, если не гоняются автоматически. Минимальный пайплайн (GitHub Actions):

name: ci
on: [push, pull_request]
jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '9.0.x'
      - run: dotnet restore --locked-mode
      - run: dotnet format --verify-no-changes         # стиль
      - run: dotnet build -c Release --no-restore
      - run: dotnet test -c Release --no-build --collect:"XPlat Code Coverage"
      # Testcontainers работает на ubuntu-runner'ах — Docker уже установлен

Практики для здорового CI:

  • Разделяйте быстрые и медленные тесты. Гоняйте юниты на каждый коммит, тяжёлые интеграционные — можно фильтром по трейтам (dotnet test --filter Category=Integration).
  • Ноль флаки-тестов. Нестабильный тест хуже отсутствия теста — команда перестаёт доверять красной сборке. Чините или карантиньте немедленно.
  • Тесты — часть определения «готово». PR без тестов на новую логику не проходит ревью.

Что дальше

Архитектура и продакшн — слоистая и гексагональная архитектура, DI, Options pattern, EF Core и паттерны устойчивого прод-кода.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов