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.

Advanced180–240 minutesSQLite fixture 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

Create SQLite in-memory tests whose database lifetime is tied deliberately to an open SqliteConnection.

02

Use migrations for migration-sensitive tests and explain when EnsureCreated is appropriate versus misleading.

03

Choose in-memory SQLite for single-connection speed and temporary-file SQLite when multiple connections/concurrency are required.

04

Verify foreign keys, tenant filters, transactions, tracker/write behavior, and migration smoke paths cheaply.

05

Maintain an explicit list of SQLite-vs-production-provider differences for types, collations, functions, DDL, locking, and translation.

06

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.

C# · connection-owned SQLite fixture
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

C# · WRONG lifetime
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.

C# · migration-aware setup
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

xUnit · composite tenant-aware FK
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

  1. Create an in-memory SQLite fixture that keeps one connection open and applies the current migrations.
  2. Verify tenant filtering, a unique constraint, a tenant-aware FK, and one transaction rollback.
  3. Close the connection and prove a new :memory: connection no longer has the schema.
  4. Create a uniquely named temporary file database, open two contexts/connections, and run the Chapter 13 two-writer concurrency test.
  5. Record the native sqlite_version(), EF/SQLite provider 10.0.11, OS, and connection mode.
  6. 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

  1. Why must an in-memory SQLite connection stay open?
  2. Why use MigrateAsync in migration-sensitive tests?
  3. When is a temporary SQLite file preferable?
  4. Does a SQLite FK test prove SQL Server RLS?
  5. Why keep a provider-fidelity checklist?
  6. 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

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