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.

Advanced180–240 minutestest-strategy 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

Classify a test by the behavior it must prove before choosing a fake, SQLite, or production-like provider.

02

Keep pure ServiceHub domain decisions testable without DbContext while preserving EF integration at the boundary.

03

Identify translation, constraints, transactions, migrations, concurrency, provider functions, and query plans as database-backed behaviors.

04

Explain why mocking DbSet or IQueryable cannot prove provider translation or relational correctness.

05

Use observable SQL, tracker state, exceptions, and database state as test evidence instead of implementation-call assertions.

06

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.

Test the contract, not the class

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.

C# · pure domain policy
public sealed class EscalationPolicy{    public bool ShouldEscalate(int priority, DateTime openedUtc, DateTime nowUtc)        => priority >= 3 && nowUtc - openedUtc >= TimeSpan.FromHours(4);}
xUnit · deterministic unit test
[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.

C# · WRONG: fake query proves only LINQ-to-Objects
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

C# · relational test shape
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.

Destructive test guard

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

  1. List ten existing ServiceHub behaviors from Chapters 13–22.
  2. Mark each as pure-domain, EF-relational, or production-provider-specific and write one sentence explaining why.
  3. Add the pure EscalationPolicy test above.
  4. Add a SQLite integration test for the tenant filter and composite tenant-aware relationship.
  5. Capture the translated SQL with sensitive-data logging disabled.
  6. Record one behavior the SQLite test still cannot prove for a SQL Server/PostgreSQL deployment.
  7. 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

  1. What determines whether a test needs a real relational database?
  2. What does List.AsQueryable() prove about EF Core?
  3. When is a pure unit test preferable?
  4. Why inspect generated SQL in selected tests?
  5. Why should migration and constraint tests use disposable resources?
  6. 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

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