Chapter 23 · Testing EF Core Correctly: Unit, Integration, Containers, and Migration Verification
Use SQLite Intentionally for Lightweight Tests and Understand Provider-Behavior Differences
Build fast SQLite in-memory and temporary-file fixtures with correct connection lifetime, migrations, constraints, and cleanup while preserving a provider-fidelity checklist for behavior that must still be tested on SQL Server, PostgreSQL, or MySQL.
Learning outcomes
Create SQLite in-memory tests whose database lifetime is tied deliberately to an open SqliteConnection.
Use migrations for migration-sensitive tests and explain when EnsureCreated is appropriate versus misleading.
Choose in-memory SQLite for single-connection speed and temporary-file SQLite when multiple connections/concurrency are required.
Verify foreign keys, tenant filters, transactions, tracker/write behavior, and migration smoke paths cheaply.
Maintain an explicit list of SQLite-vs-production-provider differences for types, collations, functions, DDL, locking, and translation.
Avoid treating a passing SQLite test as proof of SQL Server/PostgreSQL/MySQL correctness.
1. SQLite is a real relational engine—but still a different engine
SQLite gives a lightweight test a real SQL parser, constraints, transactions, indexes and provider translation. That makes it far more useful than EF InMemory for relational behavior. But Chapter 20 already established that SQLite has dynamic typing, no schemas/sequences, different DDL, different date/decimal semantics, different collations/functions, and a single-file locking model. Use it for the contracts it actually implements.
2. In-memory SQLite exists only while at least one connection stays open
The connection string Data Source=:memory: creates
an in-memory database associated with that connection. If a
fixture opens a connection, creates/migrates the database,
disposes it, and then constructs a new connection for the test,
the schema is gone.
public sealed class SqliteServiceHubFixture : IAsyncDisposable{ private readonly SqliteConnection _connection = new("Data Source=:memory:"); public async Task InitializeAsync() { await _connection.OpenAsync(); await using var db = CreateContext("tenant-a"); await db.Database.MigrateAsync(); await SeedAsync(db); } public ServiceHubContext CreateContext(string tenantId) { var options = new DbContextOptionsBuilder<ServiceHubContext>() .UseSqlite(_connection) .EnableDetailedErrors() .Options; var db = new ServiceHubContext(options); db.AssignTenant(tenantId); return db; } public async ValueTask DisposeAsync() => await _connection.DisposeAsync();}
This uses the Chapter 22 options-only constructor and overwrites
tenant state through AssignTenant for every test
context. The fixture therefore exercises the same production
model and tenant filter instead of maintaining a simplified
test-only model.
3. Deliberate failure: close the connection and the database disappears
await using (var setup = new SqliteConnection("Data Source=:memory:")){ await setup.OpenAsync(); // migrate/create schema} // database is destroyed hereawait using var test = new SqliteConnection("Data Source=:memory:");await test.OpenAsync();// SELECT -> "no such table"
The repair is either one long-lived fixture connection or a shared-memory URI with deliberate keeper-connection semantics. The simple course path uses one open connection per fixture.
4. Use migrations when the migration lifecycle is part of the contract
EnsureCreated is valid for prototypes and some
isolated tests, but it bypasses the normal migrations
history/snapshot path. A migration smoke test should call
MigrateAsync against a fresh disposable database
and then assert schema/data invariants.
await using var db = fixture.CreateContext("tenant-a");await db.Database.MigrateAsync();var applied = await db.Database.GetAppliedMigrationsAsync();Assert.Contains(applied, m => m.Contains("AddTenantLifecycleColumns"));
If the test only needs model-created tables and explicitly does
not test migrations, EnsureCreated can be
documented as a test convenience—but do not mix it into a normal
migration database and call that migration verification.
5. In-memory versus temporary-file SQLite
| Need | In-memory SQLite | Temporary file |
|---|---|---|
| Fast single-connection relational tests | Excellent | Good |
| Multiple independent connections | Requires shared-memory/keeper design | Natural |
| Locking/concurrency behavior | Awkward on one connection | Better representation |
| Inspect DB after failure | Gone when keeper closes | File can be retained on failure |
| Cleanup | dispose connection | delete generated file |
6. Foreign-key and tenant-defense verification
await using var db = fixture.CreateContext("tenant-a");// Seed a tenant-a WorkOrder and a tenant-b Technician.// Then deliberately create the cross-tenant relationship in a low-level test path.var ex = await Assert.ThrowsAsync<DbUpdateException>( () => db.SaveChangesAsync());Assert.Contains("FOREIGN KEY", ex.InnerException?.Message ?? ex.Message, StringComparison.OrdinalIgnoreCase);
This is strong evidence for the SQLite constraint created by the Chapter 22 mapping. It still does not prove SQL Server row-level security, PostgreSQL collations, or MySQL generated-column behavior.
7. Maintain a provider-fidelity checklist beside SQLite tests
| Area | SQLite result is useful for… | Still test production provider for… |
|---|---|---|
| LINQ | basic translation and parameterization | provider functions, APPLY/lateral, JSON/date/string details |
| Types | round-trip values supported by SQLite mapping | precision, server types, arrays/ranges/native JSON, unsigned behavior |
| Collation | declared SQLite collation behavior | production collation/case/accent semantics |
| DDL | SQLite migration/rebuild path | schemas, sequences, included indexes, online DDL, provider rename behavior |
| Concurrency | application Revision token | rowversion/xmin, provider locks/deadlocks/retries |
8. Mandatory lab: one in-memory fixture, one file fixture
- Create an in-memory SQLite fixture that keeps one connection open and applies the current migrations.
- Verify tenant filtering, a unique constraint, a tenant-aware FK, and one transaction rollback.
-
Close the connection and prove a new
:memory:connection no longer has the schema. - Create a uniquely named temporary file database, open two contexts/connections, and run the Chapter 13 two-writer concurrency test.
-
Record the native
sqlite_version(), EF/SQLite provider 10.0.11, OS, and connection mode. - Delete the temporary file in cleanup.
9. Production judgment and bridge
SQLite is the course’s mandatory free/local relational test path because it is fast and real, not because it is identical to every server engine. Keep a production-provider test suite for behaviors where engine/provider differences matter. Lesson 4 builds that suite using disposable production-like database instances and explicit isolation.
Check your understanding
- Why must an in-memory SQLite connection stay open?
- Why use MigrateAsync in migration-sensitive tests?
- When is a temporary SQLite file preferable?
- Does a SQLite FK test prove SQL Server RLS?
- Why keep a provider-fidelity checklist?
- What is the safe cleanup boundary?
Review the answers
1. The database lifetime is tied to the connection; closing the last owning connection destroys the in-memory database.
2. It exercises the real migration history/DDL path rather than bypassing it with EnsureCreated.
3. When tests need multiple connections, more realistic locking/concurrency, or a database artifact that can be inspected after failure.
4. No. It proves the SQLite relational constraint only; RLS is a separate SQL Server database-security feature.
5. To make explicit which SQLite results are portable evidence and which production-provider behaviors still need their own tests.
6. Only the uniquely generated in-memory/temp-file test resource, never a shared or production database.
Authoritative references
- SQLite in-memory testing — EF Core guidance for keeping the SQLite connection open
- Microsoft.Data.Sqlite in-memory databases — connection lifetime and shared-cache considerations
- SQLite provider limitations — type, schema, sequence, migration, and query limitations
- EF Core migrations — migration lifecycle and applied history
- EF Core concurrency — two-context optimistic concurrency behavior
- Choosing a testing strategy — why SQLite is still a database fake for other production providers