Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
<PackageVersion Include="MSTest.TestAdapter" Version="4.1.0" />
<PackageVersion Include="MSTest.TestFramework" Version="4.1.0" />
<PackageVersion Include="Npgsql" Version="10.0.1" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="NuGet.Protocol" Version="7.3.0" />
<PackageVersion Include="NUnit" Version="4.4.0" />
Expand Down
113 changes: 113 additions & 0 deletions TUnit.Example.Asp.Net.TestProject/EfCore/EfCoreTodoApiTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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 <see cref="TodoApiTests"/> which uses raw SQL with per-test table names.
/// </summary>
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<List<Todo>>();
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<Todo>();
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<List<Todo>>("/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<Todo>();

// 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<Todo>();
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<Todo>();

// 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);
}
}
93 changes: 93 additions & 0 deletions TUnit.Example.Asp.Net.TestProject/EfCore/EfCoreTodoTestBase.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[SuppressMessage("Usage", "TUnit0043:Property must use `required` keyword")]
public abstract class EfCoreTodoTestBase : WebApplicationTest<EfCoreWebApplicationFactory, Program>
{
[ClassDataSource<InMemoryPostgreSqlDatabase>(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<TodoDbContext>()
.UseNpgsql(PostgreSql.Container.GetConnectionString())
.ReplaceService<IModelCacheKeyFactory, SchemaModelCacheKeyFactory>()
.Options;

await using var dbContext = new TodoDbContext(options) { SchemaName = SchemaName };
await dbContext.Database.EnsureCreatedAsync();
}

protected override void ConfigureTestConfiguration(IConfigurationBuilder config)
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "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();
}

/// <summary>
/// Creates a scoped TodoDbContext for direct database access in tests.
/// The context reads Database:Schema from the per-test configuration automatically.
/// </summary>
protected AsyncServiceScope CreateDbScope(out TodoDbContext dbContext)
{
var scope = Factory.Services.CreateAsyncScope();
dbContext = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
return scope;
}

/// <summary>
/// Seeds the database with todos directly via EF Core (bypassing the API).
/// </summary>
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();
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// WebApplicationFactory configured with EF Core services.
/// Reuses the shared PostgreSQL container and adds DbContext registration.
/// </summary>
[SuppressMessage("Usage", "TUnit0043:Property must use `required` keyword")]
public class EfCoreWebApplicationFactory : TestWebApplicationFactory<Program>
{
[ClassDataSource<InMemoryPostgreSqlDatabase>(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<DbContextOptions<TodoDbContext>>();

// Re-register with the container's real connection string
services.AddDbContext<TodoDbContext>(options =>
options.UseNpgsql(PostgreSql.Container.GetConnectionString())
.ReplaceService<IModelCacheKeyFactory, SchemaModelCacheKeyFactory>());
});
}

protected override void ConfigureStartupConfiguration(IConfigurationBuilder configurationBuilder)
{
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "SomeKey", "SomeValue" },
{ "Database:ConnectionString", PostgreSql.Container.GetConnectionString() }
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing"/>
<PackageReference Include="Npgsql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Testcontainers.Kafka" />
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="Testcontainers.Redis" />
Expand Down
19 changes: 19 additions & 0 deletions TUnit.Example.Asp.Net/EfCore/SchemaModelCacheKeyFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;

namespace TUnit.Example.Asp.Net.EfCore;

/// <summary>
/// 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.
/// </summary>
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);
}
}
32 changes: 32 additions & 0 deletions TUnit.Example.Asp.Net/EfCore/TodoDbContext.cs
Original file line number Diff line number Diff line change
@@ -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<Todo> Todos => Set<Todo>();

public TodoDbContext(DbContextOptions<TodoDbContext> options, IConfiguration? config = null)
: base(options)
{
SchemaName = config?["Database:Schema"] ?? "public";
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema(SchemaName);

modelBuilder.Entity<Todo>(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");
});
}
}
Loading
Loading