Why Your EF Core Tests Are Lying to You (And How SQLite In-Memory Fixes It)

Table Of Contents
Introduction
Picture this: You’ve written a comprehensive test suite for your .NET application. All green. CI passes. You deploy with confidence. Then, at 2 AM, your phone buzzes—production is throwing foreign key constraint violations that your tests never caught.
If you’ve been using Entity Framework Core’s InMemory provider for testing, you’ve likely experienced this nightmare. The truth is, EF Core’s InMemory provider doesn’t behave like a real database—and that gap can silently introduce bugs that only surface in production.
Let’s explore why this happens and how a simple SQLite in-memory connection can save your sanity.
The Problem: InMemory Provider’s Hidden Limitations
When Microsoft introduced Microsoft.EntityFrameworkCore.InMemory, it seemed like the perfect solution for fast, isolated tests. No database setup, no cleanup, blazing fast execution. What’s not to love?
Quite a lot, actually.
What InMemory Doesn’t Do
Here’s the uncomfortable truth about EF Core’s InMemory provider:
| Feature | Real Database | InMemory Provider |
|---|---|---|
| Foreign Key Validation | ✅ Enforced | ❌ Ignored |
| Unique Constraints | ✅ Enforced | ❌ Ignored |
| Cascade Delete | ✅ Works | ❌ Doesn’t work |
| Transactions | ✅ Full ACID | ❌ Not supported |
| Raw SQL Queries | ✅ Supported | ❌ Throws exceptions |
| Computed Columns | ✅ Calculated | ❌ Returns null |
| Default Values | ✅ Applied | ⚠️ Partially works |
A Real-World Example
Consider this innocent-looking test:
[Fact]public void CreateOrder_ShouldSucceed(){// Arrangevar options = new DbContextOptionsBuilder<AppDbContext>().UseInMemoryDatabase("TestDb").Options;using var context = new AppDbContext(options);// Act - Creating an order for a user that doesn't exist!var order = new Order{UserId = 999, // This user doesn't existTotal = 100.00m};context.Orders.Add(order);context.SaveChanges(); // This succeeds with InMemory!// AssertAssert.Equal(1, context.Orders.Count()); // ✅ Test passes!}
This test passes with flying colors. But in production, with a real database? You’ll get:
SqlException: The INSERT statement conflicted with the FOREIGN KEY constraint"FK_Orders_Users_UserId". The conflict occurred in database "Production",table "dbo.Users", column 'Id'.
Your tests gave you false confidence. The InMemory provider happily accepted an order referencing a non-existent user because it simply doesn’t check foreign key constraints.
The Solution: SQLite In-Memory Database
SQLite offers an elegant middle ground: real relational database behavior with in-memory speed. The magic happens with a single connection string:
var connection = new SqliteConnection("Data Source=:memory:");
This creates a private, in-memory database that:
- Enforces foreign key constraints (when enabled)
- Validates unique constraints
- Supports transactions
- Handles cascade deletes properly
- Runs entirely in memory (no disk I/O)
- Disappears when the connection closes (automatic cleanup)
The Critical Connection Lifecycle
Here’s the catch with SQLite in-memory databases: the database only exists while the connection is open. Close the connection, and everything vanishes. This is actually perfect for testing—guaranteed isolation between tests—but you need to manage the connection carefully.
public class SqliteTestFixture : IDisposable{private readonly SqliteConnection _connection;public AppDbContext Context { get; }public SqliteTestFixture(){// Create and OPEN the connection - this creates the database_connection = new SqliteConnection("Data Source=:memory:");_connection.Open(); // Critical! Database exists only while openvar options = new DbContextOptionsBuilder<AppDbContext>().UseSqlite(_connection).Options;Context = new AppDbContext(options);Context.Database.EnsureCreated(); // Create schema}public void Dispose(){Context.Dispose();_connection.Dispose(); // Database is destroyed here}}
Foreign Keys Are Enabled by Default
Good news: when using Microsoft.Data.Sqlite (which EF Core uses), foreign key enforcement is enabled by default. The bundled e_sqlite3 native library is compiled with SQLITE_DEFAULT_FOREIGN_KEYS=1, so you don’t need to manually run PRAGMA foreign_keys = ON.
This means your tests will automatically catch referential integrity violations—no extra configuration needed.
Complete Implementation: Building a Proper Test Base
Here’s a production-ready implementation for xUnit:
using Microsoft.Data.Sqlite;using Microsoft.EntityFrameworkCore;using System;public abstract class SqliteTestBase<TContext> : IDisposablewhere TContext : DbContext{private readonly SqliteConnection _connection;protected TContext Db { get; }protected SqliteTestBase(){// Create in-memory SQLite connection_connection = new SqliteConnection("Data Source=:memory:");_connection.Open();// Build DbContext with SQLite provider// Note: Foreign keys are enabled by default in Microsoft.Data.Sqlitevar options = new DbContextOptionsBuilder<TContext>().UseSqlite(_connection).EnableSensitiveDataLogging() // Helpful for debugging.EnableDetailedErrors().Options;Db = CreateContext(options);Db.Database.EnsureCreated();// Optional: Seed test dataSeed(Db);Db.SaveChanges();}protected abstract TContext CreateContext(DbContextOptions<TContext> options);protected virtual void Seed(TContext context) { }public void Dispose(){Db?.Dispose();_connection?.Dispose();}}
Using the Test Base
public class OrderTests : SqliteTestBase<AppDbContext>{protected override AppDbContext CreateContext(DbContextOptions<AppDbContext> options) => new(options);protected override void Seed(AppDbContext context){// Seed required test datacontext.Users.Add(new User { Id = 1, Name = "John Doe" });}[Fact]public void CreateOrder_WithValidUser_Succeeds(){// Arrange & ActDb.Orders.Add(new Order { UserId = 1, Total = 100.00m });Db.SaveChanges();// AssertAssert.Equal(1, Db.Orders.Count());}[Fact]public void CreateOrder_WithInvalidUser_ThrowsException(){// Arrangevar order = new Order { UserId = 999, Total = 100.00m }; // User doesn't existDb.Orders.Add(order);// Act & Assert - Now this correctly fails!Assert.Throws<DbUpdateException>(() => Db.SaveChanges());}[Fact]public void DeleteUser_WithOrders_CascadeDeletes(){// ArrangeDb.Orders.Add(new Order { UserId = 1, Total = 50.00m });Db.SaveChanges();// Actvar user = Db.Users.Find(1)!;Db.Users.Remove(user);Db.SaveChanges();// Assert - Order should be cascade deletedAssert.Empty(Db.Orders);}}
Performance Considerations
You might wonder: “Does SQLite add significant overhead compared to InMemory?”
Here’s the reality based on typical benchmarks:
| Operation | InMemory | SQLite In-Memory | Real SQL Server |
|---|---|---|---|
| Setup | ~30 μs | ~1.1 ms | ~26 ms |
| Insert 1 entity | ~55 μs | ~1.3 ms | ~28 ms |
| Insert 10 entities | ~100 μs | ~1.7 ms | ~27 ms |
| Insert 100 entities | ~550 μs | ~4.7 ms | ~30 ms |
Yes, SQLite in-memory is roughly 8-10x slower than InMemory for simple operations. But consider:
- It’s still blazing fast - milliseconds, not seconds
- It catches real bugs - saving hours of production debugging
- It’s 23x faster than disk-based SQLite - you get real behavior without real disk I/O
The small performance cost is a worthwhile trade-off for tests you can actually trust.
The Easy Way: EfCore.TestBed
If you want all of these benefits without building the infrastructure yourself, check out EfCore.TestBed—a NuGet package that wraps all of this functionality in a clean, easy-to-use API.
Installation
dotnet add package EfCore.TestBed
NuGet: https://www.nuget.org/packages/EfCore.TestBed/
Quick Start
Option 1: Inherit from EfTestBase (Recommended)
using EfCore.TestBed.Core;public class OrderTests : EfTestBase<AppDbContext>{protected override void Seed(AppDbContext context){context.Users.Add(new User { Id = 1, Name = "John" });}[Fact]public void CreateOrder_WithValidUser_Succeeds(){// Db is automatically configured and seeded!Db.Orders.Add(new Order { UserId = 1 });Db.SaveChanges();Assert.Equal(1, Db.Orders.Count());}[Fact]public void CreateOrder_WithInvalidUser_Fails(){Db.Orders.Add(new Order { UserId = 999 });// Real FK validation!Assert.Throws<DbUpdateException>(() => Db.SaveChanges());}}
Option 2: Factory Method for Quick Tests
using EfCore.TestBed.Factory;[Fact]public void QuickTest(){using var db = TestDb.Create<AppDbContext>(ctx =>{ctx.Users.Add(new User { Name = "Test" });});Assert.Equal(1, db.Context.Users.Count());}
Option 3: One-Liner for Super Quick Tests
[Fact]public void SuperQuickTest(){using var db = TestDb.Quick<AppDbContext>();db.Users.Add(new User { Name = "John" });db.SaveChanges();Assert.Single(db.Users);}
Bonus Features
EfCore.TestBed includes additional utilities that make testing even easier:
Fluent Assertions:
using EfCore.TestBed.Extensions;Db.ShouldHave<User>(u => u.Name == "John");Db.ShouldNotHave<Order>(o => o.Status == "Cancelled");Db.ShouldHaveCount<User>(5);Db.ShouldSaveSuccessfully();Db.ShouldFailOnSave<DbUpdateException>();
Easy Seeding:
using EfCore.TestBed.Seeding;// Seed with factory patternvar products = Db.SeedMany(10, i => new Product{Name = $"Product {i}",Price = 9.99m * i});// Fluent seedingDb.Seed().Add(new User { Name = "John" }).Add(5, i => new Order { Total = i * 10 }).Build();
Transaction Support:
using EfCore.TestBed.Transactions;// Auto-rollback after testDb.InRollbackTransaction(ctx =>{ctx.Users.Add(new User { Name = "Temp" });ctx.SaveChanges();// Automatically rolled back!});
When to Use What
Here’s a decision guide for choosing your testing approach:
| Use Case | Recommendation |
|---|---|
| Unit tests for pure business logic | Mock the DbContext entirely |
| Integration tests with data access | SQLite In-Memory |
| Tests requiring FK/constraint validation | SQLite In-Memory |
| Tests with raw SQL queries | SQLite In-Memory or real database |
| Performance-critical test suites (10,000+ tests) | Consider InMemory for speed, SQLite for accuracy |
| End-to-end tests | Real database (SQL Server, PostgreSQL, etc.) |
Key Takeaways
EF Core’s InMemory provider doesn’t validate constraints - Tests can pass while hiding real bugs.
SQLite in-memory provides real database behavior - Foreign keys, unique constraints, transactions, and cascade deletes all work correctly.
The connection string is simple:
"Data Source=:memory:"Keep the connection open - The database only exists while the connection is alive.
Foreign keys are enabled by default - When using
Microsoft.Data.Sqlite, no PRAGMA needed.Consider EfCore.TestBed - It handles all the boilerplate and provides useful testing utilities.
Resources
- EfCore.TestBed GitHub: https://github.com/ShadyNagy/EfCore.TestBed
- EfCore.TestBed NuGet: https://www.nuget.org/packages/EfCore.TestBed/
- Microsoft SQLite Provider Docs: EF Core SQLite Provider
- SQLite PRAGMA Reference: SQLite Foreign Keys
Conclusion
The next time you’re setting up tests for an EF Core application, resist the temptation to reach for the InMemory provider. That extra millisecond of test execution time is a small price to pay for tests that actually catch the bugs you care about.
Your future self—the one who doesn’t get paged at 2 AM—will thank you.
Feedback and Questions
Your insights drive us! For any questions, feedback, or thoughts, feel free to connect:
- Email: shady@shadynagy.com
- Twitter: @ShadyNagy_
- LinkedIn: Shady Nagy
- GitHub: ShadyNagy
If you found this guide beneficial, don’t hesitate to share it with your network. Until the next guide, happy coding!







