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:
- 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. - Stack real cu BD în memorie —
Service → 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.
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:
dbNameunic per test. InMemory provider leagă datele de un nume — două teste cu același nume împart datele. Folosimnameof(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 —SaveChangesface „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!.Titlenu mai dă warning. Operatorul!e doar un „știu eu că nu e null", nu mai e necesar dacă xUnit recunoașteNotNullca 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. TestulGetAllAsync_ReturnsAllArticlesva vedea articolele lăsate deAddAsync_IncreasesCountși va pica pentru că count-ul e altul. Soluția:nameof(metoda)ca dbName, sau alt string unic per test.