Chapter 23 · Testing EF Core Correctly: Unit, Integration, Containers, and Migration Verification
What to Unit Test Around EF and What Requires a Real Database
Separate pure domain decisions from EF translation, relational constraints, transactions, concurrency, provider functions, and schema behavior so ServiceHub tests prove the right mechanism with the smallest faithful test environment.
Learning outcomes
Classify a test by the behavior it must prove before choosing a fake, SQLite, or production-like provider.
Keep pure ServiceHub domain decisions testable without DbContext while preserving EF integration at the boundary.
Identify translation, constraints, transactions, migrations, concurrency, provider functions, and query plans as database-backed behaviors.
Explain why mocking DbSet or IQueryable cannot prove provider translation or relational correctness.
Use observable SQL, tracker state, exceptions, and database state as test evidence instead of implementation-call assertions.
Build a practical test portfolio that is fast where it can be and faithful where it must be.
1. The first testing question is not “which mock library?”
ServiceHub now contains tenant filters, composite tenant-aware
foreign keys, an application-managed
Revision concurrency token, migrations, raw SQL
boundaries, provider-specific mappings, and transaction
behavior. A test double cannot reproduce all of those
mechanisms. The useful question is:
what observable behavior must this test prove?
A unit test runs a small unit of application/domain logic without the database. An integration test exercises EF plus a relational provider and database. A provider-fidelity test specifically runs against the same engine/provider family used in production when translation, DDL, locking, type semantics, or plans matter.
A fast unit test is excellent for a pure business decision. It is weak evidence for a LINQ query, a unique index, a migration, or a concurrency race because those contracts belong to EF/provider/database layers.
2. Pure domain logic belongs outside EF and stays cheap to test
Suppose ServiceHub escalates a work order when its priority is high and its age exceeds a policy threshold. This decision does not need SQL, identity resolution, or a change tracker. Keep the decision in a pure type and unit-test its edge cases directly.
public sealed class EscalationPolicy{ public bool ShouldEscalate(int priority, DateTime openedUtc, DateTime nowUtc) => priority >= 3 && nowUtc - openedUtc >= TimeSpan.FromHours(4);}
[Theory][InlineData(3, 5, true)][InlineData(2, 8, false)][InlineData(4, 2, false)]public void Escalation_rule_is_pure(int priority, int ageHours, bool expected){ var now = new DateTime(2026, 8, 27, 12, 0, 0, DateTimeKind.Utc); var result = new EscalationPolicy().ShouldEscalate( priority, now.AddHours(-ageHours), now); Assert.Equal(expected, result);}
This test is fast because the contract is pure. Adding EF to it would increase cost without adding evidence.
3. Some contracts only exist after EF translates and a database executes
| Behavior | Why a real relational provider matters | Useful evidence |
|---|---|---|
| LINQ translation | Expression trees become provider SQL, functions, parameters and null/case semantics |
ToQueryString(), command logs, returned
rows
|
| Unique/FK/check constraints | The database—not the CLR list—enforces them |
DbUpdateException, constraint name,
database state
|
| Transactions/savepoints | Isolation, locking and rollback are engine/provider capabilities | second connection, committed rows, transaction logs |
| Optimistic concurrency | EF appends original tokens to DML and checks affected rows |
generated UPDATE/DELETE +
DbUpdateConcurrencyException
|
| Migrations | DDL and rebuild/rename semantics are provider-specific | migration SQL, history table, preserved data |
| Performance/plans | Indexes, cardinality and server work are database phenomena | plan, rows, reads, duration under disclosed topology |
Chapter 23 does not replace unit tests with integration tests. It puts each behavior at the cheapest layer that can actually prove it.
4. Deliberate failure: a mocked IQueryable is not an EF provider
A common shortcut is to expose
IQueryable<WorkOrder>, return
List<WorkOrder>.AsQueryable() in tests, and
conclude that the production LINQ works. The test executes
LINQ-to-Objects delegates; no EF translation, SQL
parameterization, collation, global filter, provider function,
or database constraint is involved.
IQueryable<WorkOrder> fake = seededWorkOrders.AsQueryable();var rows = fake .Where(w => w.Summary.Contains(term)) .OrderBy(w => w.Id) .Take(20) .ToList();Assert.NotEmpty(rows); // says nothing about EF/provider translation
A safer seam is to unit-test business decisions above data access and separately integration-test the EF query against the relevant provider. If an application deliberately uses a repository abstraction, mock the repository’s result contract, not EF’s query provider.
5. Make the EF integration test assert external behavior
await using var db = fixture.CreateContext("tenant-a");var sql = db.WorkOrders .Where(w => w.Priority >= 3) .OrderBy(w => w.Id) .Select(w => new { w.Id, w.WorkOrderNumber }) .ToQueryString();Assert.Contains("tenant_id", sql, StringComparison.OrdinalIgnoreCase);var rows = await db.WorkOrders .Where(w => w.Priority >= 3) .OrderBy(w => w.Id) .ToListAsync();Assert.All(rows, row => Assert.Equal("tenant-a", row.TenantId));
The first assertion checks translation structure relevant to tenant defense. The result assertion checks runtime behavior. For a production provider, add provider-specific checks only where that SQL shape is part of the contract; avoid snapshotting every whitespace/alias detail.
6. Keep tests isolated from production credentials and data
Test databases must be disposable and unmistakably non-production. Use a temporary SQLite file, an in-memory SQLite connection, a dedicated local developer database, or a throwaway container. Never point tests at a production hostname, shared customer schema, or a secret copied from production configuration.
Before migration rollback, DROP/DELETE, concurrency races, or bulk cleanup, verify the connection target is a disposable test resource. Prefer generated names and credentials so a test cannot accidentally target a long-lived database.
7. ServiceHub testing portfolio
A practical portfolio for this course is layered rather than ideological:
| Layer | Examples | Typical runtime |
|---|---|---|
| Pure unit | escalation/merge/authorization decision objects | no EF/database |
| SQLite lightweight | basic relational constraints, filters, tracker/write flow, migration smoke tests | in-process/local file |
| Production-like provider | SQL translation, provider types/functions, DDL, locks/retries, real migrations | local instance/container/CI service |
| Targeted performance/reliability | plans, query counts, upgrade paths, two-writer races | controlled provider-specific environment |
8. Mandatory lab: classify first, then implement two layers
- List ten existing ServiceHub behaviors from Chapters 13–22.
- Mark each as pure-domain, EF-relational, or production-provider-specific and write one sentence explaining why.
- Add the pure
EscalationPolicytest above. - Add a SQLite integration test for the tenant filter and composite tenant-aware relationship.
- Capture the translated SQL with sensitive-data logging disabled.
- Record one behavior the SQLite test still cannot prove for a SQL Server/PostgreSQL deployment.
- Delete the disposable test database.
9. Production judgment and bridge
The correct testing strategy minimizes false confidence, not
merely test duration. Unit-test pure logic aggressively, but do
not replace the database when the database is the contract.
Microsoft’s EF testing guidance specifically recommends good
coverage against the production database system and discourages
mocking DbSet for query behavior. Lesson 2 shows
why the built-in InMemory provider is an especially weak
relational substitute.
Check your understanding
- What determines whether a test needs a real relational database?
-
What does List
.AsQueryable() prove about EF Core? - When is a pure unit test preferable?
- Why inspect generated SQL in selected tests?
- Why should migration and constraint tests use disposable resources?
- What is the core test-portfolio principle?
Review the answers
1. Whether the behavior under test depends on translation, relational constraints, transactions, concurrency, provider functions/types, DDL, locking, or plans.
2. Only LINQ-to-Objects behavior; it does not invoke EF translation or a relational provider.
3. When the contract is application/domain logic that can be expressed without EF or database state.
4. It can prove important translation structure such as tenant predicates, parameters, or provider-specific operators without pretending SQL text alone proves runtime performance.
5. They can perform destructive schema/data operations and must never endanger long-lived or production data.
6. Use the cheapest environment that faithfully implements the behavior being verified, then add production-like tests wherever provider/database semantics matter.
Authoritative references
- Choosing a testing strategy — Microsoft guidance on production-database tests, SQLite, InMemory, repositories, and mocked DbSet limitations
- Testing EF Core applications — official EF Core testing entry point
- Testing without the production database — test-double guidance and SQLite/InMemory examples
- EF Core concurrency — database-backed optimistic-concurrency behavior that unit fakes cannot prove
- EF Core transactions — transaction/savepoint behavior requiring relational execution
- EF Core performance — evidence-based performance testing and database-plan considerations