09

Lab 09 - Testing — Service Layer + Integration

Partea 4 din 4

Parte 4 — Cheatsheet

Pagină de referință rapidă pentru tot ce ați văzut în lab. Folosiți-o ca aide-mémoire la următorul dotnet test.

xUnit — atribute esențiale

Atribut / interfață Când Exemplu
[Fact] Test fără parametri [Fact] public void X_DoesY()
[Theory] + [InlineData(...)] Același test, multiple input-uri [Theory] [InlineData(1,2,3)]
Constructor Setup per test (instanță nouă) public MyTests() { ... }
IDisposable.Dispose Teardown sincron per test public void Dispose() { ... }
IAsyncLifetime Setup/teardown async per test InitializeAsync / DisposeAsync
IClassFixture<T> O instanță T partajată în clasă class Tests : IClassFixture<Factory>
[Trait("Category","Slow")] Categorie pentru filtrare dotnet test --filter Category=Slow

Convenția de naming: Metodă_Scenariu_RezultatAșteptatUpdate_AsNonOwner_Returns403.

Assert.* — tabelul de buzunar

Assert Verifică
Assert.Equal(expected, actual) Valori egale
Assert.NotEqual(expected, actual) Valori diferite
Assert.Null(obj) / Assert.NotNull(obj) Null check
Assert.Empty(collection) / Assert.Single(collection) 0 / 1 element
Assert.Equal(n, collection.Count) Count exact
Assert.Contains(item, collection) Conține elementul
Assert.True(condition) / Assert.False(condition) Boolean
Assert.IsType<T>(obj) Tip exact
Assert.IsAssignableFrom<T>(obj) Tip sau derivat
Assert.Throws<T>(() => ...) Aruncă T (sync)
await Assert.ThrowsAsync<T>(async () => ...) Aruncă T (async)

Dacă lucrați pe un repo care folosește FluentAssertions, echivalentele sunt actual.Should().Be(expected), result.Should().NotBeNull(), items.Should().HaveCount(3), act.Should().Throw<T>(). Conceptual același lucru, sintaxă diferită.

EF Core InMemory — pattern pentru service tests

private static (ArticleService service, AppDbContext context) CreateService(string dbName)
{
    var options = new DbContextOptionsBuilder<AppDbContext>()
        .UseInMemoryDatabase(databaseName: dbName)
        .Options;

    var context = new AppDbContext(options);
    var unitOfWork = new UnitOfWork(context);
    var service = new ArticleService(unitOfWork);
    return (service, context);
}

Reguli:

  • dbName unic per test — folosiți nameof(metoda_test) sau interpolat cu parametrii din [Theory]. Două teste cu același dbName împart datele.
  • SaveChanges() după seed — change tracker-ul nu e BD; fără SaveChanges, query-urile nu văd nimic.
  • InMemory ≠ SQL Server. Tranzacțiile sunt no-op, FK/UNIQUE nu sunt validate, funcțiile SQL specifice (STRING_AGG, JSON_VALUE) pică la runtime. Pentru realism 100%: Testcontainers.

Pattern AAA — mereu

[Fact]
public async Task Name()
{
    // Arrange — pregătește input + stack-ul
    var (service, context) = CreateService(nameof(Name));
    SeedTwoArticles(context);

    // Act — o singură linie de obicei
    var result = await service.GetByIdAsync(1);

    // Assert — verifică
    Assert.NotNull(result);
    Assert.Equal("Articol test 1", result!.Title);
}

WebApplicationFactory<T> — boilerplate

// Program.cs — ULTIMA LINIE, esențială
public partial class Program { }

// CustomWebApplicationFactory.cs
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
    private readonly string _dbName = $"TestDb_{Guid.NewGuid()}";

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Testing");
        builder.ConfigureServices(services =>
        {
            var d = services.SingleOrDefault(s =>
                s.ServiceType == typeof(DbContextOptions<AppDbContext>));
            if (d != null) services.Remove(d);
            services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase(_dbName));
        });
    }
}

// În test class
public class MyTests : IClassFixture<CustomWebApplicationFactory>, IAsyncLifetime
{
    private readonly HttpClient _client;
    public MyTests(CustomWebApplicationFactory f) => _client = f.CreateClient();
    public Task InitializeAsync() => Task.CompletedTask;
    public Task DisposeAsync()    => Task.CompletedTask;
}

HttpClient — request-uri pentru integration tests

// GET + JSON
var r = await _client.GetAsync("/api/articlesapi");
var dtos = await r.Content.ReadFromJsonAsync<List<ArticleDto>>();

// POST cu body JSON
var r = await _client.PostAsJsonAsync("/api/articlesapi", dto);

// POST/PUT cu Authorization (pe request individual)
var req = new HttpRequestMessage(HttpMethod.Post, "/api/articlesapi")
{
    Content = JsonContent.Create(dto)
};
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var r = await _client.SendAsync(req);

// status code assertions
Assert.Equal(HttpStatusCode.OK, r.StatusCode);            // 200
Assert.Equal(HttpStatusCode.Created, r.StatusCode);       // 201
Assert.Equal(HttpStatusCode.NoContent, r.StatusCode);     // 204
Assert.Equal(HttpStatusCode.Unauthorized, r.StatusCode);  // 401
Assert.Equal(HttpStatusCode.Forbidden, r.StatusCode);     // 403
Assert.Equal(HttpStatusCode.NotFound, r.StatusCode);      // 404

CLI — comenzi utile

# rulează toate testele
dotnet test

# rulează doar o clasă / un test
dotnet test --filter "FullyQualifiedName~ArticleServiceTests"
dotnet test --filter "Name=GetByIdAsync_ExistingId_ReturnsArticle"

# coverage (necesită coverlet.collector în csproj)
dotnet test --collect:"XPlat Code Coverage"

# verbose (vezi ce teste rulează)
dotnet test --logger "console;verbosity=detailed"

# rulare paralelă oprită (util când două clase folosesc aceeași BD InMemory)
dotnet test -- xUnit.ParallelizeAssembly=false xUnit.ParallelizeTestCollections=false

Decision tree: ce fel de test scriu?

  • Logica de business (service + repo + DB) → unit test cu InMemory, stack real (Partea 2).
  • Routing, model binding, [ApiController], JSON, validare → integration test cu WebApplicationFactory (Partea 3).
  • Auth real (cookie/JWT), [Authorize] → integration test cu login real prin endpoint.
  • Comportament SQL specific (concurrency, FK, funcții T-SQL) → Testcontainers, nu InMemory.
  • Funcție pură (parser, calcul) → unit test cu [Fact]/[Theory], fără mock-uri și fără DB.

Linkuri rapide