Перейти к содержимому

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

Как проверять .NET-код автоматически: модульные тесты и дублёры зависимостей, интеграционные тесты ASP.NET Core с настоящей базой и стратегия, которая держит баланс между уверенностью и временем прогона. На собеседовании здесь проверяют не синтаксис атрибутов, а умение объяснить, что, каким тестом и зачем проверять.

Перед этой темой: Внедрение зависимостей.

Junior+

Что такое модульный тест и как его правильно написать?

Заголовок раздела «Что такое модульный тест и как его правильно написать?»
Ответ

Что это

Модульный тест — метод, который вызывает ваш код с известными данными и проверяет результат. Он не трогает базу, сеть и файлы, поэтому выполняется за миллисекунды.

Как пользоваться

  1. Arrange — создать объект и данные.
  2. Act — один раз вызвать проверяемый метод.
  3. Assert — сравнить результат с ожидаемым: Assert.Equal(100m, price).

Имя теста состоит из трёх частей: метод, сценарий, ожидаемое поведение — Apply_UnknownCoupon_Throws. В xUnit обычный тест помечают [Fact], а тест с наборами данных — [Theory] и [InlineData].

Что тестировать

Логику с ветвлениями: расчёты, валидацию, граничные значения — ноль, пустую строку, null. Автосвойства и код фреймворка тестировать не нужно.

Частая ошибка

Тест зависит от DateTime.Now или от порядка запуска: сегодня он зелёный, завтра красный.

Как устроено

Фреймворк Тест и параметры Жизненный цикл класса теста
xUnit [Fact], [Theory] и [InlineData] новый экземпляр на каждый тест; подготовка в конструкторе, очистка в Dispose
NUnit [Test], [TestCase] один экземпляр на класс; [SetUp] и [TearDown] вокруг каждого теста
MSTest [TestMethod], [DataRow] новый экземпляр на тест; [TestInitialize], [TestCleanup]
  • xUnit по умолчанию запускает разные классы параллельно, а тесты одного класса — последовательно. NUnit и MSTest идут последовательно, пока параллельность не включили ([Parallelizable], [assembly: Parallelize]).
  • В NUnit 4 основной стиль — Assert.That(x, Is.EqualTo(y)), а классические Assert.AreEqual переехали в ClassicAssert.

Ловушки

  • Общее состояние в NUnit. Поле класса переживает тест, и тест, который его меняет, ломает соседей. [FixtureLifeCycle(LifeCycle.InstancePerTestCase)] даёт экземпляр на тест.
  • Несколько действий в тесте. Первый упавший assert скрывает остальные проверки — лучше [Theory] с наборами данных.
  • Логика в тесте. if и циклы в тесте — место для собственных багов.
  • «Один assert на тест». Правило про одно логическое утверждение: проверить несколько полей одного результата — нормально.

Правило

Одно поведение на тест, проверка через публичный API. Приватный метод проверяется через публичный, который его вызывает.

Под капотом

  • xUnit создаёт экземпляр класса на каждый тест — изоляция заложена в дизайн. Дорогой общий ресурс делят явно: IClassFixture<T> на класс, ICollectionFixture<T> на группу классов, в v3 — [assembly: AssemblyFixture] на сборку.
  • xUnit v3 (пакет xunit.v3) собирает тестовый проект в исполняемый файл и требует .NET 8+ или .NET Framework 4.7.2+. IAsyncLifetime в нём работает с ValueTask, а TestContext.Current.CancellationToken сигналит об отмене прогона или таймауте.

Цена

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

Компромиссы

  • Детерминизм дают зависимости вместо статики: TimeProvider (.NET 8+) и FakeTimeProvider из Microsoft.Extensions.TimeProvider.Testing для времени, генератор с фиксированным seed для случайных чисел, явная CultureInfo для форматирования.
  • «Единица» — это поведение, а не класс. Тест агрегата вместе с его value objects без подмен устойчивее, чем отдельный тест на каждый маленький класс.

Как проверить

  • Тест, который падает только в полном прогоне, зависит от общего состояния или порядка.
  • Намеренно сломайте проверяемую строку: если тест не упал, он ничего не проверяет.

Итог: модульный тест проверяет наблюдаемое поведение и детерминирован; всё, что привязывает его к реализации или к окружению, превращает его в шум.

Пример
xunit.v3
using Xunit;
public class DiscountCalculatorTests
{
[Fact]
public void Apply_NoCoupon_ReturnsOriginalPrice()
{
// Arrange
var calculator = new DiscountCalculator();
// Act
var price = calculator.Apply(100m, coupon: null);
// Assert
Assert.Equal(100m, price);
}
// Один тест — несколько наборов данных
[Theory]
[InlineData("SALE10", 90)]
[InlineData("SALE50", 50)]
public void Apply_KnownCoupon_ReducesPrice(string coupon, int expected)
{
var price = new DiscountCalculator().Apply(100m, coupon);
Assert.Equal(expected, price);
}
[Fact]
public void Apply_UnknownCoupon_Throws() =>
Assert.Throws<ArgumentException>(() => new DiscountCalculator().Apply(100m, "BOGUS"));
}
public class DiscountCalculator
{
public decimal Apply(decimal price, string? coupon) => coupon switch
{
null => price,
"SALE10" => price * 0.9m,
"SALE50" => price * 0.5m,
_ => throw new ArgumentException($"Unknown coupon: {coupon}", nameof(coupon))
};
}
То же на NUnit 4
// NuGet: NUnit
using NUnit.Framework;
[TestFixture]
public class DiscountCalculatorTests
{
[Test]
public void Apply_NoCoupon_ReturnsOriginalPrice() =>
Assert.That(new DiscountCalculator().Apply(100m, null), Is.EqualTo(100m));
// Аналог [Theory] + [InlineData]
[TestCase("SALE10", 90)]
[TestCase("SALE50", 50)]
public void Apply_KnownCoupon_ReducesPrice(string coupon, int expected) =>
Assert.That(new DiscountCalculator().Apply(100m, coupon), Is.EqualTo(expected));
}
Middle+

Что такое моки, стабы и фейки и когда их использовать?

Заголовок раздела «Что такое моки, стабы и фейки и когда их использовать?»
Ответ

Что это

Test double — замена настоящей зависимости в тесте. OrderService получает IEmailSender через конструктор, поэтому в тест можно передать дублёра вместо настоящей отправки писем.

Какие бывают

Вид Что делает
Stub (стаб) возвращает заготовленные ответы
Mock (мок) запоминает вызовы, тест их проверяет
Fake (фейк) работает по-настоящему, но упрощённо: репозиторий на словаре

Как пользоваться

  • NSubstitute: Substitute.For<IStockService>() создаёт дублёра, stock.IsAvailable("SKU-1").Returns(true) задаёт ответ, email.Received(1).Send(...) проверяет вызов.
  • Moq: то же через new Mock<T>(), Setup(...) и Verify(...).

Частая ошибка

Проверять каждый вызов каждой зависимости. Если метод только читает данные, проверяйте результат, а не сам факт вызова.

Как устроено

Классификация Мезароса из книги «xUnit Test Patterns»:

  • Dummy — передаётся ради сигнатуры и не используется.
  • Stub — возвращает заготовленные значения: управляет входами.
  • Spy — стаб, который записывает вызовы для проверки после действия.
  • Mock — проверяет взаимодействие: какие вызовы и с какими аргументами.
  • Fake — упрощённая рабочая реализация: репозиторий в памяти, FakeTimeProvider.

Библиотека создаёт объект, который может сыграть любую роль; роль задаёт тест.

Когда что выбирать

Действие NSubstitute Moq
Создать Substitute.For<T>() new Mock<T>(), затем .Object
Задать ответ x.Get(1).Returns(5) Setup(x => x.Get(1)).Returns(5)
Проверить вызов x.Received(1).Get(1) Verify(x => x.Get(1), Times.Once())

AutoFixture создаёт объекты с произвольными данными (fixture.Create<Order>()), с AutoNSubstituteCustomization подставляет дублёров вместо интерфейсов, а [AutoData] из AutoFixture.Xunit3 передаёт сгенерированные аргументы в тест.

Ловушки

  • Дублёр класса. Обе библиотеки перехватывают только интерфейсы и виртуальные члены: невиртуальный метод класса выполнится по-настоящему.
  • Проверка запросов. Received() у метода-чтения дублирует реализацию и ничего не добавляет к проверке результата.
  • Громоздкий матчер. Большая лямбда в Arg.Is хрупка; проще захватить аргумент и проверить его отдельно.

Правило

Стаб — для входящих данных, мок — только для исходящих команд с побочным эффектом.

Компромиссы

  • Классическая и лондонская школы. Классическая подменяет только внешние и общие зависимости и тестирует поведение группы классов. Лондонская изолирует каждый класс моками: точнее локализует ошибку, но связывает тесты с реализацией.
  • Избыток моков. Пять дублёров в конструкторе и проверка каждого вызова — тест пересказывает код построчно. Это сигнал: у класса слишком много обязанностей или тест написан не на том уровне.
  • Не мокайте то, чем не владеете. Мок HttpClient или SDK фиксирует ваше представление о чужом API, а не его поведение. Свой адаптер с узким интерфейсом подменяют в модульных тестах, а сам адаптер проверяют против настоящего сервиса или эмулятора. По той же причине Microsoft не советует мокать DbContext: LINQ-запрос не подменить честно.

Цена: лицензии

  • Moq и SponsorLink. В августе 2023 Moq 4.20.0 добавил анализатор SponsorLink: при сборке он читал email из настроек git и отправлял его хэш на внешний сервис для проверки спонсорства. После критики его убрали в 4.20.2, но часть компаний запретила пакет и перешла на NSubstitute.
  • FluentAssertions 8. С января 2025 библиотека выходит под Xceed Community License: бесплатно для некоммерческого использования, для коммерческого — платная лицензия на разработчика. Версия 7 осталась под Apache 2.0. Варианты: остаться на 7.x, перейти на форк AwesomeAssertions, на Shouldly (result.ShouldBe(5)) или на встроенные Assert.

Как проверить

Чистый рефакторинг не должен менять тесты. Если в таком PR правят десятки тестов, они проверяют реализацию, а не поведение.

Итог: подменяйте только неуправляемое и чужое за своим адаптером, а тестовые библиотеки выбирайте с учётом лицензии так же, как продуктовые.

Пример
// NuGet: xunit.v3, NSubstitute
using NSubstitute;
using Xunit;
public class OrderServiceTests
{
private readonly IStockService _stock = Substitute.For<IStockService>(); // стаб: даёт данные
private readonly IEmailSender _email = Substitute.For<IEmailSender>(); // мок: проверяем вызов
[Fact]
public void Place_InStock_SendsConfirmation()
{
_stock.IsAvailable("SKU-1").Returns(true);
var service = new OrderService(_stock, _email);
var placed = service.Place("SKU-1", "a@b.c");
Assert.True(placed);
_email.Received(1).Send("a@b.c", Arg.Is<string>(s => s.Contains("SKU-1")));
}
[Fact]
public void Place_OutOfStock_SendsNothing()
{
// IsAvailable без настройки вернёт false
var placed = new OrderService(_stock, _email).Place("SKU-2", "a@b.c");
Assert.False(placed);
_email.DidNotReceiveWithAnyArgs().Send(default!, default!);
}
}
public interface IStockService { bool IsAvailable(string sku); }
public interface IEmailSender { void Send(string to, string body); }
public class OrderService(IStockService stock, IEmailSender email)
{
public bool Place(string sku, string customer)
{
if (!stock.IsAvailable(sku)) return false;
email.Send(customer, $"Order for {sku} placed");
return true;
}
}
То же на Moq
// NuGet: Moq
using Moq;
var stock = new Mock<IStockService>();
stock.Setup(s => s.IsAvailable("SKU-1")).Returns(true); // стаб
var email = new Mock<IEmailSender>(); // мок
var placed = new OrderService(stock.Object, email.Object).Place("SKU-1", "a@b.c");
email.Verify(e => e.Send("a@b.c", It.Is<string>(s => s.Contains("SKU-1"))), Times.Once());
Console.WriteLine(placed); // True
// Те же типы, что в примере с NSubstitute
public interface IStockService { bool IsAvailable(string sku); }
public interface IEmailSender { void Send(string to, string body); }
public class OrderService(IStockService stock, IEmailSender email)
{
public bool Place(string sku, string customer)
{
if (!stock.IsAvailable(sku)) return false;
email.Send(customer, $"Order for {sku} placed");
return true;
}
}
Middle+

Как писать интеграционные тесты для ASP.NET Core?

Заголовок раздела «Как писать интеграционные тесты для ASP.NET Core?»
Ответ

Что это

Интеграционный тест запускает приложение почти целиком и проверяет, что части работают вместе: запрос доходит до эндпоинта, данные сохраняются в базу, ответ сериализуется в JSON.

Как пользоваться

  1. Добавить в тестовый проект пакет Microsoft.AspNetCore.Mvc.Testing и ссылку на проект API.
  2. Подключить фабрику к классу тестов: IClassFixture<WebApplicationFactory<Program>>.
  3. Получить клиента: var client = factory.CreateClient();.
  4. Отправить запрос и проверить ответ: Assert.Equal(HttpStatusCode.OK, response.StatusCode).

До .NET 10 в Program.cs дописывали public partial class Program { }, чтобы тест видел класс. В .NET 10 это делает генератор кода.

Частая ошибка

Запускать тесты против общей базы разработки. Тесты портят данные друг другу и падают случайно.

Как устроено

  • Фабрика выполняет Program.cs приложения, но вместо Kestrel ставит TestServer: запрос проходит весь конвейер, только без сокетов.
  • ConfigureWebHost меняет окружение (UseEnvironment("Testing")) и настройки. ConfigureTestServices выполняется после регистраций приложения, поэтому сервисы заменяют в нём.

Когда что выбирать

База в тестах Плюс Минус
InMemory-провайдер EF Core ничего не нужно настраивать не реляционный: нет транзакций и SQL; Microsoft не рекомендует
SQLite in-memory быстро, без Docker другой диалект SQL, строки сравниваются с учётом регистра
Testcontainers та же СУБД и версия, что в проде нужен Docker, старт контейнера — секунды

Ловушки

  • Повторный AddDbContext. Он не убирает настройку провайдера из Program.cs. В EF Core 9+ сначала удаляют IDbContextOptionsConfiguration<TContext>, потом регистрируют контекст заново.
  • Общие данные. Тесты пишут в одну базу. Изоляция — уникальные данные на тест, транзакция с откатом или сброс таблиц перед тестом.
  • Параллельность. xUnit запускает классы параллельно: если они делят базу, они делят и данные.

Правило

Подменять только внешнее и неуправляемое: платежи, почту, чужие API. База, сериализация и DI остаются настоящими.

Под капотом

  • Фабрика перехватывает построение хоста в Program.cs и применяет свои настройки поверх приложения. CreateClient возвращает HttpClient, чей обработчик передаёт запрос прямо в TestServer того же процесса.
  • С .NET 10 UseKestrel() поднимает вместо TestServer настоящий Kestrel на порту — это нужно браузерным тестам на Playwright.

Цена

Старт хоста — сотни миллисекунд, контейнер PostgreSQL — секунды, SQL Server — дольше. Поэтому фабрику и контейнер создают на класс или коллекцию через IClassFixture<T> и ICollectionFixture<T>, а в xUnit v3 — на сборку через [assembly: AssemblyFixture].

Компромиссы

Изоляция данных Плюс Минус
Транзакция с откатом быстро запрос через HttpClient идёт в своём scope, и общую транзакцию туда не передать
Respawn перед тестом схема остаётся, удаляются только данные тесты с общей базой идут последовательно
База на класс полная изоляция и параллельность время старта и память

HTTP-клиенты

  • Модульно: typed-клиенту передают HttpClient с обработчиком-заглушкой и проверяют URL, заголовки и разбор ответа.
  • Интеграционно: WireMock.Net поднимает HTTP-сервер с заготовленными ответами, задержками и ошибками, а адрес клиента в фабрике направляют на него. Так проверяются таймауты и повторы.

Итог: интеграционные тесты проверяют стыки на настоящей инфраструктуре; их цену держат в узде общие fixture и быстрый сброс данных.

Пример
// NuGet: xunit.v3, Microsoft.AspNetCore.Mvc.Testing, Testcontainers.PostgreSql, Npgsql.EntityFrameworkCore.PostgreSQL
using System.Net;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Testcontainers.PostgreSql;
using Xunit;
public class OrdersApiTests(ApiFactory factory) : IClassFixture<ApiFactory>
{
[Fact]
public async Task GetOrder_Unknown_Returns404()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/orders/999", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
// Одна фабрика и один контейнер на класс тестов
public class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _db = new PostgreSqlBuilder("postgres:17-alpine").Build();
public async ValueTask InitializeAsync()
{
await _db.StartAsync();
using var scope = Services.CreateScope(); // здесь строится хост приложения
await scope.ServiceProvider.GetRequiredService<AppDbContext>().Database.MigrateAsync();
}
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
await _db.DisposeAsync();
}
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.UseEnvironment("Testing").ConfigureTestServices(services =>
{
// Убираем провайдер из Program.cs (EF Core 9+) и подключаем контейнер
services.RemoveAll<IDbContextOptionsConfiguration<AppDbContext>>();
services.AddDbContext<AppDbContext>(o => o.UseNpgsql(_db.GetConnectionString()));
// Внешний сервис — фейк
services.AddSingleton<IPaymentGateway, FakePaymentGateway>();
});
}
Тест HTTP-клиента без сети
xunit.v3
using System.Globalization;
using System.Net;
using Xunit;
public class RatesClientTests
{
[Fact]
public async Task GetRate_ParsesApiResponse()
{
var handler = new StubHandler(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("41.5")
});
var client = new RatesClient(new HttpClient(handler) { BaseAddress = new Uri("https://rates.test/") });
var rate = await client.GetRateAsync("USD", TestContext.Current.CancellationToken);
Assert.Equal(41.5m, rate);
Assert.Equal("/rates/USD", handler.LastRequest?.RequestUri?.AbsolutePath);
}
}
public class RatesClient(HttpClient http)
{
public async Task<decimal> GetRateAsync(string currency, CancellationToken ct) =>
decimal.Parse(await http.GetStringAsync($"rates/{currency}", ct), CultureInfo.InvariantCulture);
}
// Заглушка вместо сети: запоминает запрос и возвращает готовый ответ
public class StubHandler(HttpResponseMessage response) : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
LastRequest = request;
return Task.FromResult(response);
}
}
Senior+

Как выстроить стратегию тестирования проекта?

Заголовок раздела «Как выстроить стратегию тестирования проекта?»
Ответ

Что это

Стратегия тестирования отвечает на три вопроса: что проверять, какими тестами и когда их запускать.

Уровни тестов

Уровень Что проверяет Скорость
Модульный одну единицу логики миллисекунды
Интеграционный части вместе: API и базу от десятков миллисекунд до секунд
Сквозной (E2E) систему глазами пользователя секунды и минуты

Пирамида: внизу больше всего модульных тестов, наверху меньше всего сквозных.

Как пользоваться

  • Баг из продакшена сначала воспроизводят тестом, потом чинят.
  • Тесты запускаются в CI на каждый pull request.
  • Покрытие смотрят, чтобы найти непроверенный код, а не ради цифры.

Частая ошибка

Гнаться за 100 % покрытия. Тест без проверок тоже поднимает процент.

Как устроено

  • Пирамида (Майк Кон): основание — модульные тесты, середина — интеграционные, вершина — UI и E2E.
  • Трофей (Кент Доддс): основание — статический анализ, тонкий слой модульных, самый широкий слой — интеграционные, сверху E2E. Для веб-API с тонкой логикой тесты через WebApplicationFactory дают больше уверенности на строку теста.

Где покрытие врёт

coverlet (dotnet test --collect:"XPlat Code Coverage") считает выполненные строки и ветви. Но:

  • тест без assert даёт 100 % строк: код выполнен, но не проверен;
  • покрытие строк скрывает ветви: if без else покрыт одним сценарием.

Мутационное тестирование

Stryker.NET (dotnet stryker) вносит в код маленькие изменения — мутанты: > на >=, && на ||, удаление вызова. Затем запускает тесты. Если ни один не упал, мутант «выжил»: это поведение не проверяется. Доля убитых мутантов — mutation score.

Ловушки

  • Порог покрытия как KPI порождает тесты ради процента.
  • Stryker на весь проект работает в разы дольше обычного прогона. В PR его запускают только по изменённому коду (--since).

Правило

Покрытие — чтобы найти непроверенный код, мутационный score — чтобы оценить качество тестов на критичной логике.

Компромиссы

  • Портфель. Модульные — на ветвистую доменную логику, интеграционные с настоящей базой — на основные сценарии, E2E — на несколько критичных путей.
  • Тесты архитектуры (NetArchTest, ArchUnitNET) фиксируют правила зависимостей: домен не ссылается на EF Core.
  • Контрактные тесты (Pact, PactNet): потребитель записывает ожидания к API в контракт, поставщик проверяет его в своём CI. Ломающее изменение между сервисами видно без общего стенда.

Flaky-тесты

Причины: время и таймауты, Task.Delay вместо ожидания условия, общее состояние, параллельность, внешние сервисы, культура и часовой пояс. Порядок работы:

  1. Обнаружить: повторный прогон упавших, история падений в CI.
  2. Изолировать в карантин с задачей на починку.
  3. Устранить причину.

Автоповтор (Microsoft.Testing.Extensions.Retry, --retry-failed-tests) — временная мера для интеграционных тестов: он прячет гонки, которые проявятся и в проде.

Время прогона в CI

Быстрые тесты — на каждый коммит, медленные — отдельной стадией по фильтру категории: [Trait("Category", "Integration")] в xUnit, [Category("Integration")] в NUnit. Контейнеры — на коллекцию, а не на тест.

Legacy без тестов

Для Майкла Фэзерса legacy — это код без тестов. Порядок:

  1. Найти место изменения и точку, где виден результат.
  2. Создать шов: виртуальный метод, параметр-интерфейс, TimeProvider.
  3. Зафиксировать текущее поведение характеризующими тестами, даже странное.
  4. Только потом рефакторить.

Те же тесты страхуют перенос системы — см. Архитектурные стили.

Итог: стратегия — портфель тестов под риски проекта; метрики и CI служат ему, а не наоборот.

Пример
xunit.v3
using Xunit;
// Характеризующий тест: фиксирует, как код работает сейчас, а не как «должен»
public class LateFeeCalculatorTests
{
[Theory]
[InlineData(0, 0)]
[InlineData(10, 50)]
[InlineData(31, 200)] // не 155: так считает прод, фиксируем как есть
public void Fee_MatchesCurrentBehavior(int daysLate, int expectedFee)
{
var due = new DateTime(2026, 1, 1);
var calculator = new TestableLateFeeCalculator(now: due.AddDays(daysLate));
Assert.Equal(expectedFee, calculator.Fee(due));
}
}
// Шов: подкласс подменяет источник времени, не трогая логику
public class TestableLateFeeCalculator(DateTime now) : LateFeeCalculator
{
protected override DateTime Now() => now;
}
// Legacy-класс: единственная правка — DateTime.Now вынесен в виртуальный метод
public class LateFeeCalculator
{
protected virtual DateTime Now() => DateTime.Now;
public int Fee(DateTime dueDate)
{
var days = (Now() - dueDate).Days;
if (days <= 0) return 0;
if (days > 30) return 200;
return days * 5;
}
}