09

Lab 09 - Testing — Service Layer + Integration

Partea 2 din 4

Parte 2 — Unit tests pe Service Layer cu EF InMemory

Pasul 4 — Ce testăm și de ce stack-ul real

ArticleService conține logica de business — orchestrează IUnitOfWork, setează PublishedAt la Add, ștergerea idempotentă, etc. E exact stratul care ne interesează.

Două opțiuni de izolare a service-ului:

  1. Mock pe IUnitOfWork — declarăm cum se comportă fiecare apel (mock.Setup(...).Returns(...)). Costul: testați și implementarea (ce metode apelează service-ul), nu doar comportamentul.
  2. Stack real cu BD în memorieService → UnitOfWork → Repository → Context (InMemory). Costul: ușor mai lent (totul în RAM, dar e EF real), dar testați comportamentul observabil.

Pentru lab alegem (2). Logica noastră e suficient de subțire încât mock-urile ar dubla costul de scriere fără să prindă mai multe bug-uri. Plus: bonus, repository-ul și context-ul sunt acoperite indirect.

ArticlesController MVC (Razor) ArticlesApiController JSON API (JWT) acoperite de integration tests (Partea 3) ArticleService TESTAM AICI IUnitOfWork AppDbContext (InMemory)

Pasul 5 — Services/ArticleServiceTests.cs — helper-ul

Creăm subfolderul Services/ în Lab09.Tests/ și fișierul ArticleServiceTests.cs. Începem cu două helper-e: unul care construiește stack-ul, altul care îl populează cu date.

using Lab09.Data;
using Lab09.Models;
using Lab09.Repositories;
using Lab09.Services;
using Microsoft.EntityFrameworkCore;

namespace Lab09.Tests.Services;

public class ArticleServiceTests
{
    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);
    }

    private static void SeedTwoArticles(AppDbContext context)
    {
        var category = new Category { Id = 1, Name = "Tehnologie" };
        context.Categories.Add(category);
        context.Articles.AddRange(
            new Article
            {
                Id = 1,
                Title = "Articol test 1",
                Content = "Continut suficient de lung pentru validare",
                PublishedAt = new DateTime(2026, 4, 20),
                CategoryId = 1,
                AuthorId = "user-1"
            },
            new Article
            {
                Id = 2,
                Title = "Articol test 2",
                Content = "Alt continut suficient de lung pentru test",
                PublishedAt = new DateTime(2026, 4, 19),
                CategoryId = 1,
                AuthorId = "user-2"
            }
        );
        context.SaveChanges();
    }

    // ... testele aici ...
}

Două detalii importante:

  • dbName unic per test. InMemory provider leagă datele de un nume — două teste cu același nume împart datele. Folosim nameof(metoda_de_test) ca să avem izolare automată.
  • context.SaveChanges() la sfârșit. Fără el, datele rămân în change tracker dar nu apar la query-uri. Tracker-ul nu e o BD — SaveChanges face „commit-ul".

Pasul 6 — Testele CRUD

GetAllAsync

[Fact]
public async Task GetAllAsync_ReturnsAllArticles()
{
    // Arrange
    var (service, context) = CreateService(nameof(GetAllAsync_ReturnsAllArticles));
    SeedTwoArticles(context);

    // Act
    var result = await service.GetAllAsync();

    // Assert
    Assert.Equal(2, result.Count);
}

[Fact]
public async Task GetAllAsync_EmptyDatabase_ReturnsEmptyList()
{
    var (service, _) = CreateService(nameof(GetAllAsync_EmptyDatabase_ReturnsEmptyList));

    var result = await service.GetAllAsync();

    Assert.Empty(result);
}

Două scenarii: cu date și fără date. Pe „fără date" verificăm că service-ul nu aruncă și întoarce o listă goală — nu null, ci listă goală.

GetByIdAsync

[Fact]
public async Task GetByIdAsync_ExistingId_ReturnsArticle()
{
    var (service, context) = CreateService(nameof(GetByIdAsync_ExistingId_ReturnsArticle));
    SeedTwoArticles(context);

    var result = await service.GetByIdAsync(1);

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

[Fact]
public async Task GetByIdAsync_InvalidId_ReturnsNull()
{
    var (service, context) = CreateService(nameof(GetByIdAsync_InvalidId_ReturnsNull));
    SeedTwoArticles(context);

    var result = await service.GetByIdAsync(999);

    Assert.Null(result);
}

Assert.NotNull(result) activează nullable narrowing în compilator — după el, result!.Title nu mai dă warning. Operatorul ! e doar un „știu eu că nu e null", nu mai e necesar dacă xUnit recunoaște NotNull ca null check (în versiuni recente o face). Lăsăm ! ca să fim defensivi.

AddAsync

[Fact]
public async Task AddAsync_IncreasesCount()
{
    var (service, context) = CreateService(nameof(AddAsync_IncreasesCount));
    SeedTwoArticles(context);

    var newArticle = new Article
    {
        Title = "Articol nou",
        Content = "Continut suficient de lung pentru validare",
        CategoryId = 1,
        AuthorId = "user-1"
    };
    await service.AddAsync(newArticle);

    var all = await service.GetAllAsync();
    Assert.Equal(3, all.Count);
}

[Fact]
public async Task AddAsync_SetsPublishedAt()
{
    var (service, context) = CreateService(nameof(AddAsync_SetsPublishedAt));
    SeedTwoArticles(context);

    var newArticle = new Article
    {
        Title = "Articol cu data",
        Content = "Continut suficient de lung pentru validare",
        CategoryId = 1,
        AuthorId = "user-1",
        PublishedAt = DateTime.MinValue
    };
    await service.AddAsync(newArticle);

    Assert.True(newArticle.PublishedAt > DateTime.MinValue);
}

Al doilea test e interesant — verificăm că service-ul suprascrie PublishedAt cu DateTime.Now (uitați-vă în ArticleService.AddAsync). Fără acest test, dacă cineva șterge linia article.PublishedAt = DateTime.Now; din service, restul testelor încă trec — dar comportamentul s-a schimbat.

DeleteAsync

[Fact]
public async Task DeleteAsync_ExistingId_RemovesArticle()
{
    var (service, context) = CreateService(nameof(DeleteAsync_ExistingId_RemovesArticle));
    SeedTwoArticles(context);

    await service.DeleteAsync(1);

    var all = await service.GetAllAsync();
    Assert.Single(all);
    Assert.Null(await service.GetByIdAsync(1));
}

[Fact]
public async Task DeleteAsync_InvalidId_DoesNotThrow()
{
    var (service, context) = CreateService(nameof(DeleteAsync_InvalidId_DoesNotThrow));
    SeedTwoArticles(context);

    await service.DeleteAsync(999);

    var all = await service.GetAllAsync();
    Assert.Equal(2, all.Count);
}

Cazul „idempotent" e important — service-ul nu trebuie să arunce dacă încercăm să ștergem un id inexistent. Dacă cineva schimbă comportamentul ca să arunce KeyNotFoundException, testul al doilea pică imediat.

UpdateAsync

[Fact]
public async Task UpdateAsync_ModifiesTitle()
{
    var (service, context) = CreateService(nameof(UpdateAsync_ModifiesTitle));
    SeedTwoArticles(context);

    var article = await service.GetByIdAsync(1);
    article!.Title = "Titlu modificat";
    await service.UpdateAsync(article);

    var updated = await service.GetByIdAsync(1);
    Assert.Equal("Titlu modificat", updated!.Title);
}

Pasul 7 — [Theory] în acțiune

Pentru GetByIdAsync putem comprima 5 teste într-unul singur:

[Theory]
[InlineData(1, true)]
[InlineData(2, true)]
[InlineData(0, false)]
[InlineData(-1, false)]
[InlineData(999, false)]
public async Task GetByIdAsync_ReturnsExpected(int id, bool shouldExist)
{
    var (service, context) = CreateService($"GetByIdAsync_Theory_{id}");
    SeedTwoArticles(context);

    var result = await service.GetByIdAsync(id);

    Assert.Equal(shouldExist, result is not null);
}

Atenție la dbName-ul interpolat — fiecare caz primește o BD InMemory proprie.

Pasul 8 — Rulare și ce ar trebui să vedeți

cd C:\Daniel\ore\DAW-2025-2026\labs\lab09\Lab09_start
dotnet test

Cu testele din această parte (10 metode + 5 cazuri din [Theory]):

Passed!  - Failed:     0, Passed:    16, Skipped:     0, Total:    16

(Plus SmokeTest din Partea 1 = 16. Numărul exact depinde de ce ați scris la fiecare exercițiu.)

Footgun frecvent: dbName partajat între teste. Dacă două teste folosesc același dbName, datele sunt partajate. Testul GetAllAsync_ReturnsAllArticles va vedea articolele lăsate de AddAsync_IncreasesCount și va pica pentru că count-ul e altul. Soluția: nameof(metoda) ca dbName, sau alt string unic per test.