09

Lab 09 - Testing — Service Layer + Integration

Partea 3 din 4

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:

  1. Program.cs, ultima linie:

    public partial class Program { }
    

    Fără asta, WebApplicationFactory<Program> nu compilează.

  2. Data/SeedData.cs — guard pentru provider-e non-relaționale:

    if (context.Database.IsRelational())
        context.Database.Migrate();
    else
        context.Database.EnsureCreated();
    
  3. Controllers/Api/ArticlesApiController.cs — schemă explicită pentru Forbid() pe Update și Delete:

    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

  1. dotnet test → toate verzi, inclusiv SmokeTest.TestInfrastructure_Works
  2. Services/ArticleServiceTests.cs — minim 7 teste, inclusiv un [Theory]
  3. Integration/CustomWebApplicationFactory.cs înlocuiește AppDbContext cu InMemory, nume unic
  4. Integration/SeedTestData.cs seed-uiește user non-admin (admin + articole vin din SeedData automat)
  5. ArticlesApiIntegrationTests.GetAll_ReturnsOkAndJsonArray200 + JSON parseabil
  6. Create_WithoutToken_Returns401401
  7. Create_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 test rulează și SmokeTest trece.
  • Lab09.Tests/Services/ArticleServiceTests.cs există cu helper-ele CreateService și SeedTwoArticles.
  • 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.* (nu if + throw).

Exercițiul 2 — [Theory] + ștergeri (1p)

Acoperă: Partea 2 (Pașii 6-7).

Toate simultan:

  • Un [Theory] cu minim 4 [InlineData] pe GetByIdAsync (id-uri valide + invalide).
  • DeleteAsync_ExistingId_RemovesArticle — verifică că Single(all) și că articolul șters nu mai e găsit cu GetByIdAsync.
  • 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.cs are public partial class Program { } la final.
  • Lab09.Tests/Integration/CustomWebApplicationFactory.cs derivă din WebApplicationFactory<Program>, scoate DbContextOptions<AppDbContext> existent, înregistrează InMemory cu nume unic per factory.
  • Lab09.Tests/Integration/SeedTestData.cs seed-uiește user-ul non-admin user@test.com (rol User).
  • Lab09.Tests/Integration/ArticlesApiIntegrationTests.cs implementează IClassFixture<CustomWebApplicationFactory> și IAsyncLifetime, cheamă factory.SeedExtraTestDataAsync() în InitializeAsync.
  • GetAll_ReturnsOkAndJsonArray200 OK + List<ArticleDto> deserializabil.
  • Create_WithoutToken_Returns401401 Unauthorized.
  • dotnet test merge 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 din SeedTestData.RegularUserEmail/Password. Dacă Update_AsNonOwner_Returns403 întoarce 200 OK cu HTML, recitiți Pasul 13.

Toate simultan:

  • Create_WithValidToken_Returns201AndArticleIsReadableAfterwards — admin se loghează, primește token, POST cu Bearer → 201 Created + Location; GET pe Location î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.