Passa al contenuto principale

Pattern: template e scope

Perché test di integrazione

I test unitari verificano la logica isolata; i test di integrazione verificano che la logica funzioni con il database reale. Sono quelli che trovano i problemi che contano: query N+1, constraint violati, migration incomplete, comportamenti LINQ non traducibili in SQL.

SQLite in-memory è più veloce ma non si comporta come un database reale su tipi, constraint, case sensitivity e funzioni specifiche del motore. Il database dei test deve essere lo stesso motore di produzione.


Il ciclo di vita

Ogni test riceve un database dedicato clonato da un template e uno scope DI fresco. Il template viene creato una volta sola per sessione e riusato per tutti i test e tutte le fixture.

Il clone è un'operazione istantanea o quasi: copia struttura e dati del template senza toccare i file di produzione.


Invalidazione del template

Il template è nominato con il nome dell'ultima migration. Se le migration cambiano, il nome cambia, il vecchio template non viene trovato e ne viene creato uno nuovo automaticamente.

I template obsoleti (da migration precedenti) non vengono rimossi automaticamente: si eliminano manualmente o con uno script di pulizia occasionale.


Scope DI per ogni test

Ogni test ha il proprio IServiceScope. I servizi si risolvono dallo scope con Get<T>() — nessun new manuale. Db e tutti i servizi dello stesso scope condividono il DbContext, come avviene in produzione durante una richiesta HTTP.

Il ServiceProvider viene costruito con la connection string del DB di test e smaltito al [TearDown], insieme allo scope.


Implementazione della classe base

La logica di creazione, clone ed eliminazione del database dipende dal motore. La classe base espone MasterConnectionString come proprietà virtuale per permettere alle sottoclassi di sostituire la fonte (es. Testcontainers — vedi 03-testcontainers).

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Npgsql;

[TestFixture]
public abstract class IntegrationTestBase
{
private static string? _templateDbName;
private string _testDbName = null!;
private ServiceProvider _provider = null!;
private IServiceScope _scope = null!;

protected T Get<T>() where T : notnull
=> _scope.ServiceProvider.GetRequiredService<T>();

protected AppDbContext Db => Get<AppDbContext>();

protected virtual Task SeedAsync(AppDbContext db) => Task.CompletedTask;

protected virtual string MasterConnectionString =>
Environment.GetEnvironmentVariable("TEST_DB_CONNECTION")
?? "Host=localhost;Username=postgres;Password=secret";

[OneTimeSetUp]
public async Task PrepareTemplate()
{
if (_templateDbName is not null) return;

var opts = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql($"{MasterConnectionString};Database=postgres").Options;
await using var ctx = new AppDbContext(opts);
var lastMigration = ctx.Database.GetMigrations().Last();
_templateDbName = $"testdb_template_{lastMigration.ToLowerInvariant()}";

await using var conn = new NpgsqlConnection($"{MasterConnectionString};Database=postgres");
await conn.OpenAsync();

await using var check = conn.CreateCommand();
check.CommandText = "SELECT 1 FROM pg_database WHERE datname = $1";
check.Parameters.AddWithValue(_templateDbName);
if (await check.ExecuteScalarAsync() is not null) return;

await using var create = conn.CreateCommand();
create.CommandText = $"CREATE DATABASE \"{_templateDbName}\"";
await create.ExecuteNonQueryAsync();

var templateOpts = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql($"{MasterConnectionString};Database={_templateDbName}").Options;
await using var templateCtx = new AppDbContext(templateOpts);
await templateCtx.Database.MigrateAsync();

await NpgsqlConnection.ClearAllPoolsAsync();
}

[SetUp]
public async Task CreateTestScope()
{
_testDbName = $"testdb_{Guid.NewGuid():N}";

await using var conn = new NpgsqlConnection($"{MasterConnectionString};Database=postgres");
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
// Clone istantaneo del template
cmd.CommandText = $"CREATE DATABASE \"{_testDbName}\" TEMPLATE \"{_templateDbName}\"";
await cmd.ExecuteNonQueryAsync();

var services = new ServiceCollection();
ConfigureServices(services, $"{MasterConnectionString};Database={_testDbName}");
_provider = services.BuildServiceProvider(validateScopes: true);
_scope = _provider.CreateScope();

await SeedAsync(Db);
}

[TearDown]
public async Task DropTestScope()
{
_scope.Dispose();
await _provider.DisposeAsync();
await NpgsqlConnection.ClearAllPoolsAsync();

await using var conn = new NpgsqlConnection($"{MasterConnectionString};Database=postgres");
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
// WITH (FORCE) termina le connessioni attive (PostgreSQL 13+)
cmd.CommandText = $"DROP DATABASE IF EXISTS \"{_testDbName}\" WITH (FORCE)";
await cmd.ExecuteNonQueryAsync();
}

private static void ConfigureServices(IServiceCollection services, string connectionString)
{
services.AddDbContext<AppDbContext>(opt => opt.UseNpgsql(connectionString));
services.AddScoped<ICreaOrdine, CreaOrdine>();
services.AddScoped<IGestoreScorte, GestoreScorte>();
// ... stesse registrazioni di Program.cs
}
}