Тестирование в .NET
Тесты — это не «дополнительная работа», а то, что позволяет менять код без страха. В этой статье соберём практичную стратегию: от быстрых юнит-тестов до интеграционных с реальной базой в контейнере, и как всё это гонять в CI.
Тест-пирамида
Классическая эвристика распределения усилий:
мало, медленно, хрупко"] INT["Интеграционные тесты
БД, HTTP, брокеры"] UNIT["Юнит-тесты
много, быстро, изолированно"] E2E --- INT --- UNIT
- Юнит (основание) — проверяют одну единицу логики в изоляции. Их должно быть много, они мгновенные (миллисекунды), запускаются на каждой сборке.
- Интеграционные — проверяют связку компонентов с реальными зависимостями (БД, очередь). Медленнее, но ловят то, что юниты не видят: 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 и паттерны устойчивого прод-кода.