Chapter 23 · Testing EF Core Correctly: Unit, Integration, Containers, and Migration Verification

Run Integration Tests Against Production-Like Engines with Containers and Isolated Databases

Run ServiceHub migrations and integration tests against disposable production-like PostgreSQL, SQL Server, or MySQL instances with Testcontainers, deterministic readiness, isolated databases, safe credentials, parallel-test boundaries, and a non-container fallback.

Advanced180–240 minutesproduction-like container labEF Core 10.0.11 · Microsoft.EntityFrameworkCore.Sqlite/InMemory 10.0.11 · dotnet-ef 10.0.11 · .NET 10.0.11 · SDK 10.0.400Mandatory free local SQLite path · Testcontainers 4.14.0 / production-like providers optional where Docker/local engine is availableTesting/package/provider status reviewed: August 27, 2026

Learning outcomes

01

Use disposable production-like database instances when provider translation, types, DDL, locking, or plans are part of the contract.

02

Configure Testcontainers for .NET 4.14.0 without embedding production credentials or relying on fixed host ports.

03

Apply real ServiceHub migrations after container readiness and seed deterministic data through the normal model.

04

Isolate parallel tests with unique databases/schemas or carefully scoped fixtures rather than one shared mutable database.

05

Record engine image/version, provider package, topology, and cleanup behavior so failures are reproducible.

06

Provide a local installed-database fallback when Docker/container execution is unavailable.

1. Production-like does not mean production data

A provider integration test should run the same EF provider and database-engine family that production uses, but against a throwaway database. Containers are useful because they package a real engine with deterministic startup/cleanup. They are not mandatory infrastructure: a dedicated local PostgreSQL/SQL Server/MySQL instance can serve the same purpose if the test creates isolated databases and never reuses production credentials.

2. Current free-local container tooling baseline

As of August 27, 2026, Testcontainers for .NET 4.14.0 is the current stable line, including PostgreSQL, Microsoft SQL Server and MySQL modules. The library is MIT-licensed; database images retain their own licenses. For this chapter, PostgreSQL is the clearest open-source production-like demonstration. The fixture syntax below uses xUnit-style lifecycle hooks; if the course test project uses another runner, keep the container/context lifecycle semantics and adapt only the test-runner integration.

Test project · optional container packages
dotnet add package Testcontainers.PostgreSql --version 4.14.0dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL --version 10.0.3# Optional provider matricesdotnet add package Testcontainers.MsSql --version 4.14.0dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 10.0.11dotnet add package Testcontainers.MySql --version 4.14.0dotnet add package MySql.EntityFrameworkCore --version 10.0.9

3. A PostgreSQL fixture owns the container lifecycle

C# · Testcontainers PostgreSQL fixture
public sealed class PostgreSqlFixture : IAsyncLifetime{    private readonly PostgreSqlContainer _container =        new PostgreSqlBuilder("postgres:18.6")            .WithDatabase("servicehub_tests")            .WithUsername("servicehub_test")            .WithPassword("local-test-only")            .Build();    public string ConnectionString => _container.GetConnectionString();    public async ValueTask InitializeAsync()    {        await _container.StartAsync();        await using var db = CreateContext(ConnectionString, "tenant-a");        await db.Database.MigrateAsync();        await SeedDeterministically(db);    }    public ValueTask DisposeAsync() => _container.DisposeAsync();}

The password is disposable test configuration, not a copied secret. Testcontainers maps a random host port by default; do not force a shared fixed port unless the environment explicitly requires it.

4. Readiness has two layers: engine ready, schema ready

StartAsync waits according to the module’s container readiness behavior. That only means the database process accepts connections. The application schema is ready after the test applies migrations and verifies the expected migration history. Treat those as separate checkpoints.

C# · schema readiness probe
await using var db = CreateContext(fixture.ConnectionString, "tenant-a");await db.Database.MigrateAsync();var pending = await db.Database.GetPendingMigrationsAsync();Assert.Empty(pending);var engine = await db.Database.SqlQueryRaw<string>("SELECT version() AS "Value"")    .SingleAsync();_output.WriteLine(engine);

5. Parallel tests need data isolation, not merely separate DbContexts

Two contexts pointing at the same mutable test database can race through seed/delete operations. Choose one isolation strategy deliberately:

Strategy Benefit Cost
Container per test/class strongest physical isolation startup/resource cost
Shared container + database per test good isolation, amortized server startup database create/drop lifecycle
Shared DB + schema per test fast where provider supports schemas schema-qualified migrations/model complexity
Shared DB + transaction rollback fast for compatible tests does not isolate DDL/background connections; provider-specific

ServiceHub migration, concurrency and retry tests should not silently share mutable rows. Generate database/schema names from a safe test identifier and clean them deterministically.

6. Deliberate failure: one shared database with delete-all cleanup

C# · WRONG under parallel test execution
[TestCleanup]public Task ResetAsync() => db.Database.ExecuteSqlRawAsync(    "DELETE FROM work_orders; DELETE FROM technicians;");

One test can delete rows while another is asserting them; identity/sequence state, migrations, background connections and dependent tables can also survive. The repair is resource isolation first, then bounded cleanup within that resource.

7. Provider matrix without container lock-in

The same test project can run a provider matrix behind environment flags: PostgreSQL container in local/CI, SQL Server container where the image/license/platform is acceptable, MySQL container for Oracle provider compatibility, or a dedicated installed database supplied by an environment variable. The test code should print the provider/engine version and skip with an explicit reason when an optional environment is unavailable—not silently fall back to SQLite and call it equivalent.

Example CI matrix
servicehub-sqlite        always        fast relational baselineservicehub-postgresql    nightly/PR    Npgsql 10.0.3 + PostgreSQL 18.6servicehub-sqlserver     nightly/PR    SQL Server provider 10.0.11 + approved imageservicehub-mysql         nightly       MySql.EntityFrameworkCore 10.0.9 + MySQL 8.4 LTS

8. Mandatory lab: production-like PostgreSQL with a no-container fallback

  1. If a Docker-compatible engine is available, start a PostgreSQL 18.6 Testcontainers instance with Testcontainers.PostgreSql 4.14.0.
  2. Otherwise point an environment variable at a dedicated local PostgreSQL test instance; do not use production credentials.
  3. Create a unique database, apply the current ServiceHub migrations, and verify no pending migrations remain.
  4. Run one tenant-filter query and one provider-specific translation test from Chapter 21.
  5. Run one composite-FK violation test and verify the provider exception.
  6. Record engine/provider versions and mapped host/port topology.
  7. Drop the unique database and dispose the container/connection.

9. Production judgment and bridge

Containers make production-like tests reproducible; they do not remove the need to manage data isolation, image licensing, CI resources, migrations, secrets, or provider versions. Keep a small focused matrix that targets contracts SQLite cannot prove. Lesson 5 turns those environments into reliability suites for upgrades, concurrency, transactions, retry behavior, query counts and performance regressions.

Check your understanding

  1. What does container readiness prove?
  2. Why avoid fixed host ports?
  3. Does a separate DbContext isolate parallel tests?
  4. What is a non-container alternative?
  5. Why print engine/provider versions in tests?
  6. Why should optional provider tests not silently fall back to SQLite?
Review the answers

1. That the engine is accepting connections; schema readiness still requires migrations/setup verification.

2. Parallel jobs and local services can collide; dynamic port mapping gives each disposable resource its own endpoint.

3. No. Contexts can still mutate the same underlying database rows/schema.

4. A dedicated local/CI database server where tests create uniquely named disposable databases and use test-only credentials.

5. To make failures reproducible and prevent a green test on one version being silently generalized to another.

6. That would change the behavior under test and produce false evidence about provider-specific translation, DDL, locks, or types.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n