Add TUnit-based unit and integration tests for backend
Set up YesChef.Api.UnitTests and YesChef.Api.IntegrationTests projects running on TUnit + Microsoft.Testing.Platform. Integration tests use a single Postgres 17 Testcontainer per session and clone a migrated template database per test (`CREATE DATABASE … TEMPLATE …`) so tests remain fully isolated and run in parallel without replaying migrations each time. Test-author DX is built around fluent entity builders, a TestDataFactory for common scenarios, and a two-level base hierarchy (IntegrationTest / AuthenticatedIntegrationTest) whose `[Before(Test)]` hooks stand up the per-test database, app factory, default user, and authenticated HttpClient — leaving each test body focused on the action under test. Adds src/backend/global.json to opt `dotnet test` into MTP mode on the .NET 10 SDK, and updates CLAUDE.md with how to run the tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
using TUnit.Core.Interfaces;
|
||||
using YesChef.Api.Data;
|
||||
|
||||
namespace YesChef.Api.IntegrationTests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// One Postgres container per test session. A migrated "template" database is
|
||||
/// created once; each test asks the fixture for a fresh DB cloned from the
|
||||
/// template via <c>CREATE DATABASE … TEMPLATE …</c>, which is far cheaper than
|
||||
/// replaying migrations.
|
||||
/// </summary>
|
||||
public sealed class PostgresFixture : IAsyncInitializer, IAsyncDisposable
|
||||
{
|
||||
private const string TemplateDbName = "yeschef_template";
|
||||
|
||||
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:17")
|
||||
.WithDatabase("postgres")
|
||||
.WithUsername("postgres")
|
||||
.WithPassword("postgres")
|
||||
.Build();
|
||||
|
||||
private string _adminConnectionString = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _container.StartAsync();
|
||||
_adminConnectionString = _container.GetConnectionString();
|
||||
|
||||
await CreateTemplateAsync();
|
||||
}
|
||||
|
||||
private async Task CreateTemplateAsync()
|
||||
{
|
||||
await ExecuteOnPostgresAsync($"""CREATE DATABASE "{TemplateDbName}";""");
|
||||
|
||||
var templateConnectionString = BuildConnectionString(TemplateDbName);
|
||||
var options = new DbContextOptionsBuilder<YesChefDb>()
|
||||
.UseNpgsql(templateConnectionString)
|
||||
.Options;
|
||||
await using (var db = new YesChefDb(options))
|
||||
{
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
// Postgres holds idle pooled connections to the template; close them so
|
||||
// the template is free to be used as a CREATE DATABASE source.
|
||||
NpgsqlConnection.ClearAllPools();
|
||||
}
|
||||
|
||||
public async Task<TestDatabase> CreateDatabaseAsync()
|
||||
{
|
||||
var name = $"yeschef_test_{Guid.NewGuid():N}";
|
||||
await ExecuteOnPostgresAsync($"""CREATE DATABASE "{name}" TEMPLATE "{TemplateDbName}";""");
|
||||
return new TestDatabase(name, BuildConnectionString(name), this);
|
||||
}
|
||||
|
||||
internal async Task DropDatabaseAsync(string name)
|
||||
{
|
||||
NpgsqlConnection.ClearAllPools();
|
||||
await ExecuteOnPostgresAsync($"""DROP DATABASE IF EXISTS "{name}" WITH (FORCE);""");
|
||||
}
|
||||
|
||||
private async Task ExecuteOnPostgresAsync(string sql)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_adminConnectionString);
|
||||
await conn.OpenAsync();
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private string BuildConnectionString(string database)
|
||||
{
|
||||
var builder = new NpgsqlConnectionStringBuilder(_adminConnectionString)
|
||||
{
|
||||
Database = database,
|
||||
// Per-test apps open a small number of connections; the high default
|
||||
// pool keeps the cluster from being flooded under heavy parallelism.
|
||||
MaxPoolSize = 10,
|
||||
Timeout = 60,
|
||||
};
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
NpgsqlConnection.ClearAllPools();
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Handle to a per-test database. Disposing drops it.</summary>
|
||||
public sealed class TestDatabase(string name, string connectionString, PostgresFixture fixture) : IAsyncDisposable
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
public string ConnectionString { get; } = connectionString;
|
||||
|
||||
public async ValueTask DisposeAsync() => await fixture.DropDatabaseAsync(Name);
|
||||
}
|
||||
Reference in New Issue
Block a user