Parte 3 — Integration tests cu WebApplicationFactory + JWT
Unit-urile pe service prind logica de business. Dar nu prind: routing, model binding, [ApiController] validation, DI, serializare JSON, middleware-urile de autentificare. Toate astea trăiesc în framework-ul ASP.NET Core.
Integration test = pornim aplicația reală în memorie, facem cereri HTTP printr-un HttpClient, verificăm răspunsurile. Tot stack-ul rulează: routing, [Authorize], serializare, JWT.
WebApplicationFactory<T> (din Microsoft.AspNetCore.Mvc.Testing) face fix asta. T e un tip din assembly-ul aplicației — în cazul nostru, Program.
Pasul 9 — De ce public partial class Program { }?
Când scrieți Program.cs cu top-level statements, compilatorul generează în spate:
internal partial class Program { static async Task Main(string[] args) { /* codul vostru */ } }
internal = vizibil doar în Lab09, nu în Lab09.Tests. WebApplicationFactory<Program> are nevoie să referențieze tipul din afară → CS0122: 'Program' is inaccessible.
Soluția — o linie la finalul Program.cs:
// Expunere Program pentru WebApplicationFactory<Program> din proiectul de teste.
public partial class Program { }
Redeclară Program ca public partial. Compilatorul merge-uiește declarațiile, clasa devine public. Nu adaugă logică — e o ușă deschisă.
Alternativă:
<InternalsVisibleTo Include="Lab09.Tests" />în.csproj. Ambele merg;public partial class Program { }e mai explicit și mai comun în docs.
Pasul 10 — CustomWebApplicationFactory cu InMemory
Cu WebApplicationFactory<Program> direct, aplicația se conectează la SQL Server real (din appsettings.json). Pe CI fără SQL, pică. Local, testele modifică BD-ul de dezvoltare. Nu ok.
Soluția: factory derivat care înlocuiește AppDbContext cu InMemory.
Integration/CustomWebApplicationFactory.cs
using Lab09.Data;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Lab09.Tests.Integration;
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly string _dbName = $"Lab09TestDb_{Guid.NewGuid()}";
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
// Scoate inregistrarea reala (SQL Server)
var dbContextDescriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (dbContextDescriptor != null)
services.Remove(dbContextDescriptor);
// Inlocuieste cu InMemory, nume unic per factory
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase(_dbName));
});
}
public async Task SeedExtraTestDataAsync()
{
using var scope = Services.CreateScope();
await SeedTestData.Initialize(scope.ServiceProvider);
}
}
_dbName cu Guid.NewGuid() — dacă rulează două clase de teste integration în paralel, nu se ciocnesc pe același store.
Mini-incident: SeedData.Migrate() pică pe InMemory
Primul GetAll aruncă:
System.InvalidOperationException: Relational-specific methods can only be used
when the context is using a relational database provider.
at Lab09.Data.SeedData.InitializeAsync(...)
Cauza: SeedData.InitializeAsync are context.Database.Migrate(); — metodă relațională, explodează pe InMemory.
Fix-ul în Data/SeedData.cs (deja aplicat în Lab09_start):
if (context.Database.IsRelational())
context.Database.Migrate();
else
context.Database.EnsureCreated();
Lecție: codul de producție are presupuneri implicite despre lumea în care rulează. Testele aduc o altă lume — și presupunerile tăcute devin zgomotoase. Fix-ul e în producție, nu în teste.
Integration/SeedTestData.cs — user non-admin
Programul seed-uiește deja admin-ul + articolele la startup. Pentru testele de ownership avem nevoie și de un user non-admin.
using Lab09.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
namespace Lab09.Tests.Integration;
public static class SeedTestData
{
public const string RegularUserEmail = "user@test.com";
public const string RegularUserPassword = "User@123";
public static async Task Initialize(IServiceProvider services)
{
var userManager = services.GetRequiredService<UserManager<ApplicationUser>>();
if (await userManager.FindByEmailAsync(RegularUserEmail) == null)
{
var user = new ApplicationUser
{
UserName = RegularUserEmail,
Email = RegularUserEmail,
FullName = "Regular Test User",
EmailConfirmed = true
};
var result = await userManager.CreateAsync(user, RegularUserPassword);
if (result.Succeeded)
await userManager.AddToRoleAsync(user, "User");
}
}
}
Pasul 11 — Primul test prin HTTP
Integration/ArticlesApiIntegrationTests.cs
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Lab09.DTOs;
namespace Lab09.Tests.Integration;
public class ArticlesApiIntegrationTests : IClassFixture<CustomWebApplicationFactory>, IAsyncLifetime
{
private readonly CustomWebApplicationFactory _factory;
private readonly HttpClient _client;
private const string AdminEmail = "admin@newsportal.com";
private const string AdminPassword = "Admin@123";
public ArticlesApiIntegrationTests(CustomWebApplicationFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
public async Task InitializeAsync() => await _factory.SeedExtraTestDataAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task GetAll_ReturnsOkAndJsonArray()
{
var response = await _client.GetAsync("/api/articlesapi");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var articles = await response.Content.ReadFromJsonAsync<List<ArticleDto>>();
Assert.NotNull(articles);
}
[Fact]
public async Task GetById_ForNonexistentId_Returns404()
{
var response = await _client.GetAsync("/api/articlesapi/99999");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task Create_WithoutToken_Returns401()
{
var dto = new CreateArticleDto("Test Title", "Continut destul de lung pentru validare", 1);
var response = await _client.PostAsJsonAsync("/api/articlesapi", dto);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
IClassFixture<CustomWebApplicationFactory> = aceeași factory partajată pentru toate testele clasei (pornirea aplicației costă; o facem o dată). IAsyncLifetime ne dă InitializeAsync per test, în care chemăm seed-ul suplimentar.
Pasul 12 — JWT în integration tests
Endpoint-urile protejate cu [Authorize(JwtBearer)] cer header Authorization: Bearer <token>. Cel mai realist e să obținem token-ul exact cum îl obține un client: prin login.
Adăugăm doi helper-i în clasă:
private async Task<string> LoginAndGetTokenAsync(string email, string password)
{
var loginDto = new LoginDto(email, password);
var response = await _client.PostAsJsonAsync("/api/authapi/login", loginDto);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<LoginResponse>();
return payload!.Token;
}
private async Task<HttpResponseMessage> SendAuthorizedAsync<T>(
HttpMethod method, string url, string token, T body)
{
var request = new HttpRequestMessage(method, url)
{
Content = JsonContent.Create(body)
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await _client.SendAsync(request);
}
private record LoginResponse(string Token, int ExpiresIn);
PostAsJsonAsync nu ne lasă să atașăm un header — pentru Authorization construim HttpRequestMessage manual.
Test — Create cu token valid + round-trip
[Fact]
public async Task Create_WithValidToken_Returns201AndArticleIsReadableAfterwards()
{
var token = await LoginAndGetTokenAsync(AdminEmail, AdminPassword);
var dto = new CreateArticleDto(
"Titlu din integration test",
"Continut suficient de lung ca sa treaca validarea MinLength(20)",
1);
var createResponse = await SendAuthorizedAsync(HttpMethod.Post, "/api/articlesapi", token, dto);
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
Assert.NotNull(createResponse.Headers.Location);
var createdDto = await createResponse.Content.ReadFromJsonAsync<ArticleDto>();
Assert.Equal("Titlu din integration test", createdDto!.Title);
// Round-trip: GET pe Location trebuie sa intoarca acelasi articol
var getResponse = await _client.GetAsync(createResponse.Headers.Location);
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var fetched = await getResponse.Content.ReadFromJsonAsync<ArticleDto>();
Assert.Equal(createdDto.Id, fetched!.Id);
}
Un singur test acoperă: routing pe POST /api/articlesapi, [Authorize(JWT)] cu token valid, model binding pe CreateArticleDto, validare (MinLength(5) / MinLength(20)), [ApiController] returnând 201 Created + Location, AuthorId setat din claim, persistare reală + GET round-trip.
Pasul 13 — Mini-incident: Forbid() și schemele de auth
Pentru un test viitor (Ex 4): user non-admin încearcă să editeze articolul altcuiva, primește 403. Scris naiv, testul pică — așteaptă 403, primește 200 OK cu HTML-ul paginii de login.
Diagnoza: ArticlesApiController.Update (din Lab 8) avea return Forbid(); — fără argument. Forbid() fără schemă caută schema default, care e Identity cookies (din AddIdentity). CookieAuthenticationHandler.HandleForbiddenAsync redirectează spre /Auth/Login (302), iar HttpClient urmează redirect-ul → ajunge la pagina de login → vede 200 OK.
Fix-ul în controller (deja aplicat în Lab09_start):
if (!IsOwnerOrAdmin(article))
return Forbid(JwtBearerDefaults.AuthenticationScheme);
Acum JwtBearerHandler.HandleForbiddenAsync rulează și scrie efectiv 403. Aceeași schimbare și în Delete.
De ce a scăpat de manual testing în Lab 8? Pentru că nu am făcut request-uri API-only cu un user non-owner. Manual testing era prin UI MVC, unde cookie-ul e activ și redirect-ul e dorit. Abia integration test-ul pe ruta JSON descoperă discrepanța.
Morala: „merge manual" ≠ „merge automat cu toate cazurile". Integration testing nu validează doar testele — uneori validează și codul.
Schimbările în codul de producție
În Lab09_start, trei schimbări față de Lab 8:
-
Program.cs, ultima linie:public partial class Program { }Fără asta,
WebApplicationFactory<Program>nu compilează. -
Data/SeedData.cs— guard pentru provider-e non-relaționale:if (context.Database.IsRelational()) context.Database.Migrate(); else context.Database.EnsureCreated(); -
Controllers/Api/ArticlesApiController.cs— schemă explicită pentruForbid()peUpdateșiDelete:return Forbid(JwtBearerDefaults.AuthenticationScheme);
Niciuna nu schimbă logica de business — doar adaptează codul la realitatea că rulează în două lumi (SQL real + InMemory, MVC cookie + API JWT).
Referință — Assert.* comune
| Assert | Verifică |
|---|---|
Assert.Equal(expected, actual) |
Valori egale |
Assert.NotEqual(expected, actual) |
Valori diferite |
Assert.Null(obj) |
Obiectul este null |
Assert.NotNull(obj) |
Obiectul nu este null |
Assert.Empty(collection) |
Colecția e goală |
Assert.Single(collection) |
Colecția are exact un element |
Assert.Contains(item, collection) |
Colecția conține elementul |
Assert.True(condition) |
Condiția e adevărată |
Assert.False(condition) |
Condiția e falsă |
Assert.Throws<T>(() => ...) |
Aruncă excepția de tip T |
await Assert.ThrowsAsync<T>(async () => ...) |
Variantă async |
Assert.IsType<T>(obj) |
Obiectul e exact de tip T |
Assert.IsAssignableFrom<T>(obj) |
Obiectul e T sau derivat |
Documentație: xUnit assert reference · EF Core testing
Test end-to-end — checklist
dotnet test→ toate verzi, inclusivSmokeTest.TestInfrastructure_WorksServices/ArticleServiceTests.cs— minim 7 teste, inclusiv un[Theory]Integration/CustomWebApplicationFactory.csînlocuieșteAppDbContextcu InMemory, nume unicIntegration/SeedTestData.csseed-uiește user non-admin (admin + articole vin dinSeedDataautomat)ArticlesApiIntegrationTests.GetAll_ReturnsOkAndJsonArray→200+ JSON parseabilCreate_WithoutToken_Returns401→401Create_WithValidToken_Returns201AndArticleIsReadableAfterwards— login → token → POST cu Bearer → round-trip prin GET pe Location
Exerciții (4p)
Exercițiul 1 — Service tests pentru ArticleService (1p)
Acoperă: Partea 1 (xUnit) + Partea 2 (helper + CRUD).
Toate simultan:
dotnet testrulează șiSmokeTesttrece.Lab09.Tests/Services/ArticleServiceTests.csexistă cu helper-eleCreateServiceșiSeedTwoArticles.- Minim 5 teste
[Fact]acoperind:GetAllAsync(cu și fără date),GetByIdAsync(id valid și invalid),AddAsync(count crește). - Toate aserțiunile folosesc
Assert.*(nuif + throw).
Exercițiul 2 — [Theory] + ștergeri (1p)
Acoperă: Partea 2 (Pașii 6-7).
Toate simultan:
- Un
[Theory]cu minim 4[InlineData]peGetByIdAsync(id-uri valide + invalide). DeleteAsync_ExistingId_RemovesArticle— verifică căSingle(all)și că articolul șters nu mai e găsit cuGetByIdAsync.DeleteAsync_InvalidId_DoesNotThrow— id inexistent, nu aruncă, count rămâne.
Exercițiul 3 — Integration tests cu WebApplicationFactory (1p)
Acoperă: Partea 3 (Pașii 9-11).
Toate simultan:
Program.csarepublic partial class Program { }la final.Lab09.Tests/Integration/CustomWebApplicationFactory.csderivă dinWebApplicationFactory<Program>, scoateDbContextOptions<AppDbContext>existent, înregistrează InMemory cu nume unic per factory.Lab09.Tests/Integration/SeedTestData.csseed-uiește user-ul non-adminuser@test.com(rol User).Lab09.Tests/Integration/ArticlesApiIntegrationTests.csimplementeazăIClassFixture<CustomWebApplicationFactory>șiIAsyncLifetime, cheamăfactory.SeedExtraTestDataAsync()înInitializeAsync.GetAll_ReturnsOkAndJsonArray→200 OK+List<ArticleDto>deserializabil.Create_WithoutToken_Returns401→401 Unauthorized.dotnet testmerge fără SQL Server local.
Exercițiul 4 — JWT + ownership end-to-end (1p)
Acoperă: muncă peste lab — folosește Pașii 12-13.
Hint-uri: scrieți doi helperi (
LoginAndGetTokenAsync,SendAuthorizedAsync<T>). Folosiți admin-ul deja seed-uit (admin@newsportal.com/Admin@123) și user-ul dinSeedTestData.RegularUserEmail/Password. DacăUpdate_AsNonOwner_Returns403întoarce200 OKcu HTML, recitiți Pasul 13.
Toate simultan:
Create_WithValidToken_Returns201AndArticleIsReadableAfterwards— admin se loghează, primește token, POST cu Bearer →201 Created+Location; GET peLocationîntoarce același articol (round-trip).Update_AsNonOwnerNonAdmin_Returns403— user-ul non-admin face PUT pe articolul admin-ului →403 Forbidden.Delete_WithoutToken_Returns401— fără header Authorization →401.- Minim un test face verificare post-acțiune (round-trip prin GET).
Total: 4p — 1p per exercițiu.
Cele trei modificări de producție din
Lab09_start(vezi sus) sunt deja făcute. Dacă testele voastre cer alte schimbări în controllers sau services, opriți-vă și reverificați setup-ul; probabil e în teste, nu în cod.