Gerçek veri tabanıyla .NET entegrasyon testleri: hızlı, yalıtılmış, güvenilir
Birim testleriniz yeşil, entegrasyon ortamında ise ilk istek hata veriyor. Çoğu ekip bu durumu en az bir kez yaşar. Sebep genellikle testlerin gerçek sistemin önemli bir parçasını, yani veri tabanını taklit etmesidir. Bellek içi sahte bir depo, gerçek bir SQL motorunun kısıtlarını, çeviri davranışlarını ve eşzamanlılık kurallarını bilmez.
Bu yazıda ASP.NET Core API'leri için gerçek veri tabanıyla çalışan entegrasyon testlerini nasıl hızlı, yalıtılmış ve güvenilir tutabileceğinizi anlatıyorum. Örnekler C# ve EF Core üzerinden, ama fikirler başka yığınlara da taşınır.
Neden bellek içi sahteler yetmez?
EF Core'un bellek içi sağlayıcısı kullanışlıdır, fakat bir ilişkisel veri tabanı gibi davranmaz. Yabancı anahtar kısıtlarını, benzersiz indeks ihlallerini, işlem yalıtım düzeylerini ve LINQ ifadelerinin gerçek SQL'e çevrilmesini aynı biçimde sınamaz. Bellek içinde çalışan bir sorgu, üretimde çevrilemeyip çalışma zamanında hata verebilir.
SQLite ile bellek içi test bir adım daha yakındır ama o da farklı bir motordur. Üretimde PostgreSQL veya SQL Server kullanıyorsanız, farkların bir kısmı hâlâ gizli kalır. Kural basit: testin değeri, ürettiği güvenin gerçeğe yakınlığıyla orantılıdır. Veri erişimini sınıyorsanız aynı motoru kullanın.
Konteynerde gerçek veri tabanı
Testlerin sunucu kurulumuna bağlı kalmaması için veri tabanını test süreci içinde konteyner olarak başlatmak yaygın bir yöntemdir. Testcontainers gibi kütüphaneler bunu kod içinden yönetir: konteyneri başlatır, bağlantı dizesini verir, iş bitince kaldırır.
public sealed class DatabaseFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container =
new PostgreSqlBuilder().Build();
public string ConnectionString => _container.GetConnectionString();
public async Task InitializeAsync()
{
await _container.StartAsync();
await using var db = CreateContext();
await db.Database.MigrateAsync();
}
public Task DisposeAsync() => _container.DisposeAsync().AsTask();
public AppDbContext CreateContext() =>
new(new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(ConnectionString).Options);
}
Önemli ayrıntı şu: şemayı EnsureCreated ile değil, gerçek migrasyonlarla kurun. Böylece migrasyonların kendisi de her test çalışmasında doğrulanmış olur. Konteyner başlatmak pahalıdır; bu yüzden her test için değil, test koleksiyonu başına bir kez başlatın.
WebApplicationFactory ile uçtan uca istek
API'yi HTTP düzeyinde sınamak için WebApplicationFactory<TEntryPoint> kullanılır. Uygulama süreç içinde ayağa kalkar, test bir HttpClient ile istek atar. Burada tek yapılacak iş, bağlantı dizesini test veri tabanına yönlendirmektir.
public sealed class ApiFactory(string connectionString)
: WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(o =>
o.UseNpgsql(connectionString));
});
}
}
Bu yaklaşımda yönlendirme, model bağlama, doğrulama, yetkilendirme ve veri erişimi gerçek hâliyle çalışır. Yalnızca sınırın dışındaki bağımlılıkları, örneğin e-posta gönderimini veya üçüncü taraf bir HTTP servisini, test çiftleriyle değiştirin. Kendi veri tabanınızı değiştirmeyin.
Test yalıtımı: testler birbirini etkilememeli
Paylaşılan bir veri tabanında testler birbirinin verisini görürse sonuçlar sıraya bağlı hâle gelir ve hata ayıklamak zorlaşır. Üç yaygın strateji vardır:
- Her testte işlem açıp geri almak. Hızlıdır, ancak uygulama kendi işlemini yönetiyorsa veya birden fazla bağlantı kullanıyorsa kırılgan olabilir.
- Her testten sonra tabloları temizlemek. Basit ve anlaşılırdır. Yabancı anahtar sırasına dikkat etmek gerekir.
- Her test için ayrı şema veya veri tabanı. En güçlü yalıtımı verir, ama kurulum maliyeti daha yüksektir.
Pratikte ikinci yöntemi öneririm. Tabloları temizleyen küçük bir yardımcı, her testten önce çalışır. Şema bilgisinden silme sırasını çıkaran hazır kütüphaneler de var; sıfırdan yazmak yerine onları değerlendirebilirsiniz.
public async Task ResetAsync()
{
await using var db = CreateContext();
await db.Database.ExecuteSqlRawAsync(
"TRUNCATE TABLE orders, customers RESTART IDENTITY CASCADE");
}
Testi okunur tutmak
Entegrasyon testlerinin sık görülen sorunu, hazırlık kodunun asıl amacı gölgelemesidir. Test verisini üreten küçük yardımcılar yazın ve testin gövdesini üç adıma indirin: durumu hazırla, isteği gönder, sonucu doğrula.
[Fact]
public async Task Siparis_olusturulur_ve_listelenir()
{
await _db.ResetAsync();
var musteriId = await _db.SeedCustomerAsync("Ayşe");
var response = await _client.PostAsJsonAsync("/orders",
new { customerId = musteriId, items = new[] { new { sku = "A1", qty = 2 } } });
response.StatusCode.Should().Be(HttpStatusCode.Created);
var list = await _client.GetFromJsonAsync<List<OrderDto>>("/orders");
list.Should().ContainSingle(o => o.CustomerId == musteriId);
}
Doğrulamayı yalnızca yanıt koduyla bitirmeyin. Veriyi gerçekten yazıp okuduğunuzu ikinci bir istekle ya da doğrudan veri tabanı sorgusuyla teyit edin. Aksi hâlde endpoint 201 döner ama kayıt eksik kalır ve test yine geçer.
Hızlı kalmak: CI'da maliyeti yönetmek
Gerçek veri tabanı testlerinin ana itirazı yavaş olmalarıdır. Bu itiraz kısmen haklıdır, ama yönetilebilir:
- Konteyneri her sınıf için değil, paylaşılan bir fixture ile koleksiyon başına bir kez başlatın.
- Migrasyonları bir kez uygulayın; testler arasında yalnızca veriyi sıfırlayın.
- Aynı konteyneri paylaşan testleri paralel çalıştırmak yerine koleksiyonlara ayırın veya her koleksiyona ayrı veri tabanı verin.
- Testleri katmanlandırın: iş kuralları için hızlı birim testleri, sorgu ve uçtan uca akışlar için daha az sayıda entegrasyon testi.
CI tarafında konteyner çalıştırabilen bir ortam gerekir. Görüntüyü önceden çekmek ya da önbelleğe almak ilk çalıştırma süresini belirgin biçimde kısaltabilir. Etkisini kendi boru hattınızda ölçün; kazanç ortama göre değişir.
Sık yapılan hatalar
Birincisi, entegrasyon testlerini her şeyi kapsayan bir araç sanmaktır. Her iş kuralı için HTTP üzerinden test yazmak süiti yavaşlatır. Entegrasyon testlerini, bileşenlerin birlikte doğru çalıştığını gösteren kritik akışlara saklayın.
İkincisi, testlerde zamanı ve rastgeleliği kontrol etmemektir. DateTime.UtcNow doğrudan kullanılıyorsa testler belirli saatlerde kırılır. Zamanı soyutlayıp testte sabitleyin.
Üçüncüsü, başarısız testin çıktısını yetersiz bırakmaktır. Hata anında yanıt gövdesini ve ilgili veriyi mesaja ekleyin. CI'da bir hatayı yerelde yeniden üretmek zorunda kalmak, süitin değerini düşürür.
Kapanış
Gerçek veri tabanıyla yazılmış entegrasyon testleri, sahtelerin gizlediği hataları test aşamasında yakalar: eksik indeksler, kırık migrasyonlar, çevrilemeyen sorgular. Bunun için yalnızca üç alışkanlık gerekir: üretimle aynı motoru kullanmak, her testi temiz bir durumdan başlatmak ve maliyeti paylaşılan fixture'larla düşük tutmak.
Küçük başlayın. En kritik akışınız için tek bir WebApplicationFactory testi yazın, çalışır hâle getirin ve süiti oradan büyütün. Güven, test sayısıyla değil, testlerin neyi gerçekten sınadığıyla artar.