
Разрабатывая приложения, мы стараемся не злоупотреблять дублированием кода. Из часто встречающегося кода мы формируем библиотеки, а для их соединения в инфраструктуре ASP.NET Core приложения используем DI-контейнер.
Инфраструктура тестирования для ASP.NET Core API, как правило, тоже повторяется, но какие инструменты помогают нам переиспользовать тестовый код?
Разработчики Python решают эту задачу с помощью pytest.fixtures, однако в dotnet-экосистеме (xUnit) хорошего аналога пока нет.
В статье рассмотрим пример, как в несколько строк собрать полноценное интеграционное окружение с изолированной БД, фейковым временем и случайностью, а также как донастроить это окружение для отдельно взятого теста.
Для кого эта статья: для бэкенд-разработчиков (на .NET), технических лидов, QA-инженеров, которые пишут код, и всех, кто устал от бессмысленно повторяющегося кода в тестах.
Если вы пишете на C#, но хотите добавить тестам элегантности Python — добро пожаловать.
Содержание
Статья рассчитана на читателей, которые в целом понимают, кто, как и зачем тестирует бэкенд.
Спойлер
По-хорошему стоит написать отдельную статью и на эту тему.
Тестируемое приложение (WeatherForecast API)
Наше приложение — minimal ASP.NET Core API с двумя эндпоинтами:
-
POST /weatherforecast/generate— создаёт прогноз погоды, используяTimeProvider,Randomи конфигурацию (например,appsettings.json), и сохраняет его в PostgreSQL через EF Core. -
GET /weatherforecast/today— возвращает прогноз на сегодня из БД.
Полный код тестового приложения
Приложению необходима БД PostgreSQL, доступная через ConnectionString с именем PgDb.
using Microsoft.EntityFrameworkCore;namespace WebApiTestSubject;public class Program{ public const string ConnectionStringName = "PgDb"; public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); builder.Services .AddSingleton((_) => Random.Shared) .AddSingleton((_) => TimeProvider.System) .AddDbContext<ApplicationDbContext>((sp, options) => { var connStr = builder.Configuration.GetConnectionString(ConnectionStringName); options.UseNpgsql(connStr); }); var app = builder.Build(); app.MapPost("/weatherforecast/generate", async (TimeProvider tp, Random r, IConfiguration cfg, ApplicationDbContext dbCtx) => { var now = tp.GetUtcNow(); var date = DateOnly.FromDateTime(now.Date); var temperature = r.Next(100); var summary = cfg.GetValue<string>("summary"); var forecast = new WeatherForecast(date, temperature, summary); dbCtx.WeatherForecasts.Add(new WeatherForecastEntity { Data = forecast }); await dbCtx.SaveChangesAsync(); }); app.MapGet("/weatherforecast/today", async (TimeProvider tp, ApplicationDbContext dbCtx) => { var now = tp.GetUtcNow(); var today = DateOnly.FromDateTime(now.Date); var entity = await dbCtx.WeatherForecasts .Where(x => x.Data.Date == today) .FirstOrDefaultAsync(); return entity is null ? Results.NotFound() : Results.Ok(entity.Data); }); app.Run(); }}public class ApplicationDbContext : DbContext{ public DbSet<WeatherForecastEntity> WeatherForecasts { get; init; } public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<WeatherForecastEntity>().ComplexProperty(e => e.Data); }}public class WeatherForecastEntity{ public long Id { get; init; } public required WeatherForecast Data { get; init; }}public record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary);
Наша цель — написать детерминированный интеграционный тест, который проверяет весь цикл: от HTTP-запроса до записи в базу данных.
Проблемы классического способа тестирования
Чтобы протестировать это приложение, нам нужно:
-
Создать
CustomWebApplicationFactory, унаследовав отWebApplicationFactory<Program>. -
Переопределить
ConfigureWebHost, чтобы заменитьTimeProviderиRandomна моки. -
Создать уникальную базу данных для каждого теста, иначе тесты будут мешать друг другу.
-
Создать
HttpClientчерез фабрику. -
Написать конструктор тестового класса для инициализации всего вышеперечисленного.
-
Не забыть про
IAsyncDisposableдля удаления базы и очистки других ресурсов после теста.
И это — только для одного тестового класса. Представьте, что у вас их десяток, и каждому требуется немного изменить WebApplicationFactory. Появляется иерархия тестовых классов, код копируется. Поддерживать и читать становится всё сложнее. Отдельная боль — переиспользовать полученный код в других проектах.
Код тестов, используя стандартные средства
using System.Data.Common;using System.Net;using AwesomeAssertions;using AwesomeAssertions.Json;using FEFF.TestFixtures.AspNetCore.Randomness;using Microsoft.AspNetCore.Hosting;using Microsoft.AspNetCore.Mvc.Testing;using Microsoft.EntityFrameworkCore;using Microsoft.Extensions.Configuration;using Microsoft.Extensions.DependencyInjection;using Microsoft.Extensions.DependencyInjection.Extensions;using Microsoft.Extensions.Time.Testing;using Newtonsoft.Json.Linq;using WebApiTestSubject;namespace ExampleTests.AspNetCore.Basic;internal class CustomWebApplicationFactory : WebApplicationFactory<Program>{ public FakeTimeProvider FakeTime { get; } = new(); public FakeRandom FakeRandom { get; } = new(); protected override void ConfigureWebHost(IWebHostBuilder builder) { base.ConfigureWebHost(builder); builder.ConfigureServices((ctx, _) => { ctx.Configuration.AddSuffixToConnectionString("PgDb", Guid.NewGuid().ToString()); }); builder.ConfigureServices(services => services.TryReplaceSingleton<TimeProvider>(FakeTime) ); builder.ConfigureServices(services => services.TryReplaceSingleton<Random>(FakeRandom) ); builder.UseSetting("summary", "sunny"); }}public sealed class BasicApiTests : IAsyncLifetime{ internal CustomWebApplicationFactory App { get; } internal HttpClient Client { get; } internal AsyncServiceScope Scope { get; } internal FakeTimeProvider AppTime => App.FakeTime; internal FakeRandom AppRandom => App.FakeRandom; internal ApplicationDbContext AppDbCtx => Scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); public BasicApiTests() { App = new(); Client = App.CreateClient(); // Application starts here Scope = App.Services.CreateAsyncScope(); } public async ValueTask DisposeAsync() { await AppDbCtx.Database.EnsureDeletedAsync(TestContext.Current.CancellationToken); await Scope.DisposeAsync(); Client.Dispose(); await App.DisposeAsync(); } public async ValueTask InitializeAsync() { await AppDbCtx.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); } #region tutorial: ASP.NET Core Application Testing /// <summary> /// Test: POST /weatherforecast/generate creates a forecast using time, random, and env var, /// persists it to the database, and GET /weatherforecast/today returns it. /// /// This test verifies the full integration flow: /// 1. Configure fake time, fake random, and environment variable /// 2. POST to /weatherforecast/generate /// 3. Query the database directly to verify persistence /// 4. GET /weatherforecast/today to verify the API returns the persisted record /// </summary> [Fact] public async Task Example_Tutorial_Asp__Api__should_persist_and_return() { // Arrange var expectedDate = "2025-06-15"; var expectedTemperature = 42; var expectedSummary = "sunny"; AppTime.SetUtcNow(DateTimeOffset.Parse($"{expectedDate}T12:00:00Z")); AppRandom.Int32Next = FixedNextStrategy.From(expectedTemperature); // ACT await PostAsync(Client, "/weatherforecast/generate", null); // Assert var forecastEntities = await AppDbCtx.WeatherForecasts.ToListAsync(TestContext.Current.CancellationToken); var forecasts = forecastEntities.Select(x => x.Data).ToList(); JToken.FromObject(forecasts) .Should().BeEquivalentTo($$""" [ { "Date": "{{expectedDate}}", "TemperatureC": {{expectedTemperature}}, "Summary": "{{expectedSummary}}", } ] """); // ACT var response = await GetAsync(Client, "/weatherforecast/today"); // Assert response .Should().BeEquivalentTo( $$""" { "date": "{{expectedDate}}", "temperatureC": {{expectedTemperature}}, "summary": "{{expectedSummary}}" } """); } # endregion #region helpers private static async Task<JToken> GetAsync(HttpClient client, string url) { var getResp = await client.GetAsync(url, TestContext.Current.CancellationToken); var getBody = await getResp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); getResp.StatusCode.Should().Be(HttpStatusCode.OK, getBody); return JToken.Parse(getBody); } private static async Task PostAsync(HttpClient client, string url, string? data) { StringContent? sc = null; if (data != null) sc = new StringContent(data, System.Text.Encoding.UTF8, "application/json"); var resp = await client.PostAsync(url, sc, TestContext.Current.CancellationToken); var body = await resp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); resp.StatusCode.Should().Be(HttpStatusCode.OK, body); } #endregion}internal static class HelperExtensions{ public static IServiceCollection TryReplaceSingleton<TService>(this IServiceCollection services, TService instance) where TService : class { var srcType = typeof(TService); var oldD = services.SingleOrDefault(d => d.ServiceType == srcType); if (oldD == null) return services; if(oldD.Lifetime != ServiceLifetime.Singleton) throw new InvalidOperationException(); var sdNew = new ServiceDescriptor(srcType, instance); services.Replace(sdNew); return services; } internal static void AddSuffixToConnectionString(this IConfiguration config, string connectionStringName, string suffix) { var key = "ConnectionStrings:" + connectionStringName; var cs = config[key]; var csb = new DbConnectionStringBuilder { ConnectionString = cs }; csb["Database"] = $"{csb["Database"]}-{suffix}"; var newCs = csb.ConnectionString; config[key] = newCs; }}
Решение: фикстуры как способ мышления
Я долгое время разрабатываю на Python и C# попеременно. В экосистеме Python есть такая штука @pytest.fixture:
In testing, a fixture provides a defined, reliable and consistent context for the tests. This could include environment (for example a database configured with known parameters) or content (such as a dataset).
Иными словами, фикстура — это выделенный (переиспользуемый) компонент тестового кода, включающий состояние, методы инициализации и очистки. Состояние фикстуры явно используется для выполнения тестов, в то время как методы инициализации и очистки вызываются неявно тестовым движком.
Нужна временная директория? pytest.tmp_path. Нужно подменить текущее время? Есть pytest-freezegun, и всё это компонуется через декларативные зависимости (там фикстура подключается как аргумент тестовой функции).
Каждый раз, начиная новый проект на C#, я ловлю себя на мысли: «Жаль, что нет фикстур как в Python. Почему для простого интеграционного теста мне нужно написать (скопировать и поправить) несколько классов?» Так родилась идея применить философию pytest-фикстур в .NET. Результат — библиотека FEFF.TestFixtures.
Это работает так:
-
Вы помечаете класс атрибутом
[Fixture]. -
Если фикстура нуждается в других фикстурах, вы объявляете зависимости через аргументы конструктора. Система автоматически разрешает граф зависимостей.
-
Вы запрашиваете фикстуру в тесте через
TestContext.Current.GetFeffFixture<T>(). -
Если необходимо освободить ресурсы, фикстура должна реализовать
IDisposableилиIAsyncDisposable. -
Scope (область видимости) определяет, когда создаётся и удаляется фикстура: на каждый тест, на класс, на коллекцию или на всю сборку.
-
При необходимости комбинируйте фикстуры через композицию.
-
Для тестирования
ASP.NET Core Web APIесть набор готовых фикстур. Привет, переиспользование!
Звучит знакомо? Да, это тот же подход, что и в pytest. Давайте посмотрим на практике.
Практика: тестируем WeatherForecast API
Шаг 1. Подключаем пакеты к проекту с тестами (xUnit.v3)
В примере используется тестовый фреймворк xUnit версии 3 и выше.
Также есть поддержка TUnit.
dotnet add package FEFF.TestFixtures.XunitV3dotnet add package FEFF.TestFixtures.AspNetCoredotnet add package FEFF.TestFixtures.AspNetCore.EFdotnet add package AwesomeAssertionsdotnet add package AwesomeAssertions.Json
AwesomeAssertions— форкFluentAssertions, делает ассерты более удобными.
Шаг 2. Активируем расширение
В любом файле тестового проекта добавляем атрибут для активации системы фикстур:
[assembly: FEFF.TestFixtures.Xunit.TestFixturesExtension]
Шаг 3. Изолируем базу данных
Чтобы тесты выполнялись параллельно и не конфликтовали друг с другом, каждый тест должен работать со своей базой данных. Для этого создадим конфигурационную фикстуру:
[Fixture]public class OptionsFixture : ITmpDatabaseNameFixtureOptions{ public IReadOnlyCollection<string> ConnectionStringNames => ["PgDb"];}
Этот класс понадобится для настройки TmpDatabaseNameFixture, которая автоматически перехватит connection string "PgDb" и подменит поле Database, добавив уникальный суффикс.
Шаг 4. Компонуем FixtureSet
Вместо наследования от базовых классов мы используем композицию. Создадим FixtureSet — record, объединяющий всё необходимое для тестирования нашего API:
[Fixture]public record FixtureSet( AppManagerFixture<Program> AppManagerFx, FakeRandomFixture<Program> FakeRandomFx, FakeTimeFixture<Program> FakeTimeFx, AppClientFixture<Program> ClientFx, DatabaseLifecycleFixture<Program, ApplicationDbContext> DbFx, TmpDatabaseNameFixture<Program, OptionsFixture> TmpDbNameFx);
Каждый элемент — это фикстура, решающая свою задачу:
|
Фикстура |
|
|
Что делает |
Управляет жизненным циклом тестируемого приложения. Позволяет менять конфигурацию до запуска. |
|
Параметры |
|
|
Фикстура |
|
|
Что делает |
Предоставляет |
|
Параметры |
|
|
Фикстура |
|
|
Что делает |
Подменяет |
|
Параметры |
|
|
Фикстура |
|
|
Что делает |
Подменяет |
|
Параметры |
|
|
Фикстура |
|
|
Что делает |
Создаёт, удаляет и даёт доступ к базе данных в контексте теста. |
|
Параметры |
|
|
Фикстура |
|
|
Что делает |
Гарантирует уникальное имя БД для каждого теста. |
|
Параметры |
|
Примечание: фикстуры с пометкой (*) зависят от AppManagerFixture<Program>, потому что им нужно зарегистрировать подмену сервисов в DI-контейнере приложения. Чтобы система фикстур смогла разрешить зависимости, нам нужно указать параметр Program. Всё остальное будет сделано автоматически.
Шаг 5. Создаём тестовый класс
public class ApiTests{ // фикстуры материализуются тут protected FixtureSet FixtureSet { get; } = TestContext.Current.GetFeffFixture<FixtureSet>(); // Удобные свойства для быстрого доступа protected FakeRandom AppRandom => FixtureSet.FakeRandomFx.Value; protected FakeTimeProvider AppTime => FixtureSet.FakeTimeFx.Value; protected IAppConfigurator AppConfigurationBuilder => FixtureSet.AppManagerFx.ConfigurationBuilder; protected HttpClient Client => FixtureSet.ClientFx.LazyValue; protected ApplicationDbContext AppDbCtx => FixtureSet.DbFx.LazyDbContext; protected IDatabaseLifecycleFixture DbFx => FixtureSet.DbFx;}
Всё. Тестовая инфраструктура готова!
Шаг 6. Пишем тесты
[Fact] public async Task Generate_weatherforecast__should_persist_and_return() { // Arrange var expectedDate = "2025-06-15"; var expectedTemperature = 42; var expectedSummary = "sunny"; // Фиксируем время: сегодня 15 июня 2025 AppTime.SetUtcNow(DateTimeOffset.Parse($"{expectedDate}T12:00:00Z")); // Фиксируем случайность: температура всегда 42 AppRandom.Int32Next = FixedNextStrategy.From(expectedTemperature); // Подменяем конфигурацию до старта приложения AppConfigurationBuilder.UseSetting("summary", expectedSummary); // Создаём изолированную базу данных // В этот момент стартует приложение await DbFx.EnsureCreatedAsync(TestContext.Current.CancellationToken); // Act: генерируем прогноз await PostAsync(Client, "/weatherforecast/generate", null); // Assert: данные реально попали в базу? var forecasts = await AppDbCtx.WeatherForecasts .Select(x => x.Data) .ToListAsync(TestContext.Current.CancellationToken); JToken.FromObject(forecasts) .Should().BeEquivalentTo($$""" [ { "Date": "{{expectedDate}}", "TemperatureC": {{expectedTemperature}}, "Summary": "{{expectedSummary}}" } ] """); // Act: запрашиваем через API прогноз на сегодня var response = await GetAsync(Client, "/weatherforecast/today"); // Assert: API возвращает то, что лежит в базе? response.Should().BeEquivalentTo($$""" { "date": "{{expectedDate}}", "temperatureC": {{expectedTemperature}}, "summary": "{{expectedSummary}}" } """); }
Что же здесь произошло?
-
Детерминизм. Мы зафиксировали время на 15 июня 2025 года и температуру на 42 градуса. Тест больше не зависит от системных часов и случайности. Фикстуры самостоятельно нашли тестовое приложение и обновили его DI-контейнер.
-
Гибкость. Мы изменили конфигурацию приложения, установив значение “sunny” для переменной “summary”. Мы можем запросто модифицировать тестовое приложение для отдельного теста.
-
Изоляция.
TmpDatabaseNameFixtureгарантирует, что этот тест работает со своей базой. Даже если запустить сотню тестов параллельно — каждый получит уникальную БД. -
Автоматическая очистка. После теста
DatabaseLifecycleFixtureудалит временную базу,AppClientFixtureосвободитHttpClient, аAppManagerFixtureостановит приложение. Мы не написали ни одной строчки кода очистки.
Сравнение с альтернативами
xUnit Native Fixtures
Нативный механизм xUnit (IClassFixture<T>, ICollectionFixture<T>, AssemblyFixtureAttribute) решает ту же задачу, но с существенными ограничениями:
|
Возможность |
FEFF.TestFixtures |
xUnit Native |
|---|---|---|
|
Фикстура с областью видимости отдельного теста |
✅ Есть |
❌ Нет |
|
Разрешение зависимостей между фикстурами |
✅ Есть |
❌ Нет |
|
Удобство материализации фикстур |
✅ Один метод |
⚠️ Несколько интерфейсов и конструктор |
|
Встроенные фикстуры |
✅ Для ASP.NET Core, БД, времени |
❌ Нет |
|
Async setup |
⚠️ Ручной вызов |
✅ |
Главное отличие: используя xUnit, придётся либо копировать setup-код, либо строить сложные иерархии наследования. FEFF.TestFixtures предлагает переиспользование через композицию.
Pytest
Для тех, кто приходит из Python:
|
|
Python (pytest) |
.NET (FEFF.TestFixtures) |
|---|---|---|
|
Объявление фикстуры |
Атрибут |
Атрибут |
|
Использование в тесте |
Аргумент функции: |
Вызов статического метода |
|
Управление областью видимости |
|
|
|
Фикстура временной папки |
|
|
|
Фикстура времени |
|
|
|
Фикстура базы данных |
|
|
Философия та же — декларативное объявление зависимостей, автоматическое управление жизненным циклом, композиция вместо наследования.
Отличия:
|
|
Python (pytest) |
.NET (FEFF.TestFixtures) |
|---|---|---|
|
Область видимости фикстуры |
Определяется автором фикстуры при её реализации |
Определяется автором теста при использовании фикстуры |
|
Смешение областей видимости |
Фикстуры могут зависеть от фикстур с другими областями видимости |
Зависимые фикстуры создаются в той же области видимости |
Заключение: что стало лучше
Давайте подведём итог. В начале статьи интеграционный тест ASP.NET Core API выглядел так:
-
Наследование от
WebApplicationFactory. -
Переопределение
ConfigureWebHost. -
Setup-код в классе тестов.
-
IDisposableв классе тестов для очистки. -
Дублирование всего этого в каждом тестовом классе и в каждом проекте.
С FEFF.TestFixtures.AspNetCore:
-
✅ Декларативное описание инфраструктуры тестов через
FixtureSet(за счёт композиции). -
✅ Есть возможность донастроить
WebApplicationFactoryпод отдельный тест (за счёт композиции). -
✅ Setup-код занимает ровно 1 строку.
-
✅ Нет кода очистки — фикстуры берут это на себя.
-
✅ Фикстуры можно (и нужно) переиспользовать для разных проектов.
Если вам, как и мне, не хватает элегантности pytest в мире .NET — попробуйте FEFF.TestFixtures. Это не серебряная пуля, но это шаг к тому, чтобы тестирование стало одновременно удобным и полезным инструментом.
Ссылки и ресурсы
-
📦 NuGet: FEFF.TestFixtures.AspNetCore
-
📚 Документация: https://metacoder-feff.github.io/FEFF.TestFixtures/
-
💻 Исходный код: https://github.com/metacoder-feff/FEFF.TestFixtures
-
🧪 Код тестов из статьи: ApiTests.cs
-
📝 Приложение, которое тестируем: WebApiTestSubject
Бонус: минутка AI
Попросите вашего ассистента создать такие тесты для вашего проекта следующим промтом:
In file <путь-до-файла-с-тестами>.cs
Create api tests for application <название-или-путь-папки-с-проектом-приложением-web-api>
Using FEFF.TestFixtures Library:Use latest stable versions of packages FEFF.TestFixtures.*
Не забудьте подставить сюда пути исходного проекта и конечного файла с тестами. Если приложение большое, то перечислите в промте отдельные эндпоинты и/или бизнес-функции.
Для создания дополнительных тестов — как обычно просите сделать по аналогии с уже созданными.
Автор — разработчик, который уверен, что хорошие тесты должны писаться с такой же лёгкостью, как хороший продакшн-код.
Если статья была полезна — поделитесь опытом в комментариях. Буду рад обратной связи и вопросам!
ссылка на оригинал статьи https://habr.com/ru/articles/1044534/