xUnit Test Yaşam Döngüsü: Constructor, IAsyncLifetime, Fixture'lar ve 4.0'da Tam Paralellik
xUnit her test için test class'ının yeni bir instance'ını oluşturur; pahalı ve salt okunur hazırlık class, collection ya da assembly fixture'ında bir kez yapılır. 4.0'ın ParallelMode.All'u 8 testlik örnekte süreyi 1,06 saniyeden 0,55'e indirdi, ama static bir fake paylaşan iki test 20 koşunun 16'sında kaldı. IAsyncLifetime, opt-out seviyeleri, ITestOutputHelper ve TestContext ekleri.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ve xUnit.net v3 4.0.2 ile derlenip denenmiştir.
Önce Tahmin Et
Aşağıdaki test class'ında üç test var. Class bir IClassFixture kullanıyor, assembly seviyesinde de bir fixture tanımlı; ikisi de IAsyncLifetime uyguluyor. Her adım bir log'a sıra numarasıyla yazıyor:
[assembly: AssemblyFixture<Bookstore.Lifecycle.Tests.PriceListFixture>]
namespace Bookstore.Lifecycle.Tests;
// Bütün assembly için bir kez: ör. salt okunur fiyat listesi
public sealed class PriceListFixture : IAsyncLifetime
{
public IReadOnlyDictionary<string, decimal> Prices { get; private set; } = new Dictionary<string, decimal>();
public ValueTask InitializeAsync()
{
Log.Write("PriceListFixture.InitializeAsync");
Prices = new Dictionary<string, decimal> { ["978-0321125217"] = 55m, ["978-0134757599"] = 48m };
return ValueTask.CompletedTask;
}
public ValueTask DisposeAsync()
{
Log.Write("PriceListFixture.DisposeAsync");
foreach (string line in Log.Lines)
{
Console.WriteLine(line);
}
return ValueTask.CompletedTask;
}
}
// Bu class'taki testler için bir kez
public sealed class CatalogFixture : IAsyncLifetime
{
public List<string> Isbns { get; } = [];
public ValueTask InitializeAsync()
{
Log.Write("CatalogFixture.InitializeAsync");
Isbns.AddRange(["978-0321125217", "978-0134757599"]);
return ValueTask.CompletedTask;
}
public ValueTask DisposeAsync()
{
Log.Write("CatalogFixture.DisposeAsync");
return ValueTask.CompletedTask;
}
}
public sealed class CatalogTests : IClassFixture<CatalogFixture>, IAsyncLifetime
{
private readonly CatalogFixture catalog;
private readonly PriceListFixture prices;
private readonly List<string> cart = [];
public CatalogTests(CatalogFixture catalog, PriceListFixture prices)
{
this.catalog = catalog;
this.prices = prices;
Log.Write($"CatalogTests.ctor #{GetHashCode():x8}");
}
public ValueTask InitializeAsync()
{
Log.Write("CatalogTests.InitializeAsync");
return ValueTask.CompletedTask;
}
[Fact]
public void Cart_starts_empty()
{
Log.Write("Test: Cart_starts_empty");
Assert.Empty(cart);
}
[Fact]
public void Adding_a_book_puts_one_item_in_the_cart()
{
Log.Write("Test: Adding_a_book_puts_one_item_in_the_cart");
cart.Add(catalog.Isbns[0]);
Assert.Single(cart);
}
[Fact]
public void Catalog_books_have_a_price()
{
Log.Write("Test: Catalog_books_have_a_price");
Assert.All(catalog.Isbns, isbn => Assert.True(prices.Prices.ContainsKey(isbn)));
}
public ValueTask DisposeAsync()
{
Log.Write("CatalogTests.DisposeAsync");
return ValueTask.CompletedTask;
}
}
Log, thread-safe bir ConcurrentQueue'ya Interlocked ile artan bir numara ekleyerek yazan küçük bir static class. dotnet run çalıştırıldığında log kaç satır olur? CatalogTests'in constructor'ı kaç kez çalışır, CatalogFixture.InitializeAsync kaç kez? Testler kaynak koddaki sırayla mı çalışır? Adding_a_book_puts_one_item_in_the_cart, Cart_starts_empty'den önce çalışırsa ikincisi kalır mı?
Cevap: Her Test İçin Yeni Instance
01 PriceListFixture.InitializeAsync
02 CatalogFixture.InitializeAsync
03 CatalogTests.ctor #00a2e605
04 CatalogTests.InitializeAsync
05 Test: Catalog_books_have_a_price
06 CatalogTests.DisposeAsync
07 CatalogTests.ctor #02e1ec34
08 CatalogTests.InitializeAsync
09 Test: Cart_starts_empty
10 CatalogTests.DisposeAsync
11 CatalogTests.ctor #00c75fa2
12 CatalogTests.InitializeAsync
13 Test: Adding_a_book_puts_one_item_in_the_cart
14 CatalogTests.DisposeAsync
15 CatalogFixture.DisposeAsync
16 PriceListFixture.DisposeAsync
On altı satır. Constructor üç kez çalıştı ve üç farklı instance oluştu (hash'ler farklı). CatalogFixture ise bir kez kuruldu ve üç testin hepsi aynı instance'ı aldı. Testlerin sırası kaynak koddaki sıra değil: xUnit test sırasını bilerek belirsiz tutar (v3'te sıra koşudan koşuya kararlı, ama yazdığın sıra değil). cart her test için yeni bir List olduğu için Cart_starts_empty, öteki testin sepete kitap eklemesinden etkilenmez. Yazının geri kalanı bu tablonun her satırının ne anlama geldiğini ve paralellikle nasıl birleştiğini anlatıyor.

Constructor, Dispose ve IAsyncLifetime
xUnit'in en önemli tasarım kararı, her test için test class'ının yeni bir instance'ını oluşturması. NUnit'teki [SetUp]/[TearDown]'ın karşılığı burada C#'ın kendi mekanizmaları: hazırlık constructor'da, temizlik Dispose ya da DisposeAsync'te. Field'larda tutulan her şey (cart gibi) test başına sıfırdan başlar; testler arası state sızıntısı bu yüzden instance field'larıyla mümkün değil.
Constructor async olamadığı için async hazırlık IAsyncLifetime ile yapılır: InitializeAsync constructor'dan hemen sonra, DisposeAsync testten sonra çalışır. v3'te iki method da ValueTask döner ve IAsyncLifetime, IAsyncDisposable'dan türer; v2'deki Task imzalı kod v3'e geçerken derlenmez, 13. yazıda bu taşımaya döneceğiz. Bir class hem IDisposable hem IAsyncDisposable uyguluyorsa xUnit yalnız async olanı çağırır; ikisine aynı temizliği koymak gerekmiyor.
Ne zaman constructor, ne zaman fixture? Hazırlık ucuzsa (birkaç object oluşturmak) constructor'da kalsın; her test temiz bir dünya ile başlar ve test kendi içinde okunur. Hazırlık pahalıysa (büyük bir veri setini yüklemek, bir process başlatmak) ve testler onu değiştirmiyorsa fixture'a taşı. Unit test'te ikinci durum seyrektir; pahalı hazırlık çoğu zaman testin aslında bir integration test olduğunun işaretidir.
Class, Collection ve Assembly Fixture'ları
Üç seviye var ve şekildeki iç içe kutulara karşılık geliyorlar.
Class fixture: IClassFixture<T> uygulayan class'ın bütün testleri için bir kez oluşturulur, son testten sonra dispose edilir. Test class'ı fixture'ı constructor parametresi olarak alır. Yukarıdaki CatalogFixture böyle.
Collection fixture: birden fazla test class'ının aynı fixture'ı paylaşması için. Önce bir collection tanımı yapılır, sonra class'lar o collection'a bağlanır. v3'te .NET 8 ve üstünde generic attribute ile string ad kullanmadan bağlanabiliyor:
// Pahalı ama salt okunur: 10.000 kitaplık katalog bir kez kurulur
public sealed class LargeCatalogFixture
{
public IReadOnlyList<string> Isbns { get; } =
Enumerable.Range(0, 10_000).Select(i => $"978-{i:D10}").ToArray();
}
[CollectionDefinition]
public sealed class LargeCatalogCollection : ICollectionFixture<LargeCatalogFixture>;
[Collection<LargeCatalogCollection>]
public sealed class SearchTests(LargeCatalogFixture catalog)
{
[Fact]
public void Isbn_prefix_search_finds_ten_books() =>
Assert.Equal(10, catalog.Isbns.Count(i => i.StartsWith("978-000000999", StringComparison.Ordinal)));
}
[Collection<LargeCatalogCollection>]
public sealed class PagingTests(LargeCatalogFixture catalog)
{
[Fact]
public void Last_page_of_50_has_50_books() =>
Assert.Equal(50, catalog.Isbns.Chunk(50).Last().Length);
}
Collection'ın bir yan etkisi var ve sık unutuluyor: aynı collection'daki testler paralel çalışmaz. SearchTests ile PagingTests artık sırayla koşuyor. Collection fixture'ı yalnız paylaşım için kullandığında paralelliği de farkında olmadan kaybedersin.
Assembly fixture: v3 ile gelen en geniş seviye. [assembly: AssemblyFixture(typeof(...))] ile tanımlanır, 4.0'da generic AssemblyFixture<T> da var. Bütün assembly için bir kez oluşturulur ve herhangi bir test class'ının constructor'ına enjekte edilebilir; yukarıda CatalogTests, PriceListFixture'ı böyle aldı. Collection fixture'ından farkı, testleri aynı collection'a sokmaması: paralellik korunur. Bu yüzden assembly fixture'daki state ya salt okunur olmalı ya da thread-safe.
4.0'da Paralellik: None, Collections ve All
xUnit'te paralelliğin birimi uzun süre collection'dı: her class varsayılan olarak kendi collection'ı, collection'lar paralel, bir collection'ın içi sırayla. 4.0 buna yeni bir seviye ekledi: ParallelMode.All ile collection ve class'tan bağımsız olarak bütün testler paralel çalışabiliyor. Etkisini ölçmek için dört class, her birinde 500 ms bekleyen iki test (toplam 8 test) yazdım ve üç modda dotnet run -- -parallelMode ... ile çalıştırdım:
== none
Bookstore.Parallel.Tests Total: 8, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0, Time: 4.064s
== collections
Bookstore.Parallel.Tests Total: 8, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0, Time: 1.061s
== all
Bookstore.Parallel.Tests Total: 8, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0, Time: 0.550s
Sıralı 4 saniye; collection modunda dört class paralel ama her class'ın iki testi sırayla, 1 saniye; All'da sekiz test birlikte, yarım saniye. Varsayılan hâlâ Collections. Mod, assembly attribute'u ya da xunit.runner.json'daki "parallelMode" anahtarıyla ("none", "collections", "all") değiştirilir. Attribute'un iki tipi farklı namespace'lerde, ParallelizationAttribute Xunit.v3'te, ParallelMode enum'u Xunit.Sdk'da:
using Xunit.Sdk;
using Xunit.v3;
[assembly: Parallelization(Mode = ParallelMode.All)]
namespace Bookstore.Parallel.Tests;
[CollectionDefinition("Stock ledger", DisableParallelization = true)]
public sealed class StockLedgerCollection;
[Collection("Stock ledger")]
public sealed class StockLedgerTests
{
[Fact]
public Task A() => Task.Delay(500, TestContext.Current.CancellationToken);
}
public sealed class OptOutMethodTests
{
[Fact(DisableParallelization = true)]
public Task Serial() => Task.Delay(500, TestContext.Current.CancellationToken);
}
All modunda geri çekilmek mümkün: collection, class, method, theory veri kaynağı ve tek bir veri satırı seviyesinde paralelliği kapatabilirsin. Bir seviyede kapattığında alt seviyede yeniden açamazsın. Aynı projeye bu iki opt-out'u ekleyince toplam süre 0,55 saniyeden 1,56 saniyeye çıktı (10 test): paralel olmayan testler paralel olanların yanında değil, ayrıca koşuyor. Opt-out bedava değil; gerekçesi olan yerde kullan.
4.0 ile CollectionBehavior attribute'unun DisableTestParallelization, MaxParallelThreads ve ParallelAlgorithm property'leri obsolete oldu; yerlerini Parallelization attribute'unun Mode, MaxThreads ve Algorithm property'leri aldı. Varsayılan algoritma Conservative: aynı anda en fazla MaxThreads kadar test başlatır (varsayılan değer CPU (Central Processing Unit) thread sayısı), bu sayede [Fact(Timeout = ...)] güvenilir çalışır. Aynı sürümde collection, class, method ve test case seviyesinde test orderer'ları da geldi; sıralamaya ihtiyaç duyan testler bir koku olsa da migration senaryolarında işe yarayabiliyor.
ITestOutputHelper ve TestContext ile Çıktı
Unit test'te Console.WriteLine varsayılan olarak test sonucuna bağlanmaz; v3'te console yakalamayı [assembly: CaptureConsole] ile açman gerekir. Testle ilişkilendirilmiş çıktı için ITestOutputHelper'ı constructor'dan alırsın. TestContext ise dosya eki ve testler arası anahtar-değer saklama alanı sunuyor:
public sealed class InvoiceOutputTests(ITestOutputHelper output)
{
[Fact]
public void Invoice_total_matches_line_sum()
{
decimal[] lines = [55m, 48m, 12.5m];
string invoiceJson = """{ "number": "INV-2026-0042", "total": 115.00 }""";
output.WriteLine($"Lines: {string.Join(", ", lines)}");
TestContext.Current.AddAttachment("invoice.json", invoiceJson);
TestContext.Current.KeyValueStorage["invoice"] = "INV-2026-0042";
Assert.Equal(115.00m, lines.Sum());
}
}
failed Bookstore.Lifecycle.Tests.InvoiceOutputTests.Invoice_total_matches_line_sum (9ms)
Assert.Equal() Failure: Values differ
Expected: 115.00
Actual: 115.5
Standard output
Lines: 55, 48, 12.5
In process file artifacts produced:
- For test 'Bookstore.Lifecycle.Tests.InvoiceOutputTests.Invoice_total_matches_line_sum': …/p05/TestResults/e79df539…/invoice.json.txt
Fatura JSON'unda (JavaScript Object Notation) toplam 115, satırların toplamı 115,5. Çıktıdaki satır listesi farkın 12,5'lik satırdan geldiğini, ek olarak kaydedilen JSON da faturanın kendisini gösteriyor. 4.0 ile ekler --results-directory ile verilen sonuç klasörüne yazılıyor. KeyValueStorage testler arası paylaşılan bir sözlük; bir extension'ın ya da fixture'ın test sırasında bilgi bırakması için düşünülmüş. Testlerin birbirine veri aktarması için kullanma, aşağıdaki tuzağın kapısını açar.
Üretimde Dikkat
All'a geçmeden önce paylaşılan state'i ara. 4.0'a yükseltmek modu değiştirmiyor; varsayılan Collections kalıyor. Ama süre kazancını görüp All'a geçtiğinde, yıllardır aynı class içinde sırayla çalıştığı için saklanan sorunlar ortaya çıkar. Geçişten önce test projesinde static mutable field'ları, singleton'ları ve Environment.SetEnvironmentVariable, CultureInfo.CurrentCulture gibi process genelindeki ayarları değiştiren testleri ara.
Yerel makinede geçip CI'da (Continuous Integration) kalan testlerin önemli bir kısmı paralellik kaynaklı. CI makinesinin çekirdek sayısı farklı olduğunda testlerin üst üste binme zamanlaması da değişiyor. Bir test aralıklı kalıyorsa, onu -parallelMode none ile birkaç kez çalıştırmak, paralellik kaynaklı olup olmadığını hızlıca gösterir. Flaky testlerin pipeline'da nasıl yönetileceği CI/CD Temelleri serisinin konusu.
Fixture'ın dispose edilmesini garanti etme. Test process'i çöktüğünde ya da zaman aşımıyla kesildiğinde DisposeAsync çalışmayabilir. Unit test fixture'ları dış kaynak (dosya, port, process) tutmamalı; tutuyorsa bu bir integration test sinyali.
Sık Düşülen Tuzaklar
Static ya da paylaşılan mutable state. Bu yazı için yazdığım şu iki test, "her testte yeni fake oluşturmak yavaş" gerekçesiyle tek bir static outbox paylaşıyor:
// Testlerin "hızlı olsun" diye paylaştığı static fake
public static class SharedEmailOutbox
{
public static List<string> Sent { get; } = [];
}
public sealed class OrderEmailTests
{
[Fact]
public async Task Placing_an_order_sends_one_confirmation()
{
SharedEmailOutbox.Sent.Clear();
await Task.Delay(10, TestContext.Current.CancellationToken);
SharedEmailOutbox.Sent.Add("ada@example.com: Order confirmed");
Assert.Single(SharedEmailOutbox.Sent);
}
[Fact]
public async Task Cancelling_an_order_sends_one_cancellation()
{
SharedEmailOutbox.Sent.Clear();
await Task.Delay(10, TestContext.Current.CancellationToken);
SharedEmailOutbox.Sent.Add("ada@example.com: Order cancelled");
Assert.Single(SharedEmailOutbox.Sent);
}
}
Her modda 20 kez çalıştırdım. Collections: 20 geçti, 0 kaldı. All: 4 geçti, 16 kaldı. Kalan koşuların mesajı:
Assert.Single() Failure: The collection contained 2 items
Collection: ["ada@example.com: Order cancelled", "ada@example.com: Order confirmed"]
Collection modunda iki test aynı class'ta olduğu için sırayla koşuyor ve sorun saklanıyor; testler hiçbir zaman gerçekten bağımsız değildi. Çözüm her test için yeni bir fake: instance field ya da constructor'da oluşan bir object. Bir fake'i oluşturmak mikro saniyeler sürer; paylaşmanın kazancı yok.
Fixture'ı "her şeyi koyduğum yer" yapmak. Bir TestFixture class'ı zamanla bütün mock'ları, test verisini ve yardımcı method'ları toplar; her test class'ı onu alır ve hangi testin neye ihtiyaç duyduğu görünmez olur. Daha kötüsü, fixture paylaşıldığı için bir testin fixture'daki bir listeyi değiştirmesi diğerlerini etkiler. Fixture'da yalnız pahalı ve salt okunur şeyler dursun; testin kendi verisi testin içinde ya da 10. yazıdaki builder'larda kurulsun.
Test sırasına güvenmek. "Önce kayıt testi, sonra güncelleme testi" kurgusu, xUnit'in sırayı belirsiz tutması yüzünden zaten kırılgandır; All modunda bütünüyle çöker. Her test kendi ön koşulunu kendisi kurmalı.
Küçük Bir Deneme
★☆☆ CatalogTests'e dördüncü bir test ekle ve log'u yeniden üret. CatalogFixture.InitializeAsync kaç kez çalıştı? cart'ı static yapınca hangi test, hangi sırada kalıyor?
★★☆ OrderEmailTests'i, static outbox yerine her test için yeni bir FakeEmailSender kullanacak şekilde düzelt ve -parallelMode all ile 20 kez çalıştır. Kaç koşu kaldı?
★★★ Kendi test projende [assembly: Parallelization(Mode = ParallelMode.All)] ekle, suite'i 10 kez çalıştır ve aralıklı kalan testleri listele. Her biri için paylaşılan state'in kaynağını bul: static field, singleton, ortam değişkeni ya da kültür.
Bir sonraki yazıda bu yazıda adını andığımız "fake"in ne olduğunu netleştiriyoruz: dummy, stub, spy, mock ve fake arasındaki fark, elle yazılmış test double'lar ve FakeTimeProvider ile FakeLogger.