İyi Bir Unit Test'in Anatomisi: AAA, İsimlendirme, Tek Davranış ve Okunabilir Assertion'lar
İyi bir unit test tek bir davranışı tek bir Act ile çalıştırır, dallanma içermez ve kırmızıya döndüğünde adı ve hata mesajıyla sorunu kodu açmadan anlatır. AAA'nın sınırları, Method_Scenario_Expected ile düz cümle isimlendirme, Assert.Multiple, Shouldly, AwesomeAssertions ve FluentAssertions 8'in Xceed lisansı gerçek hata mesajlarıyla.
Sürüm notu: Bu yazıdaki kodlar .NET 10, xUnit.net v3 4.0.2, Shouldly 4.3.0, AwesomeAssertions 9.6.0 ve FluentAssertions 8.11.0 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un fiyat hesabında iki kural var: 5 ve üzeri tamamlanmış siparişi olan müşteriye %5 sadakat indirimi, 50 EUR ve üzeri siparişte bedava kargo; altında kargo 4,99 EUR. Kod:
namespace Bookstore.Pricing;
public sealed record Customer(string Email, int CompletedOrders);
public sealed record PriceQuote(decimal Subtotal, decimal Discount, decimal Shipping)
{
public decimal Total => Subtotal - Discount + Shipping;
}
public sealed class PriceCalculator
{
public const decimal FreeShippingThreshold = 50m;
public const decimal StandardShipping = 4.99m;
public const int LoyalCustomerOrderCount = 5;
public const decimal LoyaltyRate = 0.05m;
public PriceQuote Quote(decimal subtotal, Customer customer)
{
decimal discount = customer.CompletedOrders >= LoyalCustomerOrderCount
? Math.Round(subtotal * LoyaltyRate, 2)
: 0m;
// Kural: 50 EUR ve üzeri siparişte kargo bedava
decimal shipping = subtotal > FreeShippingThreshold ? 0m : StandardShipping;
return new PriceQuote(subtotal, discount, shipping);
}
}
Kargo kuralı için yazılmış test, üç tutarı bir döngüde deniyor:
public sealed class PriceCalculatorLoopTests
{
[Fact]
public void Test1()
{
PriceCalculator calc = new();
decimal[] subtotals = [20m, 50m, 80m];
decimal[] expected = [4.99m, 0m, 0m];
for (int i = 0; i < subtotals.Length; i++)
{
PriceQuote q = calc.Quote(subtotals[i], new Customer("ada@example.com", 0));
Assert.Equal(expected[i], q.Shipping);
}
}
}
Test kalıyor. Yalnız test çıktısına bakarak hangi tutarın yanlış hesaplandığını söyleyebilir misin? Kodu okumadan, sadece CI (Continuous Integration) ekranındaki mesajla.
Cevap: Mesaj Hangi Satır Olduğunu Söylemiyor
failed Bookstore.Pricing.Tests.PriceCalculatorLoopTests.Test1 (8ms)
from …/p03/Bookstore.Pricing.Tests/bin/Debug/net10.0/Bookstore.Pricing.Tests.dll (net10.0|arm64)
Assert.Equal() Failure: Values differ
Expected: 0
Actual: 4.99
at Bookstore.Pricing.Tests.PriceCalculatorLoopTests.Test1() in …/p03/Bookstore.Pricing.Tests/PriceCalculatorLoopTests.cs:17
"Beklenen 0, gelen 4,99". Bu 50 için mi, 80 için mi? Mesaj söylemiyor. Testin adı (Test1) hiçbir şey söylemiyor. Döngü ilk hatada durduğu için 80'in doğru çalışıp çalışmadığını da bilmiyoruz. Hatayı bulmak için kodu açıp debugger'la döngüye girmek gerekiyor. Gerçek neden yorumla kodun çeliştiği yer: kural "50 ve üzeri" diyor, kod > kullanıyor. Bu yazıda böyle bir hatanın ilk bakışta okunduğu testlerin anatomisini çıkarıyoruz.
Arrange, Act, Assert ve Sınırları
AAA (Arrange, Act, Assert) testleri üç bölüme ayırır: ortamı hazırla, test edilen davranışı bir kez çalıştır, sonucu kontrol et. ASP.NET Core Web API (Application Programming Interface) serisinin 29. yazısı bu yapıyı tanıtıyor; burada kuralın sınırlarına bakalım. Aynı kural, döngüsüz ve isimli testlerle:
public sealed class FreeShippingTests
{
private const decimal StandardShipping = 4.99m;
private static readonly Customer NewCustomer = new("ada@example.com", CompletedOrders: 0);
private readonly PriceCalculator calculator = new();
[Fact]
public void Order_below_50_pays_standard_shipping()
{
PriceQuote quote = calculator.Quote(49.99m, NewCustomer);
Assert.Equal(StandardShipping, quote.Shipping);
}
[Fact]
public void Order_of_exactly_50_ships_free()
{
PriceQuote quote = calculator.Quote(50m, NewCustomer);
Assert.Equal(0m, quote.Shipping);
}
[Fact]
public void Order_above_50_ships_free()
{
PriceQuote quote = calculator.Quote(80m, NewCustomer);
Assert.Equal(0m, quote.Shipping);
}
}
Çıktı artık kendini anlatıyor: yalnız Order_of_exactly_50_ships_free kalıyor, diğer ikisi geçiyor. Hata 50 sınırında. Bu dönüşümde dört kural işledi.
Tek Act. Her testte test edilen kod bir kez çağrılıyor. Act bölümünde iki çağrı görüyorsan (önce AddLine, sonra Quote, sonra yine AddLine) ya test birden fazla davranışı kontrol ediyordur ya da API'nin kendisi bir şeyi gizliyordur. Khorikov bunu ikinci durumda bir tasarım sinyali olarak okur: tek bir iş için iki çağrı gerekiyorsa, o iki çağrıyı birleştiren bir method eksik olabilir.
Testte if, for, switch yok. Test kodunda dallanma, testin kendisinin de test edilmesi gerektiği anlamına gelir. Döngü, hata mesajından bağlamı siler ve ilk hatada durur. Aynı kuralı farklı girdilerle denemek istiyorsan bunun doğru aracı [Theory]; bir sonraki yazının konusu.
Magic value yerine anlamlı sabit. 4.99m testte üç kez geçseydi, okuyan biri bunun kargo ücreti mi yoksa başka bir şey mi olduğunu tahmin etmek zorunda kalırdı. StandardShipping sabiti niyeti söylüyor. Dikkat: sabit test class'ında tanımlı, production'daki PriceCalculator.StandardShipping kullanılmıyor. Beklenen değeri production kodundan almak, production'daki yanlış bir değeri testin de kabul etmesi demektir.
Minimal girdi. NewCustomer'ın e-posta adresi kargo kuralını etkilemiyor ama constructor istiyor; bu yüzden tek bir yerde, adı niyetini söyleyen bir sabitte duruyor. Testte görünen her değer okura "bu önemli" der. Davranışı etkilemeyen değerleri gizlemek, 10. yazıdaki test data builder'ların çıkış noktası.
İsimlendirme: Method_Scenario_Expected mı, Cümle mi?
.NET dünyasında iki isimlendirme stili yaygın. Roy Osherove'un The Art of Unit Testing kitabıyla yayılan Method_Scenario_Expected stili: Quote_SubtotalEqualsThreshold_ReturnsZeroShipping. Microsoft'un unit testing best practices dokümanı da bu üç parçalı yapıyı önerir. Khorikov ise bu stilin method adını teste bağladığını, method yeniden adlandırıldığında test adlarının eskidiğini söyler ve davranışı alan diliyle anlatan düz cümleleri önerir: Order_of_exactly_50_ships_free.
Ben ikincisini tercih ediyorum, çünkü test adı bir hata raporunun başlığıdır ve o raporu okuyan kişi çoğu zaman method adlarını bilmez. Hangisini seçersen seç, kural tek: ad, testin neyi garanti ettiğini söylemeli. Test1, QuoteTest, ShouldWork hiçbir şey söylemiyor.
xUnit bu cümleleri daha okunur göstermek için bir ayar sunuyor. xunit.runner.json:
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"methodDisplay": "method",
"methodDisplayOptions": "replaceUnderscoreWithSpace"
}
Aynı kalan test artık şöyle görünüyor:
failed Order of exactly 50 ships free (0ms)
Expected: 0
Actual: 4.99
methodDisplay: method class adını gösterimden çıkarıyor; class adı da bilgi taşıyorsa (FreeShippingTests) varsayılan classAndMethod'da kalmak daha iyi olabilir. 4.0 bu listeye removeAsyncSuffix'i de ekledi.
Test Class'ını Davranışa Göre Düzenlemek
İlk yazıda "her class'a bir test class'ı" refleksinin testleri class yapısına bağladığını söylemiştik. Alternatif, test class'larını davranışa göre adlandırmak: FreeShippingTests, LoyaltyDiscountTests, OrderCancellationTests. PriceCalculator yarın ShippingPolicy ve DiscountPolicy diye ikiye bölünse bu test class'larının adı hâlâ doğru kalır; içlerindeki testler yalnız hangi class'ı çağırdıklarını değiştirir.
Böyle düzenlenen bir test suite'i dokümantasyon gibi okunur. Test Explorer'da FreeShippingTests altındaki üç satır, kargo kuralının spesifikasyonu: 50'nin altında ücretli, tam 50'de bedava, üstünde bedava. Yeni gelen bir geliştirici kuralı öğrenmek için kodu değil test listesini okuyabilir. Test adlarını bu gözle yazmak, iyi bir test adının ölçüsünü de veriyor: listeyi okuyan biri kuralı yeniden kurabiliyor mu?
Okunabilir Assertion'lar
Bir testin değeri kırmızıya döndüğü an ortaya çıkar; o anda okunacak tek şey hata mesajıdır. xUnit'in yerleşik Assert'i record'larda iyi iş çıkarıyor, çünkü record'ların ToString'i bütün property'leri yazar:
[Fact]
public void Builtin_record_equality()
{
PriceQuote quote = calculator.Quote(50m, Loyal);
Assert.Equal(new PriceQuote(Subtotal: 50m, Discount: 2.50m, Shipping: 0m), quote);
}
Assert.Equal() Failure: Values differ
Expected: PriceQuote { Subtotal = 50, Discount = 2.50, Shipping = 0, Total = 47.50 }
Actual: PriceQuote { Subtotal = 50, Discount = 2.50, Shipping = 4.99, Total = 52.49 }
Bir sonucun birkaç yönünü kontrol etmek bir davranışı kontrol etmenin parçası olabilir: sadık müşterinin 50 EUR'luk siparişinde indirim, kargo ve toplam birlikte bir anlam taşır. Ardışık Assert'ler ilk hatada durur; Assert.Multiple hepsini çalıştırıp hataları birlikte raporlar:
[Fact]
public void Loyal_customer_quote_at_50_has_discount_free_shipping_and_total()
{
PriceQuote quote = new PriceCalculator().Quote(50m, Loyal);
Assert.Multiple(
() => Assert.Equal(2.50m, quote.Discount),
() => Assert.Equal(0m, quote.Shipping),
() => Assert.Equal(47.50m, quote.Total));
}
Assert.Multiple() Failure: Multiple failures were encountered
---- Assert.Equal() Failure: Values differ
Expected: 0
Actual: 4.99
---- Assert.Equal() Failure: Values differ
Expected: 47.50
Actual: 52.49
Burada sınırı iyi çizmek gerekiyor: "tek davranış" kuralı "tek Assert" demek değil. Bir davranışın sonucu üç property'de görünüyorsa üçünü kontrol etmek doğru. Yanlış olan, ilgisiz iki davranışı (indirim kuralı ve iptal kuralı gibi) aynı testte doğrulamak.
Shouldly, AwesomeAssertions ve FluentAssertions 8'in Lisansı
Yerleşik Assert'in mesajı neyin karşılaştırıldığını söylemez, yalnız değerleri verir. Assertion kütüphanelerinin sattığı şey tam olarak bu boşluk. Aynı kalan kontrolün üç kütüphanedeki hâli:
// Shouldly
quote.Shipping.ShouldBe(0m);
// AwesomeAssertions (using AwesomeAssertions;)
quote.Should().BeEquivalentTo(new { Subtotal = 50m, Discount = 2.50m, Shipping = 0m, Total = 47.50m });
// FluentAssertions 8 (using FluentAssertions;)
quote.Shipping.Should().Be(0m);
Shouldly.ShouldAssertException : quote.Shipping
should be
0m
but was
4.99m
Expected property quote.Shipping to be 0M, but found 4.99M.
Expected property quote.Total to be 47.50M, but found 52.49M.
Expected quote.Shipping to be 0M, but found 4.99M (difference of 4.99).
Üçü de testin kaynak kodundaki ifadeyi (quote.Shipping) mesaja taşıyor; Shouldly bunu çağrının yapıldığı kaynak kod satırını okuyarak yapıyor. AwesomeAssertions'ın equivalency karşılaştırması anonim bir object ile record'u property bazında eşleştirip iki farkı birden raporluyor (çıktıda bunun ardından kullanılan karşılaştırma ayarlarının uzun bir listesi de var).
Seçimde teknik farklar kadar lisans da önemli. FluentAssertions, Ocak 2025'te Xceed ile ortaklık duyurdu; 8.0 ile lisans Apache 2.0'dan Xceed'in ticari lisansına geçti. Ticari olmayan kullanım ücretsiz, ticari kullanım ücretli abonelik istiyor; InfoQ'nun haberine göre tek geliştirici için yıllık 129,95 USD. 7.x sürüm hattı Apache 2.0 olarak kalıyor. 8.11.0 ile koşan testlerin çıktısına, her koşuda, Xceed'in lisans uyarısı düşüyor: "You may use Fluent Assertions free of charge for non-commercial use only." AwesomeAssertions bu değişikliğe tepki olarak topluluk tarafından başlatılan bir fork; README'si lisansın Apache 2.0 kalacağını taahhüt ediyor. v9 ile namespace AwesomeAssertions oldu, bu yüzden FluentAssertions'tan geçiş bir paket ve using değişikliği. Shouldly 4.3.0'ın lisansı ise BSD (Berkeley Software Distribution) lisansının üç maddelik hâli, yani BSD-3-Clause.
Bu seride yerleşik Assert'i kullanıyoruz. Gerekçe basit: ek bağımlılık yok, xUnit analyzer'ları onu tanıyor ve record'larla mesajları yeterince okunur. Ekibin zaten Shouldly ya da AwesomeAssertions kullandığı bir projede onları kullanmamak için de bir neden yok; önemli olan bir projede tek bir assertion stili olması.
Üretimde Dikkat
CI'da hata mesajının okunabildiğini kontrol et. Kırmızı bir build'in ekran görüntüsüne bak: test adı ve mesaj, kodu açmadan sorunu anlatıyor mu? Anlatmıyorsa sorun kodda değil testtedir. Ekip içinde "bu test neden kırıldı?" sorusunun sık sorulması, isimlendirmenin ve assertion'ların zayıf olduğunu gösterir.
Lisans denetimi bağımlılık güncellemesinin parçası. Dependabot ya da Renovate gibi bir araç FluentAssertions'ı 7'den 8'e otomatik yükseltebilir ve ticari bir projede bu, kimse fark etmeden lisans koşullarını değiştirir. Test bağımlılıkları da üretim bağımlılıkları kadar lisans kontrolünden geçmeli; 07. yazıda Moq'un 2023'teki olayıyla bu konuya döneceğiz.
Ondalık sayılarla dikkat. Assert.Equal(0.1 + 0.2, 0.3) double'da kalır; para hesabında zaten decimal kullanıyoruz. double karşılaştırman gerekiyorsa Assert.Equal(expected, actual, precision) ya da tolerans alan overload'ları kullan.
Sık Düşülen Tuzaklar
Birden fazla davranışı tek testte assert etmek. "Sipariş tutarı doğru hesaplanır ve iptal edilince stok geri döner" iki testtir. Tek testte birleştiğinde, ilk kısım kırıldığında ikinci kısım hiç çalışmaz ve test adı hangisinin bozulduğunu söyleyemez.
Hata mesajı okunmayan assertion. Assert.True(quote.Shipping == 0m) kaldığında mesaj "Expected: True, Actual: False" der; değerler kaybolur. Karşılaştırma için Assert.Equal, koleksiyon için Assert.Contains/Assert.Single, tip için Assert.IsType gibi niyeti söyleyen assertion'ları seç. Denediğimde xUnit analyzer'ları (2.2.0) Assert.True(shipping == 0m) ve Assert.True(list.Count == 1) için uyarı vermedi; bu disiplin sana kalıyor.
Implementation detail assert etmek. PriceCalculator'ın içinde private bir cache dictionary'si var diye, reflection ile o dictionary'nin dolduğunu kontrol etmek. Ya da bir log satırının tam metnini kontrol etmek. Bunlar davranış değil, davranışın o günkü gerçekleştirilme biçimidir; ilk refactoring'de yanlış alarm üretirler.
Beklenen değeri production koduyla hesaplamak. Assert.Equal(subtotal * PriceCalculator.LoyaltyRate, quote.Discount) testi, production'daki formülün bir kopyasıdır: formül yanlışsa test de aynı yanlışı yapar. Beklenen değeri elle hesaplanmış bir sabit olarak yaz: 50 EUR'nun %5'i 2,50 EUR.
Küçük Bir Deneme
★☆☆ PriceCalculator'daki hatayı düzelt (> yerine >=) ve döngülü testi sil. FreeShippingTests'in üç testinin adını, kuralı bilmeyen birine okut: kuralı doğru tarif edebiliyor mu?
★★☆ Sadakat indirimi için üç test yaz: 4 siparişli müşteri indirim almaz, 5 siparişli alır, indirim kuruşa yuvarlanır (ör. 33,33 EUR'luk siparişte 1,67 EUR). Beklenen değerleri production sabitlerini kullanmadan yaz.
★★★ Projendeki bir test dosyasında Assert.True(x == y) ya da Assert.True(list.Count == 1) gibi kullanımları ara. Bunları niyeti söyleyen assertion'larla değiştir, birini bilerek kırmızıya döndür ve önceki ve sonraki hata mesajlarını karşılaştır.
Bir sonraki yazıda döngünün doğru karşılığını, veri odaklı testleri ele alıyoruz: [Theory], MemberData, tip güvenli TheoryData, satır bazında metadata taşıyan TheoryDataRow ve kombinasyonlar için MatrixTheoryData.