Vertical Slice'ları Test Etmek: Slice Başına Integration Test ve Gerektiğinde Unit Test
Bir slice'ın doğal test birimi HTTP isteğinden gerçek veritabanına kadar olan yoldur; domain kuralları ise saf unit test'le, ucuzca test edilir. WebApplicationFactory, Testcontainers ile gerçek SQL Server ve Respawn ile slice başına test kuruyor, sıfırlama olmadan test'lerin nasıl birbirini kırdığını ve InMemory provider'ın aynı arama sorgusunda hem yanlış geçip hem yanlış kaldığını gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'a kitap arama slice'ı ekleniyor. Kullanıcı "dune" yazınca "Dune" da bulunsun diye developer karşılaştırmayı açıkça büyük/küçük harfe duyarsız yapıyor:
app.MapGet("/books", (string term, BookstoreDbContext db, CancellationToken ct) =>
db.Books
.Where(b => b.Title.Contains(term, StringComparison.OrdinalIgnoreCase))
.OrderBy(b => b.Title)
.Select(b => new BookSummary(b.Id, b.Title, b.Price))
.ToListAsync(ct));
Slice'ın test'i hızlı olsun diye Entity Framework Core'un (EF Core) InMemory provider'ıyla yazılmış ve geçiyor:
[Fact]
public async Task Search_ignores_case()
{
HttpClient client = factory.CreateClient();
List<BookSummary>? books = await client.GetFromJsonAsync<List<BookSummary>>("/api/books?term=dune", TestContext.Current.CancellationToken);
BookSummary book = Assert.Single(books!);
Assert.Equal("Dune", book.Title);
}
passed (851ms)
Test run summary: Passed!
total: 1
failed: 0
succeeded: 1
Tahmin et: aynı slice production'daki SQL (Structured Query Language) Server veritabanına karşı çalıştığında "dune" araması ne döner?
Slice'ın Doğal Test Birimi
Layered bir projede test'ler genellikle katmanları izler: service'in unit test'i, repository'nin sahtesiyle; controller'ın unit test'i, service'in sahtesiyle. Bogard'ın 2018 tarihli VSA (Vertical Slice Architecture) yazısında layered mimarileri "mock-heavy" diye nitelemesi buna gönderme. Bir slice'ta ise bu sınırlar çoğu zaman yok: endpoint, validation, handler, sorgu ve veritabanı tek bir davranışı birlikte oluşturuyor. Arama slice'ının doğru çalışıp çalışmadığı, LINQ (Language Integrated Query) sorgusunun hangi SQL'e çevrildiğine ve veritabanının o SQL'i nasıl çalıştırdığına bağlı. Bunu bir mock'la test etmek, test edilmesi gereken şeyi test dışında bırakmak demek.
Bu yüzden VSA'da varsayılan test birimi slice'ın kendisi: HTTP (Hypertext Transfer Protocol) isteğinden veritabanına kadar. ASP.NET Core Web API (Application Programming Interface) serisinin "ASP.NET Core Web API'de Integration Test: WebApplicationFactory ile Uçtan Uca Test" yazısındaki WebApplicationFactory uygulamayı bellekte ayağa kaldırıyor; eksik olan, production'daki veritabanının aynısı. Saf unit test'lerin yeri de var ama farklı: kuralların yaşadığı domain model.
Bu, klasik test piramidini ters çevirmek anlamına gelmiyor; piramidin ortasındaki katmanın tanımı değişiyor. Layered bir projede "integration test" çoğu zaman birkaç class'ı birlikte çalıştıran ama veritabanını yine sahteleyen bir test olur. Slice'ta ise ortadaki katman, gerçek veritabanıyla ama tarayıcısız ve dış servisler sahtelenmiş hâlde, uygulamanın bir isteğini baştan sona koşturan test. Ödeme sağlayıcısı gibi dış sistemler sahte kalıyor (FakePaymentGateway), çünkü onların davranışı bizim kontrolümüzde değil; veritabanı ise uygulamanın bir parçası, sahtelenmiyor.
Gerçek Veritabanı: WebApplicationFactory ve Testcontainers
Testcontainers, test sırasında Docker'da gerçek bir SQL Server container'ı başlatıp test bitince kaldırıyor. Bütün test'ler için tek bir container ve tek bir uygulama:
using Bookstore.Api.Domain;
using Bookstore.Api.Infrastructure;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Respawn;
using Testcontainers.MsSql;
namespace Bookstore.Api.Tests;
// Bütün test'ler için tek container, tek uygulama
public sealed class BookstoreApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly MsSqlContainer _sql = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2025-latest").Build();
private Respawner _respawner = null!;
public async ValueTask InitializeAsync()
{
await _sql.StartAsync();
using IServiceScope scope = Services.CreateScope();
BookstoreDbContext db = scope.ServiceProvider.GetRequiredService<BookstoreDbContext>();
await db.Database.EnsureCreatedAsync();
db.Books.AddRange(
new Book { Title = "Dune", Price = 12.50m },
new Book { Title = "Emma", Price = 8.00m });
await db.SaveChangesAsync();
// Kitaplar referans veri: her test'ten önce yalnızca sipariş tabloları temizlenir
await using SqlConnection connection = new(_sql.GetConnectionString());
await connection.OpenAsync();
_respawner = await Respawner.CreateAsync(connection, new RespawnerOptions
{
TablesToIgnore = ["Books"]
});
}
public async Task ResetDatabaseAsync()
{
await using SqlConnection connection = new(_sql.GetConnectionString());
await connection.OpenAsync();
await _respawner.ResetAsync(connection);
}
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.UseSetting("ConnectionStrings:Bookstore", _sql.GetConnectionString());
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
await _sql.DisposeAsync();
}
}
[CollectionDefinition(nameof(ApiCollection))]
public sealed class ApiCollection : ICollectionFixture<BookstoreApiFactory>;
Uygulamanın kendisi değişmedi: Program.cs connection string'i configuration'dan okuyor, factory de UseSetting ile container'ınkini veriyor. Testcontainers.MsSql 4.16'da image'ı constructor'a vermek gerekiyor; eski new MsSqlBuilder().WithImage(...) yazımı artık CS0618 uyarısı veriyor. SQL Server'ın SqlClient üzerinden getirdiği System.Security.Cryptography.Pkcs 9.0.11 için NuGet, bilinen güvenlik açıkları için NU1902 (orta) ve NU1903 (yüksek) uyarıları verdi; API project'ine paketin 10.0.14 sürümünü açıkça ekleyerek kapattım. Factory bir xUnit collection fixture: bütün test class'ları aynı container'ı ve aynı uygulamayı paylaşıyor, container test başına değil test koşusu başına bir kez başlıyor.
Slice Başına Test
Her slice'ın test class'ı, kullanıcının o slice'tan beklediği davranışları HTTP üzerinden deniyor:
using System.Net;
using System.Net.Http.Json;
using Bookstore.Api.Features.Orders.PlaceOrder;
using Microsoft.AspNetCore.Mvc;
namespace Bookstore.Api.Tests;
public sealed class PlaceOrderTests(BookstoreApiFactory factory) : SliceTestBase(factory)
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Fact]
public async Task Places_order_and_returns_total()
{
HttpResponseMessage response = await Client.PostAsJsonAsync("/api/orders",
new { customerId = 7, lines = new[] { new { bookId = 1, quantity = 2 }, new { bookId = 2, quantity = 1 } } }, Ct);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
PlaceOrderResponse? body = await response.Content.ReadFromJsonAsync<PlaceOrderResponse>(Ct);
Assert.Equal(33.00m, body!.Total);
}
[Fact]
public async Task Rejects_more_than_five_copies_across_lines()
{
HttpResponseMessage response = await Client.PostAsJsonAsync("/api/orders",
new { customerId = 7, lines = new[] { new { bookId = 1, quantity = 3 }, new { bookId = 1, quantity = 4 } } }, Ct);
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
ProblemDetails? problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(Ct);
Assert.Equal("At most 5 copies of book 1 per order.", problem!.Detail);
}
[Fact]
public async Task Rejects_invalid_input_before_the_handler_runs()
{
HttpResponseMessage response = await Client.PostAsJsonAsync("/api/orders",
new { customerId = 0, lines = new[] { new { bookId = 1, quantity = 9 } } }, Ct);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
Request gövdesi bilerek anonim bir object: test, slice'ın request record'unu değil HTTP sözleşmesini kullanıyor; record'da bir property'nin adı değişirse test bunu yakalıyor. Bazı davranışlar HTTP cevabında görünmez. 04. yazıdaki reddedilen ödeme hatası tam böyleydi: cevap 402'ydi ama sipariş veritabanına yazılmıştı. Bu test o yüzden veritabanına da bakıyor:
using System.Net;
using System.Net.Http.Json;
using Bookstore.Api.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Bookstore.Api.Tests;
public sealed class CheckoutTests(BookstoreApiFactory factory) : SliceTestBase(factory)
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Fact]
public async Task Declined_payment_leaves_no_order_behind()
{
HttpResponseMessage response = await Client.PostAsJsonAsync("/api/checkout",
new { customerId = 8, lines = new[] { new { bookId = 2, quantity = 2 } }, cardToken = "declined" }, Ct);
Assert.Equal(HttpStatusCode.PaymentRequired, response.StatusCode);
using IServiceScope scope = Factory.Services.CreateScope();
BookstoreDbContext db = scope.ServiceProvider.GetRequiredService<BookstoreDbContext>();
Assert.Equal(0, await db.Orders.CountAsync(Ct));
}
[Fact]
public async Task Successful_payment_stores_a_paid_order()
{
HttpResponseMessage response = await Client.PostAsJsonAsync("/api/checkout",
new { customerId = 8, lines = new[] { new { bookId = 2, quantity = 2 } }, cardToken = "tok_ok" }, Ct);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
string details = await Client.GetStringAsync(response.Headers.Location, Ct);
Assert.Contains("\"status\":\"Paid\"", details);
}
}
Bir slice için kaç test yazmalı? Pratikte işe yarayan bir ölçü, slice'ın dönebileceği her farklı HTTP cevabı için bir test: başarılı yol, input validation hatası (400), iş kuralı ihlali (422), bulunamayan kayıt (404), yetkisiz erişim (401 ya da 403). Test adları da bu davranışları anlatır: Rejects_more_than_five_copies_across_lines, okuyana handler'ın içini değil slice'ın sözünü söylüyor. Bu test'ler slice'ın iç yapısına bağlı olmadığı için, 04. yazıdaki gibi bir kuralı handler'dan entity'ye taşıdığında hiçbiri değişmedi; refactoring'in güvenlik ağı tam olarak bu.
Veritabanını kontrol ederken yeni bir scope ve yeni bir DbContext açıyoruz; isteğin kullandığı context'e bakmak, kaydedilmemiş değişiklikleri de görmek demek olurdu.
Test'ler Arası İzolasyon: Respawn
Bütün test'ler aynı veritabanını paylaştığına göre biri ötekinin bıraktığı veriyi görebilir. Her test'ten önce veritabanını sıfırlayan küçük bir base class:
namespace Bookstore.Api.Tests;
// Her test temiz bir sipariş tablosuyla başlar
[Collection(nameof(ApiCollection))]
public abstract class SliceTestBase(BookstoreApiFactory factory) : IAsyncLifetime
{
protected BookstoreApiFactory Factory { get; } = factory;
protected HttpClient Client { get; } = factory.CreateClient();
public async ValueTask InitializeAsync() => await Factory.ResetDatabaseAsync();
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
Jimmy Bogard'ın Respawn library'si (Apache 2.0, sürüm 7.0.0) şemayı bir kez okuyup foreign key sırasına göre DELETE komutları üretiyor; TablesToIgnore ile kitaplar gibi referans veriler korunuyor. Sıfırlamayı kapattığım bir kopyada sonuç:
passed Bookstore.Api.Tests.PlaceOrderTests.Places_order_and_returns_total (45ms)
passed Bookstore.Api.Tests.CheckoutTests.Successful_payment_stores_a_paid_order (46ms)
failed Bookstore.Api.Tests.CheckoutTests.Declined_payment_leaves_no_order_behind (9ms)
Assert.Equal() Failure: Values differ
Expected: 0
Actual: 2
Test, kendi işini doğru yapan bir slice yüzünden değil, başka test'lerin bıraktığı iki sipariş yüzünden kırıldı; xUnit'in test sırası garanti olmadığı için de bu hata bazı koşularda çıkar, bazılarında çıkmaz. En kötü test türü budur.
Alternatif, her test'i bir transaction içinde çalıştırıp sonunda rollback yapmak. EF Core'un kendi dokümanı da bu tekniği anlatıyor; ancak WebApplicationFactory ile uygulama her request'te kendi DbContext'ini ve connection'ını açtığı için test'in transaction'ını uygulamaya taşımak, production koduna test için kanca eklemeyi gerektiriyor. Respawn'ın bedeli ise her test başına birkaç DELETE; aşağıdaki sürelerde görüldüğü gibi küçük bir şema için fark edilmiyor.
Domain Kurallarına Saf Unit Test
04. yazıda Order'a taşıdığımız invariant'lar ise HTTP'ye de veritabanına da ihtiyaç duymuyor. Onlar için klasik unit test doğru araç:
using Bookstore.Api.Domain;
namespace Bookstore.Api.Tests;
// Domain kuralları: veritabanı yok, HTTP yok, milisaniyeler
public sealed class OrderTests
{
private static Order NewOrder() => new(customerId: 7, placedAtUtc: new DateTime(2026, 11, 8, 10, 0, 0, DateTimeKind.Utc));
[Fact]
public void Adding_the_same_book_twice_merges_the_line()
{
Order order = NewOrder();
order.AddItem(bookId: 1, quantity: 2, unitPrice: 12.50m);
order.AddItem(bookId: 1, quantity: 3, unitPrice: 12.50m);
OrderItem item = Assert.Single(order.Items);
Assert.Equal(5, item.Quantity);
Assert.Equal(62.50m, order.Total);
}
[Fact]
public void Sixth_copy_is_rejected()
{
Order order = NewOrder();
order.AddItem(1, 5, 12.50m);
DomainException ex = Assert.Throws<DomainException>(() => order.AddItem(1, 1, 12.50m));
Assert.Equal("At most 5 copies of book 1 per order.", ex.Message);
}
[Fact]
public void Cancelled_order_cannot_change()
{
Order order = NewOrder();
order.Cancel();
Assert.Throws<DomainException>(() => order.AddItem(2, 1, 8.00m));
Assert.Throws<DomainException>(order.MarkPaid);
}
}
Kuralın bütün kenar durumlarını (tam 5, 6, iptal, ödenmiş) burada ucuzca denersin; slice test'i ise kuralın HTTP'ye doğru biçimde, 422 ve doğru mesajla yansıdığını bir kez doğrular. Kural slice'ta, handler'ın içinde dursaydı, her kenar durumu için bir HTTP test'i yazmak gerekirdi. Domain'e taşınan her kural, test'leri de ucuzlatıyor.
Çalışma Süresini Yönetmek
Bütün suite'in bu makinedeki koşusu:
passed Bookstore.Api.Tests.OrderTests.Sixth_copy_is_rejected (4ms)
passed Bookstore.Api.Tests.OrderTests.Adding_the_same_book_twice_merges_the_line (3ms)
passed Bookstore.Api.Tests.OrderTests.Cancelled_order_cannot_change (0ms)
passed Bookstore.Api.Tests.CheckoutTests.Successful_payment_stores_a_paid_order (231ms)
passed Bookstore.Api.Tests.CheckoutTests.Declined_payment_leaves_no_order_behind (21ms)
passed Bookstore.Api.Tests.PlaceOrderTests.Rejects_more_than_five_copies_across_lines (12ms)
passed Bookstore.Api.Tests.PlaceOrderTests.Places_order_and_returns_total (16ms)
passed Bookstore.Api.Tests.PlaceOrderTests.Rejects_invalid_input_before_the_handler_runs (3ms)
passed Bookstore.Api.Tests.SearchBooksTests.Search_ignores_case (14ms)
failed: 0
duration: 9s 236ms
Dokuz saniyenin büyük kısmı tek seferlik: Testcontainers'ın log'una göre SQL Server container'ının oluşturulmasından hazır olmasına kadar yaklaşık 6 saniye geçti (Apple Silicon'da x64 image emülasyonla çalışıyor). İlk HTTP test'i 231 ms ile uygulamanın ve EF Core model'inin ısınmasını taşıyor, sonrakiler 3–21 ms. Slice sayısı arttıkça süreyi kontrol altında tutmak için birkaç kural:
- Container'ı test başına değil koşu başına bir kez başlat (collection fixture); veritabanını yeniden oluşturmak yerine Respawn ile temizle.
- Kenar durumlarını domain unit test'lerine, slice test'lerini her slice için birkaç temsilî senaryoya bırak.
- Aynı veritabanını paylaşan test'ler tek bir collection'da sırayla çalışır; paralellik gerekiyorsa her collection'a ayrı bir veritabanı ver.
- CI'da (continuous integration) container image'ını önceden çek ya da cache'le; ilk koşudaki süre çoğunlukla indirme süresidir.
Tuzak: InMemory Provider
Açılıştaki soruya dönelim. Aynı test, Testcontainers'taki SQL Server'a karşı:
System.InvalidOperationException: The LINQ expression 'DbSet<Book>()
.Where(b => b.Title.Contains(
value: @term,
comparisonType: OrdinalIgnoreCase))' could not be translated. Additional information: Translation of method 'string.Contains' failed. If this method can be mapped to your custom function, see https://go.microsoft.com/fwlink/?linkid=2132413 for more information. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to 'AsEnumerable', 'AsAsyncEnumerable', 'ToList', or 'ToListAsync'. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.
InMemory provider sorguyu SQL'e çevirmiyor, LINQ'yu .NET'te bellekte çalıştırıyor; StringComparison parametreli Contains orada sorunsuz. SQL Server ise bu overload'u SQL'e çeviremiyor. Test yeşil, production'da her arama 500. Düzeltme, karşılaştırmayı veritabanına bırakmak:
public sealed class SearchBooksEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder app) =>
app.MapGet("/books", (string term, BookstoreDbContext db, CancellationToken ct) =>
db.Books
// SQL Server'da LIKE'a çevrilir; büyük/küçük harf duyarlılığını collation belirler
.Where(b => b.Title.Contains(term))
.OrderBy(b => b.Title)
.Select(b => new BookSummary(b.Id, b.Title, b.Price))
.ToListAsync(ct));
}
SQL Server'ın varsayılan collation'ı büyük/küçük harfe duyarsız olduğu için Testcontainers test'i artık geçiyor. Bu sefer InMemory test'i kırılıyor, çünkü .NET'teki Contains büyük/küçük harfe duyarlı:
failed Bookstore.Api.InMemoryTests.SearchBooksTests.Search_ignores_case (314ms)
Assert.Single() Failure: The collection was empty
InMemory provider hem yanlış yerde geçti hem yanlış yerde kaldı. EF Core'un "Choosing a testing strategy" dokümanı bu provider'ın test için kullanılmasını açıkça önermiyor ve yalnızca eski uygulamalar için desteklendiğini söylüyor; transaction'ları yok sayması ve raw SQL'i hiç desteklememesi de aynı listede. EF Core serisinin "EF Core ile Test: SQLite In-Memory ve InMemory Provider Tuzakları" yazısında SQLite in-memory'nin daha iyi ama yine farklı bir veritabanı olduğunu görmüştük; büyük/küçük harf duyarlılığı orada da SQL Server'dan farklı.
Sık Düşülen Tuzaklar
DbContext'i ve DbSet'i mock'lamak. Sorgular IQueryable üzerindeki extension method'lar olduğu için gerçek bir mock mümkün değil; "mock'lanmış DbSet" pratikte bellekteki bir liste, yani InMemory provider'ın sorunlarının aynısı.
Handler'ı unit test'le, slice'ı hiç test etmemek. Handler test'i geçen ama validation attribute'u unutulmuş, route'u yanlış yazılmış ya da exception handler'ı kaydedilmemiş bir slice, kullanıcı açısından bozuk. Slice başına en az bir HTTP test'i bu bağlantıları doğrular.
Test'leri birbirine bağımlı yazmak. "Önce sipariş veren test çalışsın, sonra iptal eden" düzeni, sıranın garanti olmadığı bir ortamda kırılgan. Her test ihtiyaç duyduğu veriyi kendisi kurar: iptal test'i önce kendi siparişini verir.
Production'da olmayan bir veritabanı ayarıyla test etmek. Container'daki SQL Server'ın collation'ı, sürümü ya da compatibility level'ı production'dan farklıysa, Testcontainers da InMemory gibi yanıltabilir. Image etiketini production'la aynı tut.
Küçük Bir Deneme
★☆☆ Arama slice'ını önce StringComparison.OrdinalIgnoreCase'li hâliyle Testcontainers test'lerine karşı çalıştır ve hata mesajını oku. Sonra aynı sorguyu EF.Functions.Like ile yaz ve iki test projesinde de sonucu karşılaştır.
★★☆ CancelOrder için bir slice test'i yaz: test kendi siparişini versin, iptal etsin ve iptal edilmiş siparişe kitap eklemenin 422 döndüğünü doğrulasın. Test'i tek başına ve bütün suite içinde çalıştır.
★★★ Respawn yerine transaction rollback ile izolasyon kur: BookstoreDbContext'i test'in açtığı tek bir connection ve transaction'la çalışacak şekilde factory'de yeniden kaydet. Production koduna ne eklemen gerekti? Çalışma süresini Respawn'lı hâliyle karşılaştır.
Böylece slice'ların nasıl yazılacağı, paylaşılacağı ve test edileceği tamamlandı. Serinin son yazısında karar sorusuna geçiyoruz: Vertical Slice Architecture ne zaman uygun, ne zaman değil, Clean Architecture ve Modular Monolith ile nasıl birlikte kullanılır ve layered bir proje slice'lara nasıl kademeli taşınır.