Как упростить тестирование ASP.NET Core API

от автора

Разрабатывая приложения, мы стараемся не злоупотреблять дублированием кода. Из часто встречающегося кода мы формируем библиотеки, а для их соединения в инфраструктуре 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-запроса до записи в базу данных.

Проблемы классического способа тестирования

Чтобы протестировать это приложение, нам нужно:

  1. Создать CustomWebApplicationFactory, унаследовав от WebApplicationFactory<Program>.

  2. Переопределить ConfigureWebHost, чтобы заменить TimeProvider и Random на моки.

  3. Создать уникальную базу данных для каждого теста, иначе тесты будут мешать друг другу.

  4. Создать HttpClient через фабрику.

  5. Написать конструктор тестового класса для инициализации всего вышеперечисленного.

  6. Не забыть про 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 — точка входа тестируемого приложения (внутри этой фикстуры создаётся WebApplicationFactory<Program>)

Фикстура

AppClientFixture

Что делает

Предоставляет HttpClient, подключённый к тестовому приложению.

Параметры

Program — *

Фикстура

FakeRandomFixture

Что делает

Подменяет Random на детерминированный генератор.

Параметры

Program — *

Фикстура

FakeTimeFixture

Что делает

Подменяет TimeProvider. Можно установить любую дату.

Параметры

Program — *

Фикстура

DatabaseLifecycleFixture

Что делает

Создаёт, удаляет и даёт доступ к базе данных в контексте теста.

Параметры

Program — *
ApplicationDbContext — контекст EF Core

Фикстура

TmpDatabaseNameFixture

Что делает

Гарантирует уникальное имя БД для каждого теста.

Параметры

Program — *
OptionsFixture — фикстура с конфигурацией (с её помощью передаётся название строки подключения, которую надо пропатчить)

Примечание: фикстуры с пометкой (*) зависят от 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}}"            }            """);    }

Что же здесь произошло?

  1. Детерминизм. Мы зафиксировали время на 15 июня 2025 года и температуру на 42 градуса. Тест больше не зависит от системных часов и случайности. Фикстуры самостоятельно нашли тестовое приложение и обновили его DI-контейнер.

  2. Гибкость. Мы изменили конфигурацию приложения, установив значение “sunny” для переменной “summary”. Мы можем запросто модифицировать тестовое приложение для отдельного теста.

  3. Изоляция. TmpDatabaseNameFixture гарантирует, что этот тест работает со своей базой. Даже если запустить сотню тестов параллельно — каждый получит уникальную БД.

  4. Автоматическая очистка. После теста DatabaseLifecycleFixture удалит временную базу, AppClientFixture освободит HttpClient, а AppManagerFixture остановит приложение. Мы не написали ни одной строчки кода очистки.

Сравнение с альтернативами

xUnit Native Fixtures

Нативный механизм xUnit (IClassFixture<T>, ICollectionFixture<T>, AssemblyFixtureAttribute) решает ту же задачу, но с существенными ограничениями:

Возможность

FEFF.TestFixtures

xUnit Native

Фикстура с областью видимости отдельного теста

✅ Есть

❌ Нет

Разрешение зависимостей между фикстурами

✅ Есть

❌ Нет

Удобство материализации фикстур

✅ Один метод

⚠️ Несколько интерфейсов и конструктор

Встроенные фикстуры

✅ Для ASP.NET Core, БД, времени

❌ Нет

Async setup

⚠️ Ручной вызов

IAsyncLifetime

Главное отличие: используя xUnit, придётся либо копировать setup-код, либо строить сложные иерархии наследования. FEFF.TestFixtures предлагает переиспользование через композицию.

Pytest

Для тех, кто приходит из Python:

Python (pytest)

.NET (FEFF.TestFixtures)

Объявление фикстуры

Атрибут @pytest.fixture на функцию

Атрибут [Fixture] на класс

Использование в тесте

Аргумент функции: def test_something(db, client):

Вызов статического метода TestContext.Current.GetFeffFixture<T>()

Управление областью видимости

scope="function" / "session"

FixtureScopeType.TestCase / Assembly

Фикстура временной папки

tmp_path

TmpDirectoryFixture

Фикстура времени

pytest-freezegun

FakeTimeFixture

Фикстура базы данных

pytest-postgresql

TmpDatabaseNameFixture + DatabaseLifecycleFixture

Философия та же — декларативное объявление зависимостей, автоматическое управление жизненным циклом, композиция вместо наследования.

Отличия:

Python (pytest)

.NET (FEFF.TestFixtures)

Область видимости фикстуры

Определяется автором фикстуры при её реализации

Определяется автором теста при использовании фикстуры

Смешение областей видимости

Фикстуры могут зависеть от фикстур с другими областями видимости

Зависимые фикстуры создаются в той же области видимости

Заключение: что стало лучше

Давайте подведём итог. В начале статьи интеграционный тест ASP.NET Core API выглядел так:

  • Наследование от WebApplicationFactory.

  • Переопределение ConfigureWebHost.

  • Setup-код в классе тестов.

  • IDisposable в классе тестов для очистки.

  • Дублирование всего этого в каждом тестовом классе и в каждом проекте.

С FEFF.TestFixtures.AspNetCore:

  • ✅ Декларативное описание инфраструктуры тестов через FixtureSet (за счёт композиции).

  • ✅ Есть возможность донастроить WebApplicationFactory под отдельный тест (за счёт композиции).

  • ✅ Setup-код занимает ровно 1 строку.

  • ✅ Нет кода очистки — фикстуры берут это на себя.

  • ✅ Фикстуры можно (и нужно) переиспользовать для разных проектов.

Если вам, как и мне, не хватает элегантности pytest в мире .NET — попробуйте FEFF.TestFixtures. Это не серебряная пуля, но это шаг к тому, чтобы тестирование стало одновременно удобным и полезным инструментом.

Ссылки и ресурсы


Бонус: минутка 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/