
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.
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.
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 |
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.
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:
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}}
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.
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();}}
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);}}
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:
The small performance cost is a worthwhile trade-off for tests you can actually trust.
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.
dotnet add package EfCore.TestBed
NuGet: https://www.nuget.org/packages/EfCore.TestBed/
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());}}
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());}
[Fact]public void SuperQuickTest(){using var db = TestDb.Quick<AppDbContext>();db.Users.Add(new User { Name = "John" });db.SaveChanges();Assert.Single(db.Users);}
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!});
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.) |
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.
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.
Your insights drive us! For any questions, feedback, or thoughts, feel free to connect:
If you found this guide beneficial, don’t hesitate to share it with your network. Until the next guide, happy coding!
Quick Links
Legal Stuff




