Testcontainers ile Gerçek SQL Server: Container Yaşam Döngüsü, Migration ve Testler Arası Temizlik
SQL Server container'ını assembly başına bir kez başlat, test class'ı başına ayrı veritabanı aç, şemayı EnsureCreated ile değil migration'larla kur ve her testten önce Respawn ile temizle. Transaction rollback, test başına veritabanı ve paylaşılan veritabanında Respawn'ı aynı on testle ölçüyor; PreserveExecutionContext olmadan rollback'in sessizce çalışmadığını ve container reuse'un gerçek bedelini gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, xUnit.net v3 4.0.3, Testcontainers for .NET 4.16, Respawn 7.0, Entity Framework Core 10.0.15 ve SQL Server 2025 ile derlenip denenmiştir.
Önce Tahmin Et
1. yazının sonunda iki test aynı tabloyu paylaştığı için biri kalıyordu. Çözüm "her test temiz bir veritabanıyla başlasın" ama bunun en az üç yolu var. Üçünü aynı on testle denedim. Her test bir kitap ekliyor ve listede tam olarak bir kitap görmeyi bekliyor; yani yalnız izolasyon gerçekten çalışırsa geçebiliyor:
public static async Task CreateAndExpectSingle(HttpClient admin, string isbn)
{
HttpResponseMessage created = await admin.PostAsJsonAsync("/api/books", new CreateBookRequest(isbn, "Isolation", 10m, 1));
created.EnsureSuccessStatusCode();
List<BookDto>? books = await admin.GetFromJsonAsync<List<BookDto>>("/api/books");
Assert.Single(books!);
}
Birinci yol: her testten önce Respawn ile bütün tabloları boşaltmak. İkincisi: her testi bir TransactionScope içinde koşturup sonunda commit etmeden bırakmak. Üçüncüsü: her test için migration'larla sıfırdan bir veritabanı oluşturmak. On testin toplam süresinde hangisi en hızlı, hangisi en yavaş? Ve hangisi ilk denemede hiçbir hata vermeden yanlış çalışır?
Cevap: Üç Yol, Üç Bedel
Ölçtüğüm toplam süreler, host'un ilk açılışı dahil:
| Yöntem | 10 test | Test başına ek maliyet | Paralel class'larla |
|---|---|---|---|
| Respawn ile temizlik | 675 ms | ~10 ms | aynı veritabanında güvensiz |
| TransactionScope + rollback | 100 ms | birkaç ms | güvenli, ama koşullu çalışıyor |
| Test başına veritabanı | 3742 ms | ~350 ms | güvenli |
En hızlısı transaction rollback, ama ilk denemede on testin dokuzu kaldı ve veritabanında testlerden kalan satırlar birikti. Sebebi aşağıda; kısa cevabı şu: ambient transaction, siz ayarlamadıkça TestServer'ın içindeki isteğe ulaşmıyor. Test başına veritabanı en sağlam ama en yavaş yol; her test migration'ları baştan uyguluyor ve yeni bir host açıyor. Respawn ikisinin arasında, ama paylaşılan bir veritabanında paralel çalışan class'larla birlikte kullanıldığında test sonuçlarını bozuyor. Bookstore'un vardığı yer bu ikisinin birleşimi: test class'ı başına bir veritabanı, class içindeki her testten önce Respawn.
Container'ın Yaşam Döngüsü
Container'ı yöneten fixture bu yazıda sadeleşiyor; artık migration'ı kendisi uygulamıyor, yalnız SQL Server'ı ayakta tutuyor ve istenen veritabanı adı için connection string üretiyor:
using System.Diagnostics;
using Microsoft.Data.SqlClient;
using Testcontainers.MsSql;
[assembly: AssemblyFixture(typeof(Bookstore.Api.IntegrationTests.SqlServerFixture))]
[assembly: Trait("Category", "Integration")]
namespace Bookstore.Api.IntegrationTests;
public sealed class SqlServerFixture : IAsyncLifetime
{
private readonly MsSqlContainer container = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2025-latest").Build();
public TimeSpan StartupTime { get; private set; }
public async ValueTask InitializeAsync()
{
Stopwatch stopwatch = Stopwatch.StartNew();
await container.StartAsync();
StartupTime = stopwatch.Elapsed;
}
// Aynı sunucuda, test class'ı başına ayrı bir veritabanı
public string ConnectionStringFor(string database) =>
new SqlConnectionStringBuilder(container.GetConnectionString()) { InitialCatalog = database }.ConnectionString;
public async ValueTask DisposeAsync() => await container.DisposeAsync();
}
Image adını builder'ın constructor'ına vermek Testcontainers 4.x'te artık zorunluluğa dönüşüyor. Parametresiz new MsSqlBuilder() hâlâ derleniyor ama şu uyarıyı veriyor:
warning CS0618: 'MsSqlBuilder.MsSqlBuilder()' is obsolete: 'This parameterless constructor is obsolete and will be removed. Use the constructor with the image parameter instead: https://github.com/testcontainers/testcontainers-dotnet/discussions/1470#discussioncomment-15185721.'
Image'ı açıkça yazmak zaten iyi bir alışkanlık: production'da hangi SQL Server sürümü çalışıyorsa test de onu kullanmalı, kütüphanenin varsayılanı değişti diye testlerin motoru sessizce değişmemeli. Bookstore SQL Server 2025 hedefliyor; container'da SERVERPROPERTY('ProductVersion') 17.0.5035.5 döndü.
StartAsync çağrısı image'ı (gerekirse) çekiyor, container'ı rastgele bir host port'uyla başlatıyor ve module'ün hazır olma kontrolü bitene kadar bekliyor. MsSql module'ü bunun için container içinde sqlcmd ile SELECT 1 çalıştırıyor; log'da bu satırlar art arda görünüyor. Bu ölçümde container'ın hazır olması 7,4 saniye sürdü. Rastgele port önemli: aynı makinede iki CI işi ya da iki geliştirici aynı anda test koşturduğunda port çakışması olmuyor, connection string'i de GetConnectionString() her seferinde doğru port'la üretiyor.
Module, SQL Server image'ının istediği ACCEPT_EULA ortam değişkenini kendisi set ediyor. Yani Testcontainers ile SQL Server başlattığında Microsoft'un lisans koşullarını kabul etmiş oluyorsun; Developer edition'ın test ve geliştirme için kullanımı bu koşulların kapsamında.
Container'ın temizlenmesi iki katmanlı. Normal akışta DisposeAsync container'ı durdurup siliyor. Test process'i çökerse ya da debugger'da durdurulursa bu çağrı hiç yapılmayabilir; bunun için Testcontainers ilk container'la birlikte Ryuk adlı küçük bir yardımcı container başlatıyor. Ryuk, test process'iyle bağlantısı kopunca o oturumun etiketli container'larını kendisi siliyor. docker ps çıktısında testcontainers/ryuk görmen bu yüzden.
Migration'ları Uygulamak, EnsureCreated'ı Değil
Şemayı test başlangıcında kurmanın iki yolu var: Database.MigrateAsync() ve Database.EnsureCreatedAsync(). İkincisi daha kısa ve migration'sız çalıştığı için cazip. Farkı görmek için Bookstore'a, Entity Framework Core (EF Core) modelinde karşılığı olmayan raw SQL içeren bir migration ekledim:
protected override void Up(MigrationBuilder migrationBuilder)
{
// Raw SQL: EF Core modelinde yok, yalnız migration biliyor
migrationBuilder.Sql("ALTER TABLE [Books] ADD CONSTRAINT [CK_Books_Price_Positive] CHECK ([Price] > 0);");
}
Üç ayrı veritabanında şemayı üç farklı yolla kurup negatif fiyatlı bir kitap kaydetmeyi denedim:
Migrate: DbUpdateException -> The INSERT statement conflicted with the CHECK constraint "CK_Books_Price_Positive". The conflict occurred in database "Probe_21974a9df87c439fa740e917a55441bf", table "dbo.Books", column 'Price'.
EnsureCreated: negative price saved
EnsureCreated+Migrate: SqlException -> There is already an object named 'Books' in the database.
EnsureCreated şemayı migration'lardan değil, modelin o anki hâlinden üretiyor. Migration'lardaki raw SQL, data düzeltmeleri, view'lar ve stored procedure'ler hiç uygulanmıyor; __EFMigrationsHistory tablosu da oluşmuyor. Sonuç: production'da veritabanının reddettiği bir kaydı test kabul ediyor. Üstelik EnsureCreated ile kurulmuş bir veritabanına sonradan migration uygulamak, ilk migration'ın tabloları yeniden oluşturmaya çalışmasıyla patlıyor. Integration testte MigrateAsync kullanmanın ek bir faydası daha var: migration'ların kendisi her test koşusunda boş bir veritabanına uygulanarak test ediliyor.
Respawn ile Testler Arası Temizlik
Respawn, Jimmy Bogard'ın geliştirdiği küçük bir library. Veritabanının metadata'sından foreign key ilişkilerini okuyup tabloları silme sırasını bir kez hesaplıyor, sonra her çağrıda bu sırayla DELETE komutları çalıştırıyor. README'ye göre TRUNCATE yerine DELETE seçilmesinin nedeni, truncate'in foreign key'leri kaldırmayı gerektirmesi ve yazarın ölçümlerinde sıralı silmenin daha hızlı çıkması. 7.0 sürümünde paket Microsoft.Data.SqlClient'a doğrudan bağımlı değil; connection'ı projedeki SqlClient'tan sen veriyorsun.
Bookstore'da Respawn'ı factory'nin içine koydum, çünkü factory artık kendi veritabanının sahibi:
public sealed class BookstoreApiFactory(SqlServerFixture sql) : WebApplicationFactory<Program>, IAsyncLifetime
{
// Her factory instance'ı (yani her test class'ı) kendi veritabanını alır
private readonly string connectionString = sql.ConnectionStringFor($"Bookstore_{Guid.NewGuid():N}");
private Respawner? respawner;
public string ConnectionString => connectionString;
public async ValueTask InitializeAsync()
{
DbContextOptions<BookstoreDbContext> options = new DbContextOptionsBuilder<BookstoreDbContext>()
.UseSqlServer(connectionString)
.Options;
await using (BookstoreDbContext db = new(options))
{
await db.Database.MigrateAsync();
}
await using SqlConnection connection = new(connectionString);
await connection.OpenAsync();
respawner = await Respawner.CreateAsync(connection, new RespawnerOptions
{
// Migration geçmişi silinirse bir sonraki MigrateAsync her şeyi baştan kurmaya çalışır
TablesToIgnore = ["__EFMigrationsHistory"]
});
}
public async Task ResetDatabaseAsync()
{
await using SqlConnection connection = new(connectionString);
await connection.OpenAsync();
await respawner!.ResetAsync(connection);
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.UseSetting("ConnectionStrings:Bookstore", connectionString);
// ConfigureTestServices kısmı 2. yazıdaki gibi
}
}
Factory IAsyncLifetime uyguladığı için xUnit v3 onu class fixture olarak oluştururken InitializeAsync'i de çağırıyor: veritabanı oluşuyor, migration'lar uygulanıyor, Respawn silme sırasını hesaplıyor. Test class'ları her testten önce temizliği tetikliyor:
public sealed class BookCatalogTests(BookstoreApiFactory factory) : IClassFixture<BookstoreApiFactory>, IAsyncLifetime
{
public async ValueTask InitializeAsync() => await factory.ResetDatabaseAsync();
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
// testler...
}
Temizliği testten sonra değil önce yapmak bilinçli bir seçim. Test kalırsa geride bıraktığı veri, hata ayıklarken bakılacak ilk yer; bir sonraki test zaten kendi başlangıcında temizleyecek. Bu düzenle 1. yazıda Assert.Single(books!, b => b.Isbn == ...) diye daralttığım arama testi yeniden Assert.Single(books!) ile yazılabiliyor ve "2nd Edition" testiyle birlikte geçiyor.
Transaction Rollback: Hızlı, Ama Koşullu
Ölçümde en hızlı çıkan yöntem, testi bir TransactionScope içinde çalıştırıp Complete() çağırmadan bırakmak. API'nin açtığı SQL bağlantıları ambient transaction'a katılıyor, scope dispose edilince her şey geri alınıyor. Çalışan sürüm:
[Theory]
[InlineData("978-0201633610")]
[InlineData("978-0201485677")]
[InlineData("978-0321127426")]
public async Task Each_test_sees_only_its_own_book(string isbn)
{
// TestServer, isteği testin ExecutionContext'iyle çalıştırsın; ambient transaction API'ye ulaşsın
factory.Server.PreserveExecutionContext = true;
using TransactionScope scope = new(TransactionScopeAsyncFlowOption.Enabled);
HttpClient admin = factory.CreateClientFor(TestUser.Admin);
HttpResponseMessage created = await admin.PostAsJsonAsync("/api/books",
new CreateBookRequest(isbn, "Rollback", 10m, 1), TestContext.Current.CancellationToken);
created.EnsureSuccessStatusCode();
List<BookDto>? books = await admin.GetFromJsonAsync<List<BookDto>>("/api/books", TestContext.Current.CancellationToken);
Assert.Equal(isbn, Assert.Single(books!).Isbn);
// scope.Complete() çağrılmadı: Dispose, transaction'ı rollback eder
}
Kritik satır PreserveExecutionContext = true. TransactionScope ambient transaction'ı AsyncLocal benzeri bir mekanizmayla taşıyor; TestServer ise varsayılan olarak isteği testin ExecutionContext'inden bağımsız çalıştırıyor. Satırı false yaptığımda üç testin ikisi kaldı:
failed Bookstore.Api.IntegrationTests.RollbackCatalogTests.Each_test_sees_only_its_own_book(isbn: "978-0201633610") (36ms)
Assert.Single() Failure: The collection contained 3 items
Collection: [BookDto { Id = 1, Isbn = 978-0321127426, Title = Rollback, Price = 10,00, Stock = 1 }, BookDto { Id = 2, Isbn = 978-0201485677, Title = Rollback, Price = 10,00, Stock = 1 }, BookDto { Id = 3, Isbn = 978-0201633610, Title = Rollback, Price = 10,00, Stock = 1 }]
API'nin bağlantıları transaction'a hiç katılmadı, her kayıt commit edildi ve scope'un rollback'i boş bir şeyi geri aldı. Hata mesajı rollback'ten değil, sonraki testin fazla satır görmesinden geliyor; testler tek başına koşarken her şey yeşil görünür. İkinci tuzak scope'un nerede oluşturulduğu: scope'u test class'ının async InitializeAsync'inde açtığım ilk denemede de rollback çalışmadı. Async bir method'un içinde set edilen ambient transaction, method döndüğünde çağırana geri taşınmıyor; scope test method'unun içinde açılmalı.
Üçüncü sınır daha temel. Test, scope açıkken veri hazırlamak için kendi bağlantısını açık tutar ve API ikinci bir bağlantı açarsa, aynı transaction'a iki bağlantı katılmış olur ve SqlClient onu distributed transaction'a yükseltmeye çalışır. Bu yazının ortamında bunun sonucu PlatformNotSupportedException: This platform does not support distributed transactions. oldu. Windows'ta .NET 7'den beri MSDTC (Windows'un dağıtık transaction koordinatörü) üzerinden destek var, ama TransactionManager.ImplicitDistributedTransactions varsayılan olarak false ve yükseltme denemesi yine exception'la bitiyor. Bir de E2E (uçtan uca test) bloğunda API ayrı bir process'te ya da Kestrel üzerinde çalışacak; orada test process'inin transaction'ı API'ye hiç ulaşamaz. Rollback, yalnız TestServer içinde, tek bağlantıyla çalışan dar testler için iyi bir seçenek.
Test Başına Veritabanı ve Paralellik
Respawn'ın hızına rağmen neden class başına ayrı veritabanı açtığımı göstermek için iki test class'ını aynı veritabanına bağladım; ikisi de her testten önce Respawn ile temizliyor ve xUnit v3 farklı class'ları varsayılan olarak paralel koşturuyor. Üç koşunun üçünde de 20 testin 20'si kaldı:
Assert.Single() Failure: The collection contained 2 items
Collection: [BookDto { Id = 5, Isbn = 978-9-2, Title = Isolation, Price = 10,00, Stock = 1 }, BookDto { Id = 6, Isbn = 978-8-5, Title = Isolation, Price = 10,00, Stock = 1 }]
İki class neredeyse aynı anda temizliyor, aynı anda ekliyor ve her biri diğerinin kitabını görüyor. Bir class'ın temizliği diğerinin testinin ortasına denk gelirse durum daha da kötü: test kendi eklediği satırı bulamaz. Shared veritabanı + Respawn ancak bütün veritabanı testlerini aynı xUnit collection'ında toplayıp sırayla koşturursan güvenli; bu da paralelliği tamamen kaybetmek demek.
Test başına veritabanı bu sorunu kökten çözüyor ama bedeli ağır: ölçümde test başına yaklaşık 350 milisaniye, büyük kısmı migration. 300 testlik bir suite için bu, testlerin kendisine ek olarak 100 saniyeden fazla. Class başına veritabanı orta yol: migration ve Respawn hazırlığı class başına bir kez, paralellik class'lar arasında korunuyor, class içindeki testler zaten sırayla koşuyor. Bookstore'un bütün suite'i bu düzende 11 testle üç ardışık koşuda 10,9, 13,7 ve 30,4 saniye sürdü; sondaki sıçrama container başlangıcından geldi ve aynı makinede bile bu sürenin oynak olabileceğini gösteriyor.
Üretimde Dikkat
CI'da Docker'ı olan bir runner gerekiyor. GitHub'ın hosted Linux runner'larında Docker hazır geliyor; Windows runner'larında Linux container'ı çalıştırmak mümkün değil, bu yüzden integration test işi Linux runner'da koşmalı. Kurulum ayrıntıları GitHub Actions serisinin "GitHub Actions ile .NET CI: setup-dotnet, global.json, NuGet Cache, Test Raporu ve Coverage" yazısında. SQL Server image'ı birkaç GB; runner'da cache'lenmiyorsa her koşu onu yeniden çekiyor. Image çekme süresini container başlangıç süresinden ayrı ölç ve pipeline log'unda gör.
Container'ı koşular arasında yeniden kullanmak (WithReuse(true)) yerel geliştirmede cazip görünüyor. Testcontainers dokümanı özelliği deneysel olarak işaretliyor ve önemli bir yan etkisini yazıyor: reuse açıkken Ryuk devre dışı kalıyor, container'ı kimse otomatik silmiyor. Bookstore'da üç ardışık koşuyla denedim:
startup 6,3 s, leftover Bookstore_ databases: 0
startup 6,2 s, leftover Bookstore_ databases: 3
startup 6,2 s, leftover Bookstore_ databases: 6
İki ders var. Birincisi, kazanç beklediğim kadar değil: fixture'ın DisposeAsync'i reuse edilen container'ı durduruyor, sonraki koşu onu yeniden başlatıyor ve SQL Server'ın açılmasını yine bekliyor. İkincisi, önceki koşuların veritabanları birikiyor; test verisi artık koşuya ait değil. Reuse'u ancak bunu bilerek ve bir temizlik stratejisiyle kullan; CI'da hiç kullanma. Denemeden sonra container'ı docker rm ile elle silmem gerekti.
Test veritabanlarını adlandırırken sabit bir isim yerine Guid kullanmanın bir nedeni de bu: aynı container'da birden fazla factory, birden fazla koşu ya da reuse edilen bir container olduğunda isim çakışması olmuyor.
Sık Düşülen Tuzaklar
Image'ın mimarisini kontrol etmemek. mcr.microsoft.com/mssql/server:2025-latest image'ının manifest'inde yalnız linux/amd64 var. x64 Windows ve Linux makinelerde sorun yok; ama ekipte ARM (x64'ten farklı bir işlemci mimarisi) tabanlı bir dizüstü kullanan biri ya da ARM tabanlı bir CI runner'ı varsa, container orada ya emülasyonla ve belirgin şekilde yavaş çalışır ya da hiç çalışmaz. Yeni bir runner tipine geçmeden önce docker buildx imagetools inspect ile image'ın desteklediği platformlara bak.
Container'ı test içinde başlatmak. await using MsSqlContainer container = ... satırını bir test method'una yazmak, ölçümdeki 6–7 saniyelik başlangıcı her teste ekliyor. Container assembly fixture'da, veritabanı class fixture'da, temizlik test başında: maliyetin her biri doğru seviyede ödeniyor.
EnsureCreated ile şema kurmak. Yukarıdaki deney, migration'daki bir check constraint'in testte hiç var olmadığını gösterdi. Integration testin işi production'ın gerçek şemasını kullanmak.
Respawn'a migration geçmişini sildirmek. TablesToIgnore içinde __EFMigrationsHistory yoksa ilk temizlik migration kayıtlarını da siler; aynı veritabanına bir sonraki MigrateAsync çağrısı tabloları baştan oluşturmaya çalışır ve "There is already an object named 'Books'" hatasıyla durur. Seed edilmiş referans tabloları (ülke listesi, para birimleri) varsa onları da bu listeye ekle.
Ambient transaction'ın sessizce çalışmadığını fark etmemek. Rollback yöntemini seçtiysen, suite'in sonunda veritabanında satır kalmadığını kontrol eden bir test ya da fixture adımı ekle; PreserveExecutionContext unutulduğunda hata başka bir testte ve yanıltıcı bir mesajla çıkıyor.
Küçük Bir Deneme
★☆☆ Bookstore'un test suite'ini kendi makinende iki kez çalıştır ve SqlServerFixture.StartupTime değerini test çıktısına yazdır. Image yokken ve varken ilk koşu arasındaki farkı ölç.
★★☆ Bütün test class'larını tek veritabanına bağlayan ve [Collection("Database")] ile sırayla koşturan bir varyasyon kur. Suite süresini class başına veritabanı düzeniyle karşılaştır; test class sayısı 20'ye çıktığında farkın nasıl değişeceğini tahmin et, sonra ölç.
★★★ Test başına veritabanının maliyetini düşürmek için migration'ları bir kez "template" veritabanına uygulayıp her test için onun bir kopyasını oluştur (ipucu: BACKUP DATABASE + RESTORE DATABASE ... WITH MOVE ya da database snapshot). Kopyalama süresini migration süresiyle karşılaştır.
Veritabanı artık gerçek, temiz ve testler arasında izole. Ama Bookstore'un sipariş akışı bir ödeme sağlayıcısına, bir kargo API'sine ve bir SMTP (e-posta gönderim protokolü) sunucusuna dokunuyor. Bir sonraki yazıda bu dış bağımlılıkları HTTP ve SMTP sınırında taklit ediyoruz: WireMock.Net ile ödeme ve kargo, sahte bir SMTP sunucusuyla e-posta, FakeTimeProvider ile zaman ve resilience pipeline'ının gerçekten devreye girdiği hata senaryoları.