diff --git a/Directory.Packages.props b/Directory.Packages.props index a0edaab80d..a775d41b51 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -66,6 +66,7 @@ + diff --git a/TUnit.Example.Asp.Net.TestProject/EfCore/EfCoreTodoApiTests.cs b/TUnit.Example.Asp.Net.TestProject/EfCore/EfCoreTodoApiTests.cs new file mode 100644 index 0000000000..3af357d9c8 --- /dev/null +++ b/TUnit.Example.Asp.Net.TestProject/EfCore/EfCoreTodoApiTests.cs @@ -0,0 +1,113 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.EntityFrameworkCore; +using TUnit.Example.Asp.Net.Models; + +namespace TUnit.Example.Asp.Net.TestProject.EfCore; + +/// +/// Integration tests for the EF Core Todo API demonstrating per-test schema isolation. +/// Each test gets its own schema within the shared PostgreSQL container. +/// Compare with which uses raw SQL with per-test table names. +/// +public class EfCoreTodoApiTests : EfCoreTodoTestBase +{ + [Test] + public async Task GetTodos_WhenEmpty_ReturnsEmptyList() + { + var client = Factory.CreateClient(); + + var response = await client.GetAsync("/ef/todos"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + var todos = await response.Content.ReadFromJsonAsync>(); + await Assert.That(todos).IsNotNull(); + await Assert.That(todos!.Count).IsEqualTo(0); + } + + [Test] + public async Task CreateTodo_ReturnsCreatedTodo() + { + var client = Factory.CreateClient(); + + var response = await client.PostAsJsonAsync("/ef/todos", new { Title = "EF Core Todo" }); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var todo = await response.Content.ReadFromJsonAsync(); + await Assert.That(todo).IsNotNull(); + await Assert.That(todo!.Title).IsEqualTo("EF Core Todo"); + await Assert.That(todo.IsComplete).IsFalse(); + } + + [Test] + public async Task CreateTodo_CanBeVerifiedViaDbContext() + { + var client = Factory.CreateClient(); + + await client.PostAsJsonAsync("/ef/todos", new { Title = "Verify via EF" }); + + // Verify directly via EF Core DbContext + await using var scope = CreateDbScope(out var dbContext); + var todo = await dbContext.Todos.SingleAsync(); + await Assert.That(todo.Title).IsEqualTo("Verify via EF"); + } + + [Test] + public async Task GetTodos_ReturnsSeededData() + { + // Seed via EF Core directly + await SeedTodosAsync("Seeded Item 1", "Seeded Item 2"); + + // Verify via API + var client = Factory.CreateClient(); + var todos = await client.GetFromJsonAsync>("/ef/todos"); + await Assert.That(todos!.Count).IsEqualTo(2); + } + + [Test] + public async Task UpdateTodo_ChangesValues() + { + var client = Factory.CreateClient(); + + // Create + var createResponse = await client.PostAsJsonAsync("/ef/todos", new { Title = "Update Me" }); + var created = await createResponse.Content.ReadFromJsonAsync(); + + // Update + var updateResponse = await client.PutAsJsonAsync( + $"/ef/todos/{created!.Id}", + new { Title = "Updated", IsComplete = true }); + + await Assert.That(updateResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + var updated = await updateResponse.Content.ReadFromJsonAsync(); + await Assert.That(updated!.Title).IsEqualTo("Updated"); + await Assert.That(updated.IsComplete).IsTrue(); + } + + [Test] + public async Task DeleteTodo_RemovesTodo() + { + var client = Factory.CreateClient(); + + // Create + var createResponse = await client.PostAsJsonAsync("/ef/todos", new { Title = "Delete Me" }); + var created = await createResponse.Content.ReadFromJsonAsync(); + + // Delete + var deleteResponse = await client.DeleteAsync($"/ef/todos/{created!.Id}"); + await Assert.That(deleteResponse.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + + // Verify gone + var getResponse = await client.GetAsync($"/ef/todos/{created.Id}"); + await Assert.That(getResponse.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test, Repeat(3)] + public async Task ParallelTests_AreIsolated() + { + // Each repeat gets its own schema - no data leaks between parallel tests + await using var scope = CreateDbScope(out var dbContext); + var count = await dbContext.Todos.CountAsync(); + await Assert.That(count).IsEqualTo(0); + } +} diff --git a/TUnit.Example.Asp.Net.TestProject/EfCore/EfCoreTodoTestBase.cs b/TUnit.Example.Asp.Net.TestProject/EfCore/EfCoreTodoTestBase.cs new file mode 100644 index 0000000000..1fa1402df4 --- /dev/null +++ b/TUnit.Example.Asp.Net.TestProject/EfCore/EfCoreTodoTestBase.cs @@ -0,0 +1,93 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using TUnit.AspNetCore; +using TUnit.Example.Asp.Net.EfCore; +using TUnit.Example.Asp.Net.Models; + +namespace TUnit.Example.Asp.Net.TestProject.EfCore; + +/// +/// Base class for EF Core integration tests with per-test schema isolation. +/// Each test gets a unique PostgreSQL schema, with tables created via +/// EF Core's EnsureCreatedAsync. +/// +[SuppressMessage("Usage", "TUnit0043:Property must use `required` keyword")] +public abstract class EfCoreTodoTestBase : WebApplicationTest +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public InMemoryPostgreSqlDatabase PostgreSql { get; init; } = null!; + + protected string SchemaName { get; private set; } = null!; + + protected override async Task SetupAsync() + { + SchemaName = GetIsolatedName("schema"); + + // Create the schema via raw SQL + await using var connection = new NpgsqlConnection(PostgreSql.Container.GetConnectionString()); + await connection.OpenAsync(); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = $"CREATE SCHEMA IF NOT EXISTS \"{SchemaName}\""; + await cmd.ExecuteNonQueryAsync(); + + // Use EF Core to create tables in the new schema + var options = new DbContextOptionsBuilder() + .UseNpgsql(PostgreSql.Container.GetConnectionString()) + .ReplaceService() + .Options; + + await using var dbContext = new TodoDbContext(options) { SchemaName = SchemaName }; + await dbContext.Database.EnsureCreatedAsync(); + } + + protected override void ConfigureTestConfiguration(IConfigurationBuilder config) + { + config.AddInMemoryCollection(new Dictionary + { + { "Database:Schema", SchemaName } + }); + } + + [After(HookType.Test)] + public async Task CleanupSchema() + { + if (string.IsNullOrEmpty(SchemaName)) + { + return; + } + + await using var connection = new NpgsqlConnection(PostgreSql.Container.GetConnectionString()); + await connection.OpenAsync(); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = $"DROP SCHEMA IF EXISTS \"{SchemaName}\" CASCADE"; + await cmd.ExecuteNonQueryAsync(); + } + + /// + /// Creates a scoped TodoDbContext for direct database access in tests. + /// The context reads Database:Schema from the per-test configuration automatically. + /// + protected AsyncServiceScope CreateDbScope(out TodoDbContext dbContext) + { + var scope = Factory.Services.CreateAsyncScope(); + dbContext = scope.ServiceProvider.GetRequiredService(); + return scope; + } + + /// + /// Seeds the database with todos directly via EF Core (bypassing the API). + /// + protected async Task SeedTodosAsync(params string[] titles) + { + await using var scope = CreateDbScope(out var dbContext); + foreach (var title in titles) + { + dbContext.Todos.Add(new Todo { Title = title }); + } + await dbContext.SaveChangesAsync(); + } +} diff --git a/TUnit.Example.Asp.Net.TestProject/EfCore/EfCoreWebApplicationFactory.cs b/TUnit.Example.Asp.Net.TestProject/EfCore/EfCoreWebApplicationFactory.cs new file mode 100644 index 0000000000..ec1cd7851c --- /dev/null +++ b/TUnit.Example.Asp.Net.TestProject/EfCore/EfCoreWebApplicationFactory.cs @@ -0,0 +1,45 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using TUnit.AspNetCore; +using TUnit.Example.Asp.Net.EfCore; + +namespace TUnit.Example.Asp.Net.TestProject.EfCore; + +/// +/// WebApplicationFactory configured with EF Core services. +/// Reuses the shared PostgreSQL container and adds DbContext registration. +/// +[SuppressMessage("Usage", "TUnit0043:Property must use `required` keyword")] +public class EfCoreWebApplicationFactory : TestWebApplicationFactory +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public InMemoryPostgreSqlDatabase PostgreSql { get; init; } = null!; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + // Remove the app's default DbContext registration (which has an empty connection string) + services.RemoveAll>(); + + // Re-register with the container's real connection string + services.AddDbContext(options => + options.UseNpgsql(PostgreSql.Container.GetConnectionString()) + .ReplaceService()); + }); + } + + protected override void ConfigureStartupConfiguration(IConfigurationBuilder configurationBuilder) + { + configurationBuilder.AddInMemoryCollection(new Dictionary + { + { "SomeKey", "SomeValue" }, + { "Database:ConnectionString", PostgreSql.Container.GetConnectionString() } + }); + } +} diff --git a/TUnit.Example.Asp.Net.TestProject/TUnit.Example.Asp.Net.TestProject.csproj b/TUnit.Example.Asp.Net.TestProject/TUnit.Example.Asp.Net.TestProject.csproj index 5d5406cef4..b01619b3de 100644 --- a/TUnit.Example.Asp.Net.TestProject/TUnit.Example.Asp.Net.TestProject.csproj +++ b/TUnit.Example.Asp.Net.TestProject/TUnit.Example.Asp.Net.TestProject.csproj @@ -18,6 +18,7 @@ + diff --git a/TUnit.Example.Asp.Net/EfCore/SchemaModelCacheKeyFactory.cs b/TUnit.Example.Asp.Net/EfCore/SchemaModelCacheKeyFactory.cs new file mode 100644 index 0000000000..b0ffc38da8 --- /dev/null +++ b/TUnit.Example.Asp.Net/EfCore/SchemaModelCacheKeyFactory.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace TUnit.Example.Asp.Net.EfCore; + +/// +/// Custom model cache key factory that includes the schema name in the cache key. +/// This ensures EF Core creates a separate model for each schema, enabling +/// per-test schema isolation without model conflicts. +/// +public class SchemaModelCacheKeyFactory : IModelCacheKeyFactory +{ + public object Create(DbContext context, bool designTime) + { + return context is TodoDbContext todoContext + ? (context.GetType(), todoContext.SchemaName, designTime) + : (object)(context.GetType(), designTime); + } +} diff --git a/TUnit.Example.Asp.Net/EfCore/TodoDbContext.cs b/TUnit.Example.Asp.Net/EfCore/TodoDbContext.cs new file mode 100644 index 0000000000..45cc9d240b --- /dev/null +++ b/TUnit.Example.Asp.Net/EfCore/TodoDbContext.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using TUnit.Example.Asp.Net.Models; + +namespace TUnit.Example.Asp.Net.EfCore; + +public class TodoDbContext : DbContext +{ + public string SchemaName { get; set; } + + public DbSet Todos => Set(); + + public TodoDbContext(DbContextOptions options, IConfiguration? config = null) + : base(options) + { + SchemaName = config?["Database:Schema"] ?? "public"; + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(SchemaName); + + modelBuilder.Entity(entity => + { + entity.HasKey(t => t.Id); + entity.Property(t => t.Id).HasColumnName("id"); + entity.Property(t => t.Title).IsRequired().HasMaxLength(200).HasColumnName("title"); + entity.Property(t => t.IsComplete).HasDefaultValue(false).HasColumnName("is_complete"); + entity.Property(t => t.CreatedAt).HasDefaultValueSql("NOW()").HasColumnName("created_at"); + entity.ToTable("todos"); + }); + } +} diff --git a/TUnit.Example.Asp.Net/Program.cs b/TUnit.Example.Asp.Net/Program.cs index 1ba58798ea..65f9317ca1 100644 --- a/TUnit.Example.Asp.Net/Program.cs +++ b/TUnit.Example.Asp.Net/Program.cs @@ -1,4 +1,7 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; using TUnit.Example.Asp.Net.Configuration; +using TUnit.Example.Asp.Net.EfCore; using TUnit.Example.Asp.Net.Models; using TUnit.Example.Asp.Net.Repositories; using TUnit.Example.Asp.Net.Services; @@ -17,6 +20,12 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); +// EF Core DbContext - reads connection string and schema from configuration. +// The DbContext constructor reads Database:Schema from IConfiguration directly. +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration["Database:ConnectionString"] ?? "") + .ReplaceService()); + var app = builder.Build(); var logger = app.Services.GetRequiredService().CreateLogger("Endpoints"); @@ -34,7 +43,7 @@ return "Hello, World!"; }); -// Todo CRUD endpoints +// Todo CRUD endpoints (raw SQL) app.MapGet("/todos", async (ITodoRepository repo) => await repo.GetAllAsync()); @@ -76,6 +85,52 @@ await cache.DeleteAsync(key) ? Results.NoContent() : Results.NotFound()); +// EF Core Todo endpoints (Code First approach alongside raw SQL above) +// TodoDbContext reads Database:Schema from IConfiguration in its constructor, +// so no manual schema wiring is needed in each endpoint. +app.MapGet("/ef/todos", async (TodoDbContext db) => + await db.Todos.OrderByDescending(t => t.CreatedAt).ToListAsync()); + +app.MapGet("/ef/todos/{id:int}", async (int id, TodoDbContext db) => + await db.Todos.FindAsync(id) is { } todo + ? Results.Ok(todo) + : Results.NotFound()); + +app.MapPost("/ef/todos", async (CreateTodoRequest request, TodoDbContext db) => +{ + var todo = new Todo { Title = request.Title }; + db.Todos.Add(todo); + await db.SaveChangesAsync(); + return Results.Created($"/ef/todos/{todo.Id}", todo); +}); + +app.MapPut("/ef/todos/{id:int}", async (int id, UpdateTodoRequest request, TodoDbContext db) => +{ + var todo = await db.Todos.FindAsync(id); + if (todo is null) + { + return Results.NotFound(); + } + + todo.Title = request.Title; + todo.IsComplete = request.IsComplete; + await db.SaveChangesAsync(); + return Results.Ok(todo); +}); + +app.MapDelete("/ef/todos/{id:int}", async (int id, TodoDbContext db) => +{ + var todo = await db.Todos.FindAsync(id); + if (todo is null) + { + return Results.NotFound(); + } + + db.Todos.Remove(todo); + await db.SaveChangesAsync(); + return Results.NoContent(); +}); + app.Run(); public partial class Program; diff --git a/TUnit.Example.Asp.Net/TUnit.Example.Asp.Net.csproj b/TUnit.Example.Asp.Net/TUnit.Example.Asp.Net.csproj index adf9850290..6894170add 100644 --- a/TUnit.Example.Asp.Net/TUnit.Example.Asp.Net.csproj +++ b/TUnit.Example.Asp.Net/TUnit.Example.Asp.Net.csproj @@ -11,6 +11,7 @@ + diff --git a/docs/docs/examples/aspnet.md b/docs/docs/examples/aspnet.md index 8045904eb2..bbe642641a 100644 --- a/docs/docs/examples/aspnet.md +++ b/docs/docs/examples/aspnet.md @@ -333,6 +333,106 @@ public abstract class TodoTestBase : TestsBase } ``` +### Per-Test Schema Isolation with EF Core + +For EF Core Code First applications, use per-test PostgreSQL schemas instead of per-test table names. This works with EF Core's model conventions and provides complete isolation: + +```csharp +// 1. DbContext with dynamic schema support +public class TodoDbContext : DbContext +{ + public string SchemaName { get; set; } + public DbSet Todos => Set(); + + // IConfiguration is optional: resolved via DI in the app, absent when + // constructing standalone (e.g. in SetupAsync for EnsureCreatedAsync). + public TodoDbContext(DbContextOptions options, IConfiguration? config = null) + : base(options) + { + SchemaName = config?["Database:Schema"] ?? "public"; + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(SchemaName); + modelBuilder.Entity(entity => + { + entity.HasKey(t => t.Id); + entity.Property(t => t.Title).IsRequired().HasMaxLength(200); + }); + } +} + +// 2. Model cache key factory (required for multiple schemas) +public class SchemaModelCacheKeyFactory : IModelCacheKeyFactory +{ + public object Create(DbContext context, bool designTime) + { + return context is TodoDbContext todoContext + ? (context.GetType(), todoContext.SchemaName, designTime) + : (object)(context.GetType(), designTime); + } +} + +// 3. Test base class with schema-per-test isolation +public abstract class EfCoreTodoTestBase : WebApplicationTest +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public InMemoryDatabase Database { get; init; } = null!; + + protected string SchemaName { get; private set; } = null!; + + protected override async Task SetupAsync() + { + SchemaName = GetIsolatedName("schema"); + + // Create schema via raw SQL + await using var connection = new NpgsqlConnection( + Database.Container.GetConnectionString()); + await connection.OpenAsync(); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = $"CREATE SCHEMA IF NOT EXISTS \"{SchemaName}\""; + await cmd.ExecuteNonQueryAsync(); + + // Create tables via EF Core + var options = new DbContextOptionsBuilder() + .UseNpgsql(Database.Container.GetConnectionString()) + .ReplaceService() + .Options; + + await using var dbContext = new TodoDbContext(options) { SchemaName = SchemaName }; + await dbContext.Database.EnsureCreatedAsync(); + } + + protected override void ConfigureTestConfiguration(IConfigurationBuilder config) + { + config.AddInMemoryCollection(new Dictionary + { + { "Database:Schema", SchemaName } + }); + } + + [After(HookType.Test)] + public async Task CleanupSchema() + { + await using var connection = new NpgsqlConnection( + Database.Container.GetConnectionString()); + await connection.OpenAsync(); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = $"DROP SCHEMA IF EXISTS \"{SchemaName}\" CASCADE"; + await cmd.ExecuteNonQueryAsync(); + } +} +``` + +**Key differences from raw SQL approach:** +- Uses `EnsureCreatedAsync()` instead of manual `CREATE TABLE` statements +- Isolation is at the **schema level** rather than the table name level +- `IModelCacheKeyFactory` ensures EF Core caches a separate model per schema +- Cleanup uses `DROP SCHEMA ... CASCADE` to remove all tables at once + +See the full working example in `TUnit.Example.Asp.Net.TestProject/EfCore/`. + ## HTTP Exchange Capture Capture and inspect HTTP requests/responses for assertions: diff --git a/docs/docs/examples/complex-test-infrastructure.md b/docs/docs/examples/complex-test-infrastructure.md index 75af949e7e..151b5a7a35 100644 --- a/docs/docs/examples/complex-test-infrastructure.md +++ b/docs/docs/examples/complex-test-infrastructure.md @@ -200,6 +200,91 @@ public class InMemoryPostgreSqlDatabase : IAsyncInitializer, IAsyncDisposable } ``` +### EF Core Code First with Per-Test Schema Isolation + +For EF Core Code First applications, use per-test PostgreSQL schemas instead of per-test table names. This avoids fighting EF Core's table naming conventions: + +```csharp +// DbContext with dynamic schema support — reads schema from IConfiguration when +// resolved via DI, falls back to "public" when constructed standalone. +public class TodoDbContext : DbContext +{ + public string SchemaName { get; set; } + public DbSet Todos => Set(); + + public TodoDbContext(DbContextOptions options, IConfiguration? config = null) + : base(options) + { + SchemaName = config?["Database:Schema"] ?? "public"; + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(SchemaName); + // ... entity configuration + } +} + +// IModelCacheKeyFactory ensures different schemas get different model caches +public class SchemaModelCacheKeyFactory : IModelCacheKeyFactory +{ + public object Create(DbContext context, bool designTime) + => context is TodoDbContext tc + ? (context.GetType(), tc.SchemaName, designTime) + : (object)(context.GetType(), designTime); +} + +// Test base: creates schema + tables in SetupAsync, drops in cleanup +public abstract class EfCoreTodoTestBase + : WebApplicationTest +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public InMemoryPostgreSqlDatabase PostgreSql { get; init; } = null!; + + protected string SchemaName { get; private set; } = null!; + + protected override async Task SetupAsync() + { + SchemaName = GetIsolatedName("schema"); // e.g. "Test_42_schema" + + // Create schema, then let EF Core create tables + await using var conn = new NpgsqlConnection( + PostgreSql.Container.GetConnectionString()); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = $"CREATE SCHEMA IF NOT EXISTS \"{SchemaName}\""; + await cmd.ExecuteNonQueryAsync(); + + var options = new DbContextOptionsBuilder() + .UseNpgsql(PostgreSql.Container.GetConnectionString()) + .ReplaceService() + .Options; + await using var db = new TodoDbContext(options) { SchemaName = SchemaName }; + await db.Database.EnsureCreatedAsync(); + } + + protected override void ConfigureTestConfiguration(IConfigurationBuilder config) + { + config.AddInMemoryCollection(new Dictionary + { + { "Database:Schema", SchemaName } + }); + } + + [After(HookType.Test)] + public async Task CleanupSchema() + { + await using var conn = new NpgsqlConnection( + PostgreSql.Container.GetConnectionString()); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = $"DROP SCHEMA IF EXISTS \"{SchemaName}\" CASCADE"; + await cmd.ExecuteNonQueryAsync(); + } +} +``` + +See the full working example in `TUnit.Example.Asp.Net.TestProject/EfCore/`. ## Comparison with Other Frameworks diff --git a/docs/docs/guides/cookbook.md b/docs/docs/guides/cookbook.md index 6721c8df26..6310fc0e93 100644 --- a/docs/docs/guides/cookbook.md +++ b/docs/docs/guides/cookbook.md @@ -621,6 +621,17 @@ public class OrderRepositoryIntegrationTests } ``` +### Testing with EF Core + TestContainers + WebApplicationFactory + +For a complete integration testing setup with EF Core Code First, TestContainers, and per-test schema isolation, see the full working example in `TUnit.Example.Asp.Net.TestProject/EfCore/`. This demonstrates: + +- **Per-test schema isolation**: Each test gets its own PostgreSQL schema via `GetIsolatedName("schema")` +- **EF Core table creation**: Uses `EnsureCreatedAsync()` to create tables from the model +- **Dynamic schema support**: Custom `IModelCacheKeyFactory` ensures correct model caching per schema +- **Clean teardown**: `DROP SCHEMA ... CASCADE` removes all tables after each test + +See the [ASP.NET Core Integration Testing](/docs/examples/aspnet#per-test-schema-isolation-with-ef-core) docs for the full pattern. + ### Testing with Test Containers (Docker) ```csharp