Как писать интеграционные тесты против настоящего SQL Server с помощью Testcontainers
Полное руководство по запуску интеграционных тестов ASP.NET Core против настоящего SQL Server 2022 с использованием Testcontainers 4.11 и EF Core 11: настройка WebApplicationFactory, IAsyncLifetime, подмена регистрации DbContext, применение миграций, параллелизм, очистка через Ryuk и подводные камни CI.
Чтобы запустить интеграционные тесты против настоящего SQL Server из тестового проекта на .NET 11, установите Testcontainers.MsSql 4.11.0, соберите WebApplicationFactory<Program>, владеющую MsSqlContainer, запустите контейнер в IAsyncLifetime.InitializeAsync, переопределите регистрацию DbContext в ConfigureWebHost, чтобы она указывала на container.GetConnectionString(), и примените миграции один раз перед первым тестом. Используйте IClassFixture<T>, чтобы xUnit делил один контейнер между тестами в классе. Зафиксируйте образ SQL Server на конкретном теге, по умолчанию mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04, и позвольте Ryuk утилизировать контейнер, если ваш процесс упадёт. Это руководство написано для .NET 11 preview 3, C# 14, EF Core 11, xUnit 2.9 и Testcontainers 4.11. На .NET 8, 9 и 10 шаблон тот же, меняются только версии пакетов.
Почему настоящий SQL Server, а не in-memory провайдер
EF Core поставляется с in-memory провайдером и вариантом SQLite-in-memory, которые выглядят как SQL Server до тех пор, пока не перестают. У in-memory провайдера вообще нет реляционного поведения: ни транзакций, ни принуждения внешних ключей, ни токенов конкуренции RowVersion, ни трансляции SQL. SQLite — настоящий реляционный движок, но с другим диалектом SQL, другим способом квотинга идентификаторов и другим decimal-типом. Те самые проблемы, которые ваши интеграционные тесты должны ловить — отсутствующий индекс, нарушение уникального ограничения, обрезка nvarchar или потеря точности у DateTime2, — молча маскируются.
Официальная документация EF Core несколько лет назад добавила предупреждение «не тестируйте против in-memory», а рекомендуемый командой шаблон на странице testing without your production database system звучит как «поднимите настоящий в контейнере». Testcontainers превращает это в один вызов метода. Цена — холодный старт скачивания и запуска образа SQL Server (порядка 8–12 секунд при тёплом Docker daemon), зато каждое утверждение после этого проверяет тот же движок, что и в продакшене.
Зафиксируйте образ, не оставляйте плавающим
До любого кода определитесь с тегом образа. Документация Testcontainers по умолчанию использует mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04, и это правильный выбор по той же причине, по которой вы не плавите :latest в продакшене: CI-пайплайн, который работал вчера, должен работать сегодня. Новый cumulative update — это не бесплатное обновление в вашем тестовом пайплайне, потому что каждый CU может изменить оптимизатор, поправить схемы sys.dm_* и поднять минимальный уровень патча для инструментов вроде sqlpackage.
Образ 2022-CU14-ubuntu-22.04 весит около 1,6 ГБ в сжатом виде, и первый pull на свежем CI-раннере — самая медленная часть набора. Кешируйте этот слой в CI: в GitHub Actions есть docker/setup-buildx-action с cache-from, в Azure DevOps можно кешировать ~/.docker с тем же эффектом. После первого тёплого кеша pull занимает около 2 секунд.
Если нужны возможности SQL Server 2025 (векторный поиск, JSON_CONTAINS, см. SQL Server 2025 JSON contains in EF Core 11), поднимите тег до 2025-CU2-ubuntu-22.04. Иначе оставайтесь на 2022, потому что developer-образ для 2022 наиболее широко протестирован мейнтейнерами Testcontainers.
Нужные пакеты
Три пакета покрывают happy path:
<!-- .NET 11, xUnit-based test project -->
<ItemGroup>
<PackageReference Include="Testcontainers.MsSql" Version="4.11.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="11.0.0" />
</ItemGroup>
Testcontainers.MsSql тянет базовый пакет Testcontainers и MsSqlBuilder. Microsoft.AspNetCore.Mvc.Testing поставляет WebApplicationFactory<TEntryPoint>, который поднимает весь ваш DI-контейнер и HTTP-пайплайн против TestServer. Microsoft.EntityFrameworkCore.SqlServer — то, на что уже ссылается ваш продакшен-код; тестовый проект подтягивает его, чтобы фикстура могла применять миграции.
Если тесты на xUnit, добавьте также xunit 2.9.x и xunit.runner.visualstudio 2.8.x. На NUnit или MSTest тот же фабричный шаблон работает, меняются только имена хуков жизненного цикла.
Класс фабрики
Фабрика интеграционных тестов делает три вещи: владеет временем жизни контейнера, выставляет строку подключения в DI хоста и применяет схему до запуска любого теста. Вот полная реализация для гипотетического OrdersDbContext:
// .NET 11, C# 14, EF Core 11, Testcontainers 4.11
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.MsSql;
using Xunit;
public sealed class OrdersApiFactory
: WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly MsSqlContainer _sql = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04")
.WithPassword("Strong!Passw0rd_for_tests")
.Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<DbContextOptions<OrdersDbContext>>();
services.AddDbContext<OrdersDbContext>(opts =>
opts.UseSqlServer(_sql.GetConnectionString()));
});
}
public async Task InitializeAsync()
{
await _sql.StartAsync();
using var scope = Services.CreateScope();
var db = scope.ServiceProvider
.GetRequiredService<OrdersDbContext>();
await db.Database.MigrateAsync();
}
public new async Task DisposeAsync()
{
await _sql.DisposeAsync();
await base.DisposeAsync();
}
}
Стоит обратить внимание на три детали. Контейнер создаётся в инициализаторе поля, но запускается только в InitializeAsync, потому что xUnit вызывает этот метод ровно один раз на фикстуру. Хост (а значит, и DI-контейнер) собирается WebApplicationFactory лениво, в первый раз когда читается Services или вызывается CreateClient, поэтому к моменту, когда InitializeAsync зовёт Services.CreateScope(), SQL-контейнер уже поднят и строка подключения подключена. Строка RemoveAll<DbContextOptions<OrdersDbContext>> обязательна: без неё остаются две регистрации, и services.AddDbContext становится второй, что молча сохраняет обе в зависимости от порядка резолвера.
Вызов WithPassword задаёт пароль SA. Политика паролей SQL Server требует минимум восьми символов и сочетания заглавных, строчных, цифр и символов; если вы зададите слабее, контейнер запустится, но движок не пройдёт health-проверки. По умолчанию SA-пароль Testcontainers — yourStrong(!)Password, он уже соответствует политике, поэтому пропустить .WithPassword тоже допустимо.
Использование фабрики в тестовом классе
IClassFixture<T> из xUnit — правильный скоуп для большинства случаев. Он создаёт фикстуру один раз, гоняет каждый тестовый метод класса против одного и того же SQL-контейнера, а потом утилизирует:
// .NET 11, xUnit 2.9
public sealed class OrdersApiTests : IClassFixture<OrdersApiFactory>
{
private readonly OrdersApiFactory _factory;
private readonly HttpClient _client;
public OrdersApiTests(OrdersApiFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
[Fact]
public async Task Post_creates_order_and_returns_201()
{
var response = await _client.PostAsJsonAsync("/orders",
new { customerId = "C-101", amount = 49.99m });
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
[Fact]
public async Task Get_returns_persisted_order()
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OrdersDbContext>();
db.Orders.Add(new Order { Id = "O-1", CustomerId = "C-101" });
await db.SaveChangesAsync();
var response = await _client.GetAsync("/orders/O-1");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}
Если для каждого теста нужен свежий контейнер (например, тест переписывает схему), используйте IAsyncLifetime напрямую на тестовом классе вместо IClassFixture. Это редкость; в девяти случаях из десяти вы хотите заплатить цену холодного старта один раз на класс, а состояние сбрасывать через truncate таблиц, а не через ребут.
Сбрасывайте состояние между тестами, не перезапускайте контейнер
Честная цена тестов с «настоящим SQL Server» — утечка состояния: тест A вставляет строки, тест B проверяет count и получает неправильный ответ. Есть три решения по возрастанию скорости:
- Truncate в начале каждого теста. Дешевле всего. Держите
static readonly string[] TablesInTruncationOrderи запускайтеTRUNCATE TABLEдля каждой. Это и рекомендуют мейнтейнеры Testcontainers в их примере для ASP.NET Core. - Заворачивать каждый тест в транзакцию и делать rollback в конце. Работает, если тестируемый код сам не вызывает
BeginTransaction. EF Core 11 по-прежнему не разрешает вложенные транзакции на SQL Server без вызоваEnlistTransaction. - Использовать
Respawn(пакет на NuGet). Один раз генерирует скрипт truncate, читая information schema, кеширует и запускает перед каждым тестом. На это переходят большинство крупных команд после нескольких сотен тестов.
Что бы вы ни выбрали, не вызывайте EnsureDeletedAsync и MigrateAsync между тестами. Раннер миграций EF Core тратит однозначные секунды даже на маленькую схему; помножьте на 200 тестов, и набор переедет с 30 секунд на 30 минут. О компромиссах времени жизни DbContext в тестах см. removing pooled DbContextFactory in EF Core 11 test swaps и связанные заметки про warming up the EF Core model.
Параллельный запуск тестов
xUnit по умолчанию запускает тестовые классы параллельно. С одним контейнером на class fixture это значит N классов поднимают M контейнеров одновременно, где M ограничено памятью вашего Docker-хоста. SQL Server в простое съедает примерно 1,5 ГБ ОЗУ на инстанс, поэтому 16 ГБ раннер GitHub Actions упирается примерно в восемь параллельных классов до начала свопа.
Две распространённые крутилки:
<!-- xunit.runner.json in the test project, copy to output -->
{
"parallelizeTestCollections": true,
"maxParallelThreads": 4
}
// or, opt-out per assembly
[assembly: CollectionBehavior(MaxParallelThreads = 4)]
Если вы используете атрибут [Collection], чтобы делить один контейнер между несколькими классами, эти классы сериализуются. Иногда это правильный компромисс: тёплый контейнер, медленнее по wall-clock на тест, заметно меньше давление на ОЗУ.
Что делает Ryuk и почему лучше его не выключать
Testcontainers поставляет sidecar по имени Ryuk (образ testcontainers/ryuk). При запуске .NET-процесса Ryuk цепляется к Docker daemon и следит за родительским процессом. Если ваш test runner упал, паникует или его убили kill -9, Ryuk замечает, что родителя нет, и утилизирует помеченные контейнеры. Без Ryuk упавший прогон тестов оставляет осиротевшие контейнеры SQL Server, и следующий прогон ловит конфликт портов или нехватку ОЗУ.
Ryuk включён по умолчанию. Отключение (TESTCONTAINERS_RYUK_DISABLED=true) иногда советуют в ограниченных CI-окружениях, но это перекладывает бремя очистки на CI. Если приходится отключать, добавьте post-job шаг, который запускает docker container prune -f --filter "label=org.testcontainers=true".
Подводные камни CI
Раннеры GitHub Actions поставляются с предустановленным Docker на Linux-раннерах (ubuntu-latest), но не на macOS и Windows. Зафиксируйте Linux для SQL-контейнера или платите цену docker/setup-docker-action. Linux-агенты Microsoft в Azure DevOps работают так же; на self-hosted Windows-агентах нужен Docker Desktop с бэкендом WSL2 и образ SQL Server, совместимый с архитектурой хоста.
Ещё одно, что кусает команды, — часовой пояс и культура. Базовый образ Ubuntu — UTC; если ваши тесты сравнивают с DateTime.Now, локально они проходят, а в CI падают. Используйте DateTime.UtcNow везде или внедрите TimeProvider (встроенный в .NET 8 и новее) и подавайте детерминированное время.
Проверяем, что контейнер действительно стартовал
Если тест падает с A network-related or instance-specific error occurred, контейнер не успел подняться до того, как EF Core открыл соединение. У модуля MsSql Testcontainers встроена стратегия ожидания, которая опрашивает движок, пока он не ответит, поэтому такое случается, только если вы её заменили. Подтвердить можно так:
// peek at the dynamic host port
var port = _sql.GetMappedPublicPort(MsSqlBuilder.MsSqlPort);
Console.WriteLine($"SQL is listening on localhost:{port}");
Стратегия ожидания использует sqlcmd внутри контейнера; если в вашем образе SQL Server нет sqlcmd (старые образы), передайте .WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("/opt/mssql-tools18/bin/sqlcmd", "-Q", "SELECT 1")) для переопределения.
Где этого уже мало
Testcontainers даёт вам настоящий SQL Server. Он не даёт Always On, шардированной маршрутизации и полнотекстового поиска по нескольким файлам. Если ваша продакшен-БД — настроенный кластер, ваши интеграционные тесты гоняются против одного узла, и в наборе остаётся известный пробел в покрытии. Зафиксируйте его и пишите более узкие, целевые тесты против staging-окружения для специфики кластера, см. unit testing code that uses HttpClient для шаблона, который обрабатывает вызовы staging-API.
Чему in-memory провайдер научил поколение .NET-команд: «работает локально» — это не сигнал к деплою. Настоящая база, настоящий порт, настоящие байты на проводе, оплачено 10 секундами холодного старта. Дешёвая страховка.
По теме
- How to mock DbContext without breaking change tracking
- Removing pooled DbContextFactory for cleaner test swaps in EF Core 11
- Warm up the EF Core model before the first query
- Single-step migrations with
dotnet ef update --addin EF Core 11 - Unit-testing code that uses HttpClient
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.