Start Debugging

WebApplicationFactory против Testcontainers для интеграционных тестов в ASP.NET Core

Это не альтернативы. WebApplicationFactory поднимает ваше приложение, Testcontainers поднимает его зависимости. Измерено на .NET SDK 10.0.201: фикстура с контейнером стоит 1.7 с на класс против 10 мс у SQLite, а нарушение HasMaxLength(16), которое Postgres отклоняет с кодом 22001, SQLite молча принимает.

Используйте оба. WebApplicationFactory<T> поднимает ваше приложение; Testcontainers поднимает то, с чем ваше приложение общается. Единственное решение, которое вам действительно нужно принять, касается того, что стоит за слоем данных, и ответ такой: если тест проверяет что-либо, что обеспечивает база данных, вам нужна настоящая база данных в контейнере. Если он проверяет маршрутизацию, привязку модели, авторизацию или форму JSON, обойдитесь без Docker и заплатите 10 мс вместо 1.7 секунды.

Всё изложенное ниже измерено на .NET SDK 10.0.201 с Microsoft.AspNetCore.Mvc.Testing 10.0.1, Testcontainers.PostgreSql 4.13.0, EF Core 10.0.1 и postgres:17.6-alpine, на Docker Desktop 29.5.3 (бэкенд WSL2, выделено 20 CPU) на Intel Core Ultra 7 265KF с 32 ГБ ОЗУ, Windows 11 26200. В .NET 11 preview эти API не изменились.

Три конфигурации, которые на самом деле имеют в виду

“WebApplicationFactory против Testcontainers” - плохо поставленный вопрос, потому что эти две вещи находятся на разных уровнях. На самом деле выбирают одну из трёх конфигураций:

A. WAF + подделка в процессеB. WAF + TestcontainersC. Testcontainers целиком
Где работает приложениеВ вашем тестовом процессеВ вашем тестовом процессеВ контейнере, который вы собрали
ТранспортTestServer, без сокетаTestServer, без сокетаНастоящий сокет, настоящий Kestrel
База данныхSQLite / в памяти / mockНастоящий движок в контейнереНастоящий движок в контейнере
Требуется DockerНетДаДа
Стоимость фикстуры (измерено)~10 мс~1.7 с~1.7 с плюс сборка образа
Можно поставить точку останова в коде приложенияДаДаНет
Можно подменить сервис подделкойДаДаНет
Проверяет ваш Dockerfile / entrypointНетНетДа
Проверяет HTTPS, HTTP/2, лимиты KestrelНетНетДа
Ловит нарушения ограничений базы данныхНет (см. ниже)ДаДа

A и B - это один и тот же код с другой строкой подключения. C - принципиально другое, и это единственная строка, где “против” действительно означает выбор одного из двух, потому что в C вы полностью теряете ConfigureTestServices: приложение становится запечатанным артефактом, и говорить с ним можно только по HTTP.

Большинству команд нужен вариант B, они берут A, потому что Docker показался медленным, и всерьёз никогда не рассматривают C. Цифры ниже говорят, что A дешевле, чем вы считаете его дорогим, что B дешевле, чем вы думаете, и что причина выбирать B вообще не связана с производительностью.

Измерение

Тестируемая система - minimal API с одним POST /orders, который пишет через EF Core, и одним GET /orders, который читает обратно. Для Order.Sku заданы HasMaxLength(16) и уникальный индекс. Стенд поднимает новую фабрику по три раза на конфигурацию в одном и том же процессе, так что раунд 1 включает JIT и построение модели EF, а раунды 2 и 3 показывают установившееся состояние.

// .NET 10.0.201, C# 14, Mvc.Testing 10.0.1, Testcontainers.PostgreSql 4.13.0
var sw = Stopwatch.StartNew();
var pg = new PostgreSqlBuilder("postgres:17.6-alpine").Build();
await pg.StartAsync();
var containerStart = sw.ElapsedMilliseconds;

sw.Restart();
await using var factory = new PostgresFactory(pg.GetConnectionString());
var client = factory.CreateClient();
var boot = sw.ElapsedMilliseconds;

Конфигурация A, WebApplicationFactory<T> поверх подключения к SQLite в памяти, без Docker:

РаундСтарт фабрикиСоздание схемыПервый запрос100 записей100 чтений
1129 мс309 мс64 мс205 мс193 мс
211 мс2 мс4 мс49 мс70 мс
34 мс7 мс3 мс49 мс67 мс

Конфигурация B, та же фабрика, направленная на экземпляр PostgreSQL, поднятый через Testcontainers, образ уже загружен:

РаундСтарт контейнераСтарт фабрикиСоздание схемыПервый запрос100 записей100 чтенийОстановка
12933 мс5 мс198 мс4 мс210 мс191 мс321 мс
21403 мс5 мс42 мс6 мс131 мс197 мс300 мс
31424 мс4 мс32 мс5 мс81 мс81 мс306 мс

Отсюда следуют две вещи, противоречащие расхожему мнению.

Сама фабрика бесплатна в обоих случаях. Подъём WebApplicationFactory<T> стоит от 4 до 5 мс после того, как процесс прогрелся, независимо от того, какая база данных стоит за ним. Когда говорят, что интеграционные тесты медленные, речь почти никогда не идёт о TestServer.

Стоимость одного запроса практически одинакова. 100 проходов через весь конвейер middleware, привязку модели, EF Core и обратно стоят 49 мс на SQLite и 81 мс на Postgres в контейнере в установившемся состоянии. Это 0.3 мс разницы на запрос через петлевой сокет внутрь WSL2. Не то, что база данных настоящая, делает ваш набор тестов медленным.

Дорого обходится фикстура: около 1.7 секунды на запуск и остановку контейнера, на каждую фикстуру, против примерно 10 мс у варианта в процессе. Умножьте на количество тестовых классов, у каждого из которых свой контейнер, и вы получите ответ. Набор из 40 фикстур с собственными контейнерами тратит 68 секунд на то, чтобы только запускать и останавливать Postgres.

Холодную стоимость стоит назвать отдельно, потому что именно её платит ваш первый прогон CI: загрузка postgres:17.6-alpine с нуля заняла 11.3 секунды для образа размером 106 МБ. Это дешёвый край. Образ SQL Server для разработчиков больше более чем на порядок, и поэтому руководство по Testcontainers с SQL Server отводит целый раздел кешированию этого слоя в CI.

Результат, который решает вопрос

Производительность здесь не главная ось. Главная - вот эта:

// .NET 10.0.201, EF Core 10.0.1
// Order.Sku is configured HasMaxLength(16)
db.Orders.Add(new Order { Sku = "TOOLONGSKU-0123456789", Total = 1m });
await db.SaveChangesAsync();

Против контейнера:

postgres: 22001: value too long for type character varying(16)

Против SQLite в памяти:

sqlite:   ACCEPTED, stored 21 chars

SQLite не обеспечивает ограничение длины varchar. EF Core честно генерирует TEXT для строки с HasMaxLength(16), SQLite сохраняет все 21 символ без возражений, и тест, который должен был доказать, что ваша валидация работает, проходит. В production та же запись выбрасывает исключение. Одно это расхождение и есть весь аргумент, и оно обобщается: SQLite отличается от Postgres и SQL Server точностью десятичных чисел, чувствительностью идентификаторов к регистру, точностью DateTime, поведением при конкурентной записи и почти любым запросом FromSql, который вы когда-либо напишете. Провайдер EF Core в памяти ещё хуже, поскольку не обеспечивает вообще никакой реляционной семантики.

Поэтому правило звучит не как “всегда используйте Testcontainers” и не как “Testcontainers слишком медленный”. Оно звучит так: как только проверка в тесте зависит от того, что обеспечивает движок базы данных, поддельная база данных превращает этот тест в ложь. Нарушения ограничений, каскадные удаления, токены конкурентного доступа rowversion (см. оптимистичную конкурентность с токеном rowversion), сырой SQL, миграции и всё, что затрагивает транслятор запросов, относятся к конфигурации B.

Когда выбирать каждую

Выбирайте A (WAF, без Docker), когда тест про HTTP-поверхность. Отклоняет ли /orders/{id:int} значение abc с кодом 400? Возвращает ли атрибут [Authorize(Policy = "Admin")] код 403 для не-администратора? Сериализует ли ответ total числом, а не строкой? Выдаёт ли обработчик исключений тело ProblemDetails? Ничему из этого не важно, настоящая ли база данных, а многим из этих тестов база вообще не нужна: зарегистрируйте заглушку репозитория через ConfigureTestServices и пропустите слой хранения целиком. Это те тесты, которые вы хотите запускать на каждое нажатие клавиши, и при 10 мс подготовки они это позволяют.

Выбирайте B (WAF + Testcontainers), когда проверка добирается до движка хранения. Это вариант по умолчанию для тестов репозиториев, тестов запросов EF Core, проверки миграций и любой конечной точки, чьё интересное поведение - это путь ошибки базы данных. Это также единственный честный способ проверить, что ваши миграции действительно применяются к пустой базе данных, а это класс отказов, который не ловит ни одна подделка и который кладёт production.

Выбирайте C (полностью в контейнерах), когда проверяется сам артефакт. Вы убеждаетесь, что Dockerfile собирает работоспособный образ, что entrypoint читает переменные окружения, которые задаёт ваш Helm-чарт, что TLS корректно терминируется или что согласование HTTP/2 работает. TestServer не расскажет вам ничего из этого, потому что он никогда не открывает сокет. C - это горстка дымовых тестов в конце конвейера, а не стратегия тестирования.

Как удешевить B: повторное использование

1.7 секунды на фикстуру - не фиксированная величина. Testcontainers уже давно поддерживает повторное использование контейнеров, и это превращает стоимость фикстуры в незначительную мелочь при локальной разработке:

// Testcontainers 4.13.0
var pg = new PostgreSqlBuilder("postgres:17.6-alpine")
    .WithReuse(true)
    .Build();
await pg.StartAsync();
// deliberately not disposed: reuse keeps the container alive between runs

Измерено на трёх последовательных запусках в одном процессе:

ЗапускДлительностьИдентификатор контейнера
11812 мс81ae62b0f2b4
2103 мс81ae62b0f2b4
381 мс81ae62b0f2b4

Тот же контейнер, 81 мс вместо 1812. Повторное использование определяется по хешу конфигурации контейнера, так что смена тега образа, окружения или проброса портов корректно создаёт новый контейнер.

Подвох в очистке. Документация Testcontainers прямо говорит, что включение повторного использования отключает resource reaper, поэтому Ryuk не удалит контейнер за вас, а вызов DisposeAsync() на повторно используемом контейнере останавливает его, а не удаляет. Устаревший контейнер со схемой прошлой недели будет преспокойно обслуживать ваши тесты, пока вы не удалите его вручную. Именно это сохранение состояния между прогонами делает повторное использование оптимизацией для локальной разработки, а не для CI: спрячьте его за проверку переменных окружения, чтобы конвейер всегда получал чистый движок.

Обратите внимание, что в отличие от реализации на Java, Testcontainers для .NET не требует включения через ~/.testcontainers.properties. Достаточно одного WithReuse(true), что удобно и одновременно объясняет, почему ограничивать его - ваша задача.

Второй рычаг, который важнее в CI, - разделять один контейнер между многими тестовыми классами вместо одного на класс. В xUnit это collection fixture или assembly fixture вместо IClassFixture<T>; различия между фреймворками разобраны в сравнении xUnit v3, NUnit и MSTest. Разделяйте контейнер, изолируйте данные: дайте каждому тестовому классу свою схему или свою базу данных на общем сервере либо сбрасывайте состояние через truncate между тестами.

Три ошибки, с которыми вы столкнётесь при настройке

Все три возникли при сборке стенда для этой статьи, на текущих версиях пакетов.

Solution root could not be located using application root. WebApplicationFactory<T> определяет content root приложения, поднимаясь по дереву каталогов от тестовой сборки в поисках файла .sln или .slnx, если только MSBuild-таргет из Microsoft.AspNetCore.Mvc.Testing не проставил WebApplicationFactoryContentRootAttribute в вашу тестовую сборку. Тестовый проект, не входящий в файл решения, что всё чаще встречается с раскладками эпохи dotnet run app.cs, падает на первом же CreateClient(). Либо добавьте проекты в решение, либо переопределите CreateHost и задайте content root явно.

Services for database providers 'Npgsql.EntityFrameworkCore.PostgreSQL', 'Microsoft.EntityFrameworkCore.Sqlite' have been registered in the service provider. Only a single database provider can be registered in a service provider. Это классический сбой при подмене DbContext, и совет, который вы найдёте на Stack Overflow, устарел. Удалить DbContextOptions<TContext> уже недостаточно, потому что AddDbContext в EF Core 9 и новее дополнительно регистрирует IDbContextOptionsConfiguration<TContext>, который по-прежнему тянет за собой рабочий провайдер. Удаляйте все три:

// .NET 10.0.201, EF Core 10.0.1
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    builder.ConfigureTestServices(services =>
    {
        services.RemoveAll(typeof(IDbContextOptionsConfiguration<OrdersDbContext>));
        services.RemoveAll(typeof(DbContextOptions<OrdersDbContext>));
        services.RemoveAll(typeof(DbContextOptions));
        services.AddDbContext<OrdersDbContext>(o => o.UseNpgsql(_connectionString));
    });
}

Более чистая альтернатива, если Program.cs ваш, - вообще не регистрировать провайдер, который вы собираетесь заменить: читайте строку подключения из конфигурации и позвольте тестовой фабрике подставить её через ConfigureAppConfiguration. Тогда удалять будет нечего.

'PostgreSqlBuilder.PostgreSqlBuilder()' is obsolete. Начиная с Testcontainers 4.13.0 конструкторы модулей без параметров объявлены устаревшими, и образ нужно передавать в конструктор: new PostgreSqlBuilder("postgres:17.6-alpine"). Это завершение изменения из 4.10, после которого модули перестали по умолчанию подставлять выбранный сопровождающими тег. Сегодня это предупреждение, позже станет ошибкой, и решение верное: плавающий тег образа означает, что конвейер CI, зелёный вчера, может упасть сегодня по причинам, никак не связанным с вашим коммитом.

Что бы я сделал на практике

По умолчанию конфигурация B для всего, где в стеке вызовов есть репозиторий, и конфигурация A для всего остального. Конкретно: один общий контейнер на сборку, WithReuse(true) локально, сброс через truncate между тестами вместо контейнера на класс и отдельный быстрый тестовый проект без зависимости от Docker для тестов HTTP-поверхности, чтобы dotnet test по этому проекту оставался в пределах секунды.

Не используйте SQLite или провайдер в памяти как замену вашего рабочего движка. Берите их тогда, когда база данных действительно второстепенна для того, что вы проверяете, и признайте честно: на этом этапе вы пишете HTTP-тест, которому просто нужно, чтобы слой хранения существовал. Измеренные 30 мс на сотню запросов, которые вы экономите, не стоят зелёного теста, который в production был бы красным. Если подделка всё-таки нужна, мокирование DbContext без поломки отслеживания изменений - более честная подделка, чем другой диалект SQL.

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

Похожие материалы

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад