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 + Testcontainers | C. 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 чтений |
|---|---|---|---|---|---|
| 1 | 129 мс | 309 мс | 64 мс | 205 мс | 193 мс |
| 2 | 11 мс | 2 мс | 4 мс | 49 мс | 70 мс |
| 3 | 4 мс | 7 мс | 3 мс | 49 мс | 67 мс |
Конфигурация B, та же фабрика, направленная на экземпляр PostgreSQL, поднятый через Testcontainers, образ уже загружен:
| Раунд | Старт контейнера | Старт фабрики | Создание схемы | Первый запрос | 100 записей | 100 чтений | Остановка |
|---|---|---|---|---|---|---|---|
| 1 | 2933 мс | 5 мс | 198 мс | 4 мс | 210 мс | 191 мс | 321 мс |
| 2 | 1403 мс | 5 мс | 42 мс | 6 мс | 131 мс | 197 мс | 300 мс |
| 3 | 1424 мс | 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
Измерено на трёх последовательных запусках в одном процессе:
| Запуск | Длительность | Идентификатор контейнера |
|---|---|---|
| 1 | 1812 мс | 81ae62b0f2b4 |
| 2 | 103 мс | 81ae62b0f2b4 |
| 3 | 81 мс | 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.
Похожие материалы
- Полная механика самой фабрики, включая
ConfigureTestServicesпротивConfigureWebHostи подделку аутентификации: интеграционные тесты сWebApplicationFactory<T>в ASP.NET Core 11. - Сторона контейнеров подробно, с
IAsyncLifetime, миграциями и Ryuk: интеграционные тесты против настоящего SQL Server с Testcontainers. - Разделение фикстур, настройки параллелизма и жизненный цикл отличаются в зависимости от фреймворка: xUnit v3 против NUnit против MSTest в 2026 году.
- Другой распространённый источник ненадёжных тестов: тестирование зависящего от времени кода с
TimeProviderиFakeTimeProvider. - Поведение при конкурентном доступе, которое не воспроизводит ни одна поддельная база: оптимистичная конкурентность с токеном
rowversionв EF Core 11.
Источники
- Интеграционные тесты в ASP.NET Core про
WebApplicationFactory<TEntryPoint>и атрибут content root - Выбор стратегии тестирования в документации EF Core, о том, почему провайдер в памяти не является базой данных
- Документация Testcontainers for .NET и релизы с 4.10.0 по 4.13.0, в которых появились обязательная фиксация образа и API хеша повторного использования
- Обсуждение повторного использования контейнеров в Testcontainers, охватывающее устаревшие конструкторы без параметров
- Версии пакетов в NuGet: Microsoft.AspNetCore.Mvc.Testing 10.0.1, Testcontainers.PostgreSql 4.13.0
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.