Questa nota riguarda come scrivere e strutturare al meglio gli integration test in ASP.NET.
Spesso vi Γ¨ la necessitΓ di testare non solo una singola unit of code indipendentemente da tutto ma lβintero flusso della mia applicazione, in particolare puΓ² essere una chiamata dal mondo esterno (esempio HTTP) che si riflette poi in una scrittura nel mondo esterno (esempio database).
Il problema Γ¨ che il mondo esterno Γ¨ lento e instabile e esce dallo scope del test in se: lβidea Γ¨ che un test non dovrebbe essere influenzato o influire sul mondo esterno ma dovrebbe essere tutto self contained.
Per questo motivo sono necessari degli Adapter che sono delle classi che mascherano il mondo esterno comportandosi come questo ultimo.
Step di esecuzione
- Setup
Preparazione iniziale dellβambiente di test:- Seed del database (inserimento di dati iniziali)
- Configurazione delle variabili di ambiente o file temporanei
- Dependency Mocking / Simulation
- In un integration test non si usano mock classici, ma si evita comunque di chiamare servizi esterni reali.
- Si possono usare servizi finti (stub o API locali che replicano il comportamento delle API esterne), in modo da mantenere il test autonomo e controllabile.
- Execution
- I test vengono eseguiti con gli stessi framework/tool degli unit test, ma operano a un livello piΓΉ alto (sullβintera applicazione o piΓΉ componenti insieme).
- Assertion
- Si verificano i risultati attesi:
- Risposte HTTP
- Stato del database
- Contenuto di file, messaggi di log o eventi emessi
- Coerenza del comportamento rispetto al flusso previsto
- Si verificano i risultati attesi:
- Cleanup
- Pulizia finale per non lasciare residui nel database, file system o altri servizi.
- Fondamentale per evitare che un test influenzi i successivi o sporchi lβambiente.
Naming
Il progetto di test avrΓ nome MyProject.Tests.Integration
e i metodi seguiranno lo stile HttpMethod_ReturnsXXX_WhenYYY
. Vedi Naming nei test
Database
Per fare test su un database Γ¨ una buona idea utilizzare Docker: in questo modo divento agnostico dal PC su cui stanno girando i test utilizzando una sandbox dedicata ai test.
`WebApplicationFactory
Per testare una Minimal API, Γ¨ buona pratica avviare lβapplicazione in modo controllato e isolato, simulando un ambiente reale.
La classe WebApplicationFactory<T>
(dal pacchetto nuget Microsoft.AspNetCore.Mvc.Testing
) serve a creare unβapp ASP.NET in memoria, utile per:
- simulare vere richieste HTTP (con
HttpClient
) - testare API end-to-end
- isolare lβambiente di test dal progetto principale
T
rappresenta un tipo marker, usato per individuare lβassembly (di solitoProgram
o unβinterfaccia vuota comeIApiMarker
). Il metodo fondamentale Γ¨CreateClient()
che permette di creare un client HTTP virtuale che puΓ² chiamare vari metodi della API, esempio con metodi comePostAsJsonAsync
e cosΓ¬ via. Per funzionare Γ¨ necessario creare unβinterfaccia AssemblyMarker nel progetto che contiene la REST API da testare in modo che il pacchetto sappia dove si trova il metodoProgram.cs
da lanciare automaticamente.
Esempio base
Nellβesempio seguente ho il codice minimo possibile per ottenere un HttpClient e fare una richiesta GET alla mia API.
public class GetCustomerControllerTests : IClassFixture<WebApplicationFactory<IApiMarker>>
{
private readonly HttpClient _httpClient;
public GetCustomerControllerTests(WebApplicationFactory<IApiMarker> appFactory)
{
_httpClient = appFactory.CreateClient();
}
[Fact]
public async Task Get_ReturnsNotFound_WhenCustomerDoesNotExist()
{
// Act
var response = await _httpClient.GetAsync($"customers/{Guid.NewGuid()}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
problem!.Title.Should().Be("Not Found");
problem.Status.Should().Be(404);
}
}
Questa classe poi viene utilizzata in questo modo:
public class BookTests : IClassFixture<LibraryApiFactory>
{
private readonly HttpClient _client;
public BookTests(LibraryApiFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task CreateBook_Returns201()
{
var book = new { Isbn = "1234567890123", Title = "Test Book" };
var response = await _client.PostAsJsonAsync("/books", book);
response.EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
}
Esempio completo
/// <summary>
/// Factory per l'avvio e la configurazione dell'API durante i test di integrazione.
/// Estende WebApplicationFactory per permettere test realistici dell'intera applicazione.
/// IApiMarker Γ¨ un'assembly marker. Serve per identificare l'assembly principale dell'applicazione.
/// </summary>
public class CustomerApiFactory : WebApplicationFactory<IApiMarker>, IAsyncLifetime
{
// Utenti di test simulati per il mock server GitHub
public const string ValidGithubUser = "validuser";
public const string ThrottledUser = "throttle";
// Container PostgreSQL tramite Testcontainers, per usare un DB reale isolato per i test. Il modo piΓΉ moderno Γ¨ usare PostgreSqlContainer invece di TestcontainerDatabase
private readonly TestcontainerDatabase _dbContainer =
new TestcontainersBuilder<PostgreSqlTestcontainer>()
.WithDatabase(new PostgreSqlTestcontainerConfiguration
{
Database = "db",
Username = "course",
Password = "whatever"
}).Build();
// Server HTTP mock che simula le GitHub API
private readonly GitHubApiServer _gitHubApiServer = new();
/// <summary>
/// Metodo di inizializzazione asincrona, eseguito prima dei test.
/// Avvia i container e il server mock.
/// </summary>
public async Task InitializeAsync()
{
// Avvia il mock server GitHub
_gitHubApiServer.Start();
// Prepara utenti simulati nel mock server
_gitHubApiServer.SetupUser(ValidGithubUser);
_gitHubApiServer.SetupThrottledUser(ThrottledUser);
// Avvia il container PostgreSQL
await _dbContainer.StartAsync();
}
/// <summary>
/// Metodo di pulizia finale dopo i test.
/// Ferma i container e rilascia le risorse.
/// </summary>
public new async Task DisposeAsync()
{
await _dbContainer.DisposeAsync(); // Ferma e rimuove il container DB
_gitHubApiServer.Dispose(); // Ferma il mock server GitHub
}
/// <summary>
/// Configura il WebHost (app ASP.NET) prima dell'avvio, sovrascrivendo servizi e client HTTP.
/// </summary>
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Rimuove tutti i provider di log per evitare output nei test
builder.ConfigureLogging(logging => { logging.ClearProviders(); });
// Configura i servizi da usare **solo durante i test**
builder.ConfigureTestServices(services =>
{
// Rimuove eventuali HostedService attivi (es. background jobs) che tipicamente non vogliamo che siano attivi durante i test
services.RemoveAll(typeof(IHostedService));
// Sostituisce la factory della connessione al DB con quella che punta al container PostgreSQL
services.RemoveAll(typeof(IDbConnectionFactory));
services.AddSingleton<IDbConnectionFactory>(_ =>
new NpgsqlConnectionFactory(_dbContainer.ConnectionString));
// Configura il client HTTP per GitHub in modo che punti al server finto invece che al GitHub reale
services.AddHttpClient("GitHub", httpClient =>
{
httpClient.BaseAddress = new Uri(_gitHubApiServer.Url);
httpClient.DefaultRequestHeaders.Add(
HeaderNames.Accept, "application/vnd.github.v3+json");
httpClient.DefaultRequestHeaders.Add(
HeaderNames.UserAgent, $"Course-{Environment.MachineName}");
});
});
}
}
Invece di PostgreSQL posso usare un db sqlite in memoria in questo modo
// Rimuove la factory del database registrata nell'app
collection.RemoveAll(typeof(IDbConnectionFactory));
// Usa un database SQLite in memoria per i test
collection.AddSingleton<IDbConnectionFactory>(_ =>
new SqliteConnectionFactory("DataSource=file:inmem?mode=memory&cache=shared"));
Testare API esterne
Nellβintegration testing non utilizzo mock per quanto riguarda db, file system o API ma utilizzo oggetti βveriβ. Cosa succede perΓ² se il mio sistema deve utilizzare API esterne che non dipendono da me? Per esempio devo chiamare le API di GitHub. Le API esterne hanno vari problemi con i test in quanto:
- Potrebbero essere a pagamento
- Necessitano autenticazioni complesse
- Potrebbero essere down in qualsiasi momento
Ovviamente non voglio che i miei test soffrano dei punti sopra, conseguentemente in questo caso Γ¨ necessario costruire una API "finta" che si comporta come quella vera.
Quindi una API con autenticazione come GitHub, che risponde con un JSON analogo a quello di GitHub perΓ² in locale: in questo modo posso scrivere tutti gli integration test senza temere di pagare ogni chiamata o essere dipendente dal fatto che tale API esterna potrebbe andare down in qualsiasi momento.
Un comodo pacchetto nuget per far girare dei server api mockati Γ¨
WireMock
. Per esempio nel codice della mia applicazione ho la chiamata alle api di github in questo modo:
builder.Services.AddHttpClient("GitHub", httpClient =>
{
httpClient.BaseAddress = new Uri(config.GetValue<string>("GitHub:ApiBaseUrl"));
httpClient.DefaultRequestHeaders.Add(
HeaderNames.Accept, "application/vnd.github.v3+json");
httpClient.DefaultRequestHeaders.Add(
HeaderNames.UserAgent, $"Course-{Environment.MachineName}");
});
utilizzata così:
var client = _httpClientFactory.CreateClient("GitHub");
var response = await client.GetAsync($"/users/{username}");
Ora voglio usare wiremock nei test: per farlo devo creare una classe che utilizza wiremock per creare unβapi che si comporta come github, esempio:
public class GitHubApiServer : IDisposable
{
private WireMockServer _server;
public string Url => _server.Url!;
public void Start()
{
_server = WireMockServer.Start();
}
public void SetupUser(string username)
{
_server.Given(Request.Create()
.WithPath($"/users/{username}")
.UsingGet())
.RespondWith(Response.Create()
.WithBody(GenerateGitHubUserResponseBody(username))
.WithHeader("content-type", "application/json; charset=utf-8")
.WithStatusCode(200));
}
public void SetupThrottledUser(string username)
{
_server.Given(Request.Create()
.WithPath($"/users/{username}")
.UsingGet())
.RespondWith(Response.Create()
.WithBody(@"{
""message"": ""API rate limit exceeded for 127.0.0.1. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)"",
""documentation_url"": ""https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting""
}")
.WithHeader("content-type", "application/json; charset=utf-8")
.WithStatusCode(403));
}
public void Dispose()
{
_server.Stop();
_server.Dispose();
}
private static string GenerateGitHubUserResponseBody(string username)
{
return $@"{{
""login"": ""{username}"",
""id"": 67104228,
//...
}}";
}
}
e poi sovrascrivere la DI in modo che come url github utilizzi quello di wiremock in questo modo:
public class CustomerApiFactory : WebApplicationFactory<IApiMarker>, IAsyncLifetime
{
// Server HTTP mock che simula le GitHub API
private readonly GitHubApiServer _gitHubApiServer = new();
public async Task InitializeAsync()
{
// Avvia il mock server GitHub
_gitHubApiServer.Start();
// Prepara utenti simulati nel mock server
_gitHubApiServer.SetupUser(ValidGithubUser);
_gitHubApiServer.SetupThrottledUser(ThrottledUser);
}
/// <summary>
/// Metodo di pulizia finale dopo i test.
/// Ferma i container e rilascia le risorse.
/// </summary>
public new async Task DisposeAsync()
{
_gitHubApiServer.Dispose(); // Ferma il mock server GitHub
}
/// <summary>
/// Configura il WebHost (app ASP.NET) prima dell'avvio, sovrascrivendo servizi e client HTTP.
/// </summary>
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Configura i servizi da usare **solo durante i test**
builder.ConfigureTestServices(services =>
{
// Configura il client HTTP per GitHub in modo che punti al server finto invece che al GitHub reale
services.AddHttpClient("GitHub", httpClient =>
{
httpClient.BaseAddress = new Uri(_gitHubApiServer.Url);
httpClient.DefaultRequestHeaders.Add(
HeaderNames.Accept, "application/vnd.github.v3+json");
httpClient.DefaultRequestHeaders.Add(
HeaderNames.UserAgent, $"Course-{Environment.MachineName}");
});
});
}
}
Testare con dati realistici
Bogus Γ¨ un pacchetto NuGet per generare dati finti (fake data) in modo semplice, realistico e personalizzabile. Γ utile per simulare utenti, oggetti, liste di entitΓ per testare la logica dellβapplicazione Va benissimo per test ripetibili ma non statici, con combinazioni diverse a ogni run
Esempio
In questo caso Bogus viene usato per generare dinamicamente un oggetto CustomerRequest
con valori realistici e casuali.
private readonly Faker<CustomerRequest> _customerGenerator =
new Faker<CustomerRequest>()
.RuleFor(x => x.FullName, faker => faker.Person.FullName)
.RuleFor(x => x.Email, faker => faker.Person.Email)
.RuleFor(x => x.GitHubUsername, "nickchapsas") // valore fisso
.RuleFor(x => x.DateOfBirth, faker => faker.Person.DateOfBirth.Date);
Qui stai dicendo: βOgni volta che genero un CustomerRequest
, voglio che i campi abbiano questi valori:β
FullName
: nome completo realisticoEmail
: email realisticaGitHubUsername
: sempre"nickchapsas"
(hardcoded)DateOfBirth
: data di nascita realistica
[Fact]
public async Task Create_ReturnsCreated_WhenCustomerIsCreated()
{
// Arrange
var customer = _customerGenerator.Generate();
// Act
var response = await _httpClient.PostAsJsonAsync("customers", customer);
// Assert
// ...
}
UI Testing
LβUI testing con Playwright consiste nel testare unβapplicazione simulando il comportamento reale di un utente allβinterno di un browser. Playwright apre una vera finestra del browser (o headless), naviga nelle pagine, clicca sui pulsanti, compila form, attende caricamenti e verifica che lβinterfaccia risponda correttamente. Questo approccio permette di validare lβesperienza utente end-to-end, assicurandosi che tutto il flusso visivo e interattivo dellβapp funzioni come previsto, comprese le integrazioni con backend e database reali.
PerchΓ© non si puΓ² usare WebApplicationFactory con Playwright
Nel contesto dei test end-to-end con Playwright su una webapp ASP.NET, WebApplicationFactory
non Γ¨ adatto perchΓ© lβapp viene eseguita in-process e non Γ¨ esposta su HTTP/HTTPS, mentre Playwright simula un utente reale che interagisce tramite browser e ha quindi bisogno di unβapp realmente avviata e raggiungibile su una porta HTTP.
Per questo motivo Γ¨ necessario avviare lβintera applicazione dentro un container Docker, insieme a un database reale.
Eβ necessario quindi creare un file docker-compose
che costruisce lβapp direttamente dal codice sorgente, la espone sulle porte 80 e 443, e la configura tramite variabili di ambiente. Queste variabili vengono poi mappate nel Program.cs
con config.AddEnvironmentVariables("CustomersWebApp_")
, che consente lβoverride dei valori presenti in appsettings.json
usando la sintassi degli underscore. Gli underscore vengono convertiti internamente in due punti, quindi CustomersWebApp_Database__ConnectionString
corrisponde a Database:ConnectionString
, permettendo di sovrascrivere in modo semplice configurazioni come la connection string del database o URL di servizi esterni.
version: '3.9' # Versione del file docker-compose
services:
# Servizio dell'applicazione ASP.NET
test-app:
build: ../../src/Customers.WebApp # Costruisce l'immagine Docker partendo dal progetto ASP.NET
ports:
- "7780:443" # Espone HTTPS sulla porta 7780 del host
- "7779:80" # Espone HTTP sulla porta 7779 del host
environment:
# Configura l'app ASP.NET per ascoltare su entrambe le porte
- ASPNETCORE_URLS=https://+:443;http://+:80
# Imposta il certificato HTTPS per Kestrel
- ASPNETCORE_Kestrel__Certificates__Default__Password=Test1234!
- ASPNETCORE_Kestrel__Certificates__Default__Path=/https/cert.pfx
# Ambiente di esecuzione dell'app (Production simula l'ambiente reale)
- ASPNETCORE_Environment=Production
# Override delle configurazioni dell'app tramite variabili di ambiente
# Queste corrispondono a: Database:ConnectionString e GitHub:ApiBaseUrl
- CustomersWebApp_Database__ConnectionString=Server=test-db;Port=5432;Database=mydb;User ID=course;Password=changeme;
- CustomersWebApp_GitHub__ApiBaseUrl=http://localhost:9850
# Fa partire l'app solo dopo che il DB Γ¨ pronto
depends_on:
test-db:
condition: service_healthy
# Servizio del database PostgreSQL
test-db:
image: postgres:latest # Usa l'immagine ufficiale piΓΉ aggiornata di PostgreSQL
restart: always # Riavvia il container in caso di crash
environment:
- POSTGRES_USER=course # Utente del database
- POSTGRES_PASSWORD=changeme # Password dell'utente
- POSTGRES_DB=mydb # Nome del database da creare al primo avvio
ports:
- '5435:5432' # Espone la porta del database sulla macchina host (utile per debugging locale)
# Controlla che il database sia pronto prima di far partire l'app
healthcheck:
test: [ "CMD-SHELL", "pg_isready" ] # Verifica se il DB Γ¨ raggiungibile
interval: 2s # Frequenza del controllo
timeout: 5s # Timeout per ciascun tentativo
retries: 10 # Numero massimo di tentativi prima di segnare il container come unhealthy
FluentDocker
FluentDocker
Γ¨ una libreria .NET che permette di controllare Docker (container, immagini, compose) direttamente dal codice, in modo fluido e dichiarativo.
Posso utilizzarla per avviare automaticamente lβambiente Docker Compose allβinterno del contesto di test, senza doverlo eseguire manualmente da terminale prima di lanciare i test.
In pratica, integri lβinfrastruttura di test direttamente nel codice, cosΓ¬ quando parte il test:
- Docker Compose viene lanciato.
- I container vengono creati e avviati.
- Lβapp Γ¨ raggiungibile su
https://localhost:7780
. - Il test Playwright puΓ² iniziare a interagire con la UI reale. Questo approccio Γ¨ perfetto per test end-to-end come quelli che fai con Playwright, perchΓ© crea un ciclo completo e ripetibile: ogni volta che fai partire i test, lβambiente Docker viene costruito da zero, incluso il database e lβapp, con le giuste configurazioni. Non serve nulla di esterno nΓ© passaggi manuali. Hai isolamento, coerenza e un ambiente realistico.
Esempio
Nel costruttore _dockerService
usi new Builder()
per definire in modo fluido il comportamento desiderato:
private readonly ICompositeService _dockerService = new Builder()
.UseContainer() // Avvia un ambiente containerizzato
.UseCompose() // Usa un file docker-compose
.FromFile(DockerComposeFile) // Specifica il file docker-compose da usare
.RemoveOrphans() // Rimuove eventuali container orfani non piΓΉ necessari
.WaitForHttp("test-app", AppUrl) // Aspetta che il servizio test-app risponda su AppUrl
.Build();
In questo modo, al momento dellβinvocazione di _dockerService.Start();
allβinterno del metodo InitializeAsync
, FluentDocker
lancia effettivamente Docker Compose con tutte le configurazioni che hai definito, attende che lβapp sia disponibile via HTTP(S) e rende lβambiente pronto per lβesecuzione dei test Playwright.
Alla fine, nel DisposeAsync
, _dockerService.Dispose()
ferma e pulisce tutto in automatico, evitando che i container restino attivi dopo i test.