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.
Learning outcomes
Use disposable production-like database instances when provider translation, types, DDL, locking, or plans are part of the contract.
Configure Testcontainers for .NET 4.14.0 without embedding production credentials or relying on fixed host ports.
Apply real ServiceHub migrations after container readiness and seed deterministic data through the normal model.
Isolate parallel tests with unique databases/schemas or carefully scoped fixtures rather than one shared mutable database.
Record engine image/version, provider package, topology, and cleanup behavior so failures are reproducible.
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.
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
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.
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
[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.
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
- If a Docker-compatible engine is available, start a PostgreSQL 18.6 Testcontainers instance with Testcontainers.PostgreSql 4.14.0.
- Otherwise point an environment variable at a dedicated local PostgreSQL test instance; do not use production credentials.
- Create a unique database, apply the current ServiceHub migrations, and verify no pending migrations remain.
- Run one tenant-filter query and one provider-specific translation test from Chapter 21.
- Run one composite-FK violation test and verify the provider exception.
- Record engine/provider versions and mapped host/port topology.
- 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
- What does container readiness prove?
- Why avoid fixed host ports?
- Does a separate DbContext isolate parallel tests?
- What is a non-container alternative?
- Why print engine/provider versions in tests?
- 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
- Testcontainers for .NET — container lifecycle and disposable test infrastructure
- Testcontainers PostgreSQL module — PostgreSQL builder/module usage
- Testcontainers SQL Server module — optional SQL Server container path
- Npgsql EF Core provider — PostgreSQL EF provider behavior and compatibility
- SQL Server EF Core provider — SQL Server production-like test reference
- MySQL Connector/NET EF Core — Oracle MySQL EF provider reference