.NET Tutorial
Middle6 мин чтенияВопрос 5 из 10

Как тестировать EF Core: InMemory vs реальная БД vs Testcontainers?

Сравнение вариантов

InMemory provider
очень быстро
низкое доверие

SQLite in-memory
быстро
среднее доверие

Testcontainers
(та же СУБД)
секунды на старт
высокое доверие

InMemory provider
очень быстро
низкое доверие

SQLite in-memory
быстро
среднее доверие

Testcontainers
(та же СУБД)
секунды на старт
высокое доверие

Уровень доверия и скорость
Возможность InMemory SQLite Testcontainers (PostgreSQL/SQL Server)
Трансляция LINQ → SQL Нет (LINQ-to-Objects) Да, SQLite-диалект Да, продакшен-диалект
Транзакции Нет (игнорируются) Да Да
FK, UNIQUE, CHECK Нет Да Да
Регистрозависимость сравнения строк Как в C# Своя Как в проде (collation)
Raw SQL, ExecuteUpdate/Delete Нет Частично Да
Специфика СУБД (jsonb, массивы, полнотекст, FOR UPDATE) Нет Нет Да
Миграции Нет Частично (ограничения ALTER) Да, реальные
Конкурентность, rowversion Нет Ограниченно Да
Скорость Мгновенно Очень быстро Старт контейнера + быстрые запросы
Требования — — Docker

Почему InMemory обманывает

C#
// Тест проходит с InMemory, но падает на PostgreSQL:

// 1. Метод, который нельзя перевести в SQL
var users = await db.Users.Where(u => IsVip(u)).ToListAsync();      // InMemory: OK; PostgreSQL: InvalidOperationException

// 2. Регистр строк
var u = await db.Users.FirstOrDefaultAsync(x => x.Email == "ANN@X.UZ");   // InMemory: null (C# сравнение); SQL Server (CI collation): находит

// 3. Уникальный индекс
db.Users.Add(new User { Email = "a@x.uz" }); db.Users.Add(new User { Email = "a@x.uz" });
await db.SaveChangesAsync();         // InMemory: OK; PostgreSQL: unique violation

// 4. Транзакция не откатывается
await using var tx = await db.Database.BeginTransactionAsync();   // InMemory: предупреждение, изменения не откатываются

// 5. Каскадное удаление, обязательные связи, precision decimal — всё «работает» не так

Testcontainers

Фикстура с PostgreSQL
public sealed class DatabaseFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
        .WithImage("postgres:17-alpine")      // та же мажорная версия, что в проде
        .Build();

    private Respawner _respawner = null!;
    public string ConnectionString => _container.GetConnectionString();

    public async Task InitializeAsync()
    {
        await _container.StartAsync();
        await using var db = CreateContext();
        await db.Database.MigrateAsync();                       // применяем РЕАЛЬНЫЕ миграции — заодно тестируем их

        await using var conn = new NpgsqlConnection(ConnectionString);
        await conn.OpenAsync();
        _respawner = await Respawner.CreateAsync(conn, new RespawnerOptions
        {
            DbAdapter = DbAdapter.Postgres,
            TablesToIgnore = ["__EFMigrationsHistory"]
        });
    }

    public ShopDbContext CreateContext() =>
        new(new DbContextOptionsBuilder<ShopDbContext>().UseNpgsql(ConnectionString).Options);

    public async Task ResetAsync()
    {
        await using var conn = new NpgsqlConnection(ConnectionString);
        await conn.OpenAsync();
        await _respawner.ResetAsync(conn);
    }

    public Task DisposeAsync() => _container.DisposeAsync().AsTask();
}

[CollectionDefinition(nameof(DatabaseCollection))]
public sealed class DatabaseCollection : ICollectionFixture<DatabaseFixture>;
Тест запроса
[Collection(nameof(DatabaseCollection))]
public sealed class OrderQueriesTests(DatabaseFixture fx) : IAsyncLifetime
{
    public Task InitializeAsync() => fx.ResetAsync();          // чистая БД перед каждым тестом
    public Task DisposeAsync() => Task.CompletedTask;

    [Fact]
    public async Task Returns_top_customers_by_revenue()
    {
        await using (var db = fx.CreateContext())
        {
            db.Customers.AddRange(TestData.Customer("Ann"), TestData.Customer("Bob"));
            await db.SaveChangesAsync();
            db.Orders.AddRange(TestData.PaidOrder("Ann", 500), TestData.PaidOrder("Bob", 900), TestData.PaidOrder("Ann", 300));
            await db.SaveChangesAsync();
        }

        await using var readDb = fx.CreateContext();                      // новый контекст — без кэша Identity Map
        var top = await new OrderQueries(readDb).TopCustomersAsync(take: 2, default);

        top.Select(x => (x.Name, x.Revenue)).Should().Equal(("Bob", 900m), ("Ann", 800m));
    }
}
DockerDatabaseFixturexUnitloop[каждый тест]InitializeAsync (один раз наколлекцию)запустить postgres:17Migrate() — реальныемиграцииResetAsync — Respawnочищает таблицыArrange: вставкаданныхAct: вызватьзапрос/репозиторийAssert через новыйDbContextDisposeAsync — остановитьконтейнер
DockerDatabaseFixturexUnitloop[каждый тест]InitializeAsync (один раз наколлекцию)запустить postgres:17Migrate() — реальныемиграцииResetAsync — Respawnочищает таблицыArrange: вставкаданныхAct: вызватьзапрос/репозиторийAssert через новыйDbContextDisposeAsync — остановитьконтейнер
Жизненный цикл тестов с контейнером

Что тестировать на каком уровне

Доменная логика
(Order.Pay, расчёт скидки)

Unit-тесты без БД

LINQ-запросы, проекции,
репозитории, фильтры

Интеграционные тесты
на Testcontainers

Миграции, ограничения,
конкурентность, транзакции

Эндпоинт целиком

WebApplicationFactory +
Testcontainers

Доменная логика
(Order.Pay, расчёт скидки)

Unit-тесты без БД

LINQ-запросы, проекции,
репозитории, фильтры

Интеграционные тесты
на Testcontainers

Миграции, ограничения,
конкурентность, транзакции

Эндпоинт целиком

WebApplicationFactory +
Testcontainers

Разделение ответственности тестов

Ускорение

  • Один контейнер на коллекцию (или на всю сборку), а не на тест.
  • Очистка через Respawn (удаляет данные быстрым DELETE/TRUNCATE в правильном порядке FK) вместо пересоздания БД.
  • Шаблонная БД: мигрировать один раз и клонировать (CREATE DATABASE test_n TEMPLATE base в PostgreSQL) для параллельных классов.
  • Testcontainers reuse (WithReuse(true)) при локальной разработке — контейнер переживает прогоны.
  • В CI кэшировать Docker-образы.

Вопросы на засыпку

Когда SQLite in-memory — разумный компромисс?

Когда продакшен-БД — тоже SQLite (мобильные/десктопные приложения), или когда нужна проверка ограничений FK/UNIQUE без Docker, а специфичные функции СУБД не используются. Для PostgreSQL/SQL Server-проектов лучше контейнер той же СУБД.

Как тестировать миграции?

Интеграционным тестом, который применяет все миграции к пустой БД в контейнере (MigrateAsync) — ловит ошибки SQL. Плюс тест HasPendingModelChanges() (миграция не забыта). Для сложных миграций данных — тест: заполнить данными на предыдущей миграции, применить новую, проверить результат.

Можно ли мокать DbContext или DbSet?

Технически можно (Moq + IQueryable из List), но это проверяет LINQ-to-Objects, а не SQL — та же проблема, что с InMemory. Мокать стоит интерфейс репозитория в тестах прикладной логики, а сам доступ к данным тестировать на реальной БД.

Как параллелить интеграционные тесты с БД?

Разные коллекции xUnit выполняются параллельно — дайте каждой свою БД (отдельный контейнер или отдельная база в одном контейнере). Внутри коллекции тесты последовательны и очищают данные между собой.

Итог

InMemory-провайдер создаёт ложное чувство безопасности; SQLite — компромисс с другим диалектом. Для надёжных тестов доступа к данным используйте ту же СУБД в Testcontainers с реальными миграциями и очисткой через Respawn, а доменную логику держите в быстрых unit-тестах без БД.