Test Pyramid, Testing Trophy ve Honeycomb: Hangi Şekil Hangi Sisteme Uyar?
Doğru şekil, sistemde güveni en ucuza hangi test seviyesinin verdiğine bağlıdır: domain mantığı yoğun bir API pyramid'e, ince bir CRUD API ile Angular SPA trophy'ye, microservice'ler contract testlerle honeycomb'a yakışır. InMemory provider'la yeşil yanıp gerçek SQLite'ta 500 dönen bir arama sorgusu, static katmanın 0,8 saniyede yakaladığı bir tip hatası ve ice-cream cone ile hourglass tuzakları.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.402), Entity Framework Core 10.0.14, xUnit.net v3 4.0.2, Angular 22.2 ve TypeScript 6.0 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'a kitap arama ekliyoruz: GET /api/books/search?q=clean, büyük küçük harfe bakmadan başlığında "clean" geçen kitapları dönsün. Sorgu Entity Framework Core (EF Core) ile tek satır. Bunun için iki test yazıyoruz. Birincisi service'i EF Core'un InMemory provider'ıyla doğrudan çağırıyor ve yarım saniyenin altında koşuyor. İkincisi aynı davranışı HTTP (Hypertext Transfer Protocol) üzerinden, gerçek SQLite ile doğruluyor. İkisinden biri kırmızı yanacak. Hangisi? Ve bu cevap, böyle ince bir CRUD (Create, Read, Update, Delete) API'si (Application Programming Interface) için test pyramid'i mi yoksa başka bir şekli mi seçmen gerektiği hakkında ne söylüyor?
Kısa Tarihçe: Pyramid'den Honeycomb'a
Test pyramid'i Mike Cohn'un (Scrum eğitmeni, User Stories Applied kitabının yazarı) 2009 tarihli Succeeding with Agile kitabıyla yaygınlaştı. Martin Fowler'ın (Thoughtworks'ün baş bilim insanı) 2012'deki kısa yazısına göre Cohn bu çizimi birkaç yıl önce Lisa Crispin'le konuşurken yapmıştı. Fikir basit: altta çok sayıda hızlı unit test, ortada daha az service seviyesinde test, tepede çok az UI (User Interface) üzerinden giden test. Fowler'ın gerekçesi de net: UI testleri kırılgan, yavaş ve yazması pahalı.
2018'de Ham Vocke'nin (Thoughtworks danışmanı) "The Practical Test Pyramid" makalesi şekli iki kurala indirdi: farklı granularity'de test yaz; yukarı çıktıkça daha az test yaz. Vocke katman isimlerinin bulanık olduğunu da kabul ediyor ve ekibin kendi isimlerinde anlaşmasını öneriyor.
Aynı yıl Spotify mühendisleri André Schaffer ve Rickard Dybeck, microservice'ler için honeycomb'u önerdi. Alt katmanda az sayıda "implementation detail" testi, ortada en kalabalık katman olarak integration testleri, tepede ise mümkünse hiç olmaması gereken "integrated" testler. Spotify'ın tanımında integrated test, başka bir sistemin doğruluğuna göre geçen ya da kalan testtir.
Kent C. Dodds (JavaScript eğitmeni, Testing Library'nin yazarı) ise testing trophy'yi çizdi. Çıkış noktası Guillermo Rauch'un (Vercel'in kurucusu) Aralık 2016'daki "Write tests. Not too many. Mostly integration." tweet'i. Trophy dört katmanlı: en altta static analiz (tip denetimi, linter), sonra unit, en geniş katman olarak integration, en üstte az sayıda E2E (End-to-End). Dodds, 2021 tarihli "The Testing Trophy and Testing Classifications" yazısında bu tanımları netleştirdi.
Bir de istenmeyen şekil var: ice-cream cone. Testlerin çoğu UI üzerinden ya da elle yapılıyor, unit test neredeyse yok; Fowler bunu pyramid'in ters dönmüş hali olarak anıyor. Software Engineering at Google kitabı (Titus Winters, Tom Manshreck ve Hyrum Wright, O'Reilly, 2020) buna bir de hourglass'ı ekliyor: çok unit test, çok E2E, arada neredeyse hiç integration test yok. Kitaba göre bu, parçaları birbirinden ayırmanın zor olduğu, sıkı bağlı sistemlerde ortaya çıkıyor.

Ortak Fikir: Güven Bölü Maliyet
Dört şekil birbirine rakip gibi sunuluyor ama aynı soruya cevap veriyorlar: harcadığın her dakika ve her bakım saati karşılığında ne kadar güven alıyorsun? Dodds bunu açıkça yatırım getirisi olarak tarif ediyor; getiri güven, yatırım zaman. Rehber ilkesi de şu: testlerin yazılımın kullanılma biçimine benzedikçe verdikleri güven artar.
Şekilleri ayıran şey, bu oranın senin sisteminde hangi seviyede en yüksek olduğu. Bu oran üç şeye bağlı. Mantığın nerede yaşadığına: karmaşık hesaplama domain'de mi, yoksa veritabanı sorgusunda ve framework ayarlarında mı? Bir seviyedeki testin maliyetine: Bookstore'da bir small unit test 0–6 milisaniye, bir integration test yarım saniye, bir E2E testin ortamı dört saniye sürüyordu. Ve hataların nerede çıktığına: üretim olaylarını geriye doğru incelediğinde, kök neden genelde hangi katmanda?
Bu yüzden "hangi şekil doğru?" sorusu yanlış soru. Doğru soru, "bu sistemde güveni en ucuza hangi seviye veriyor?" Aşağıda dört farklı Bookstore parçası için bu soruyu cevaplıyoruz.
Domain Mantığı Yoğun .NET API: Pyramid'e Yakın
İndirim kuralı saf bir hesap: eşik, müşteri seviyesi ve ikisinin kombinasyonu. Böyle bir kuralda hatalar sınırlarda saklanır, sınırları da en ucuz unit test dolaşır:
[Trait("Size", "Small")]
public class DiscountRateTests
{
[Theory]
[InlineData(0, CustomerTier.Standard, 0)]
[InlineData(499.99, CustomerTier.Standard, 0)]
[InlineData(500, CustomerTier.Standard, 0.10)]
[InlineData(500.01, CustomerTier.Standard, 0.10)]
[InlineData(0, CustomerTier.Gold, 0.05)]
[InlineData(499.99, CustomerTier.Gold, 0.05)]
[InlineData(500, CustomerTier.Gold, 0.15)]
[InlineData(12000, CustomerTier.Gold, 0.15)]
public void Rate_depends_on_threshold_and_tier(decimal subtotal, CustomerTier tier, decimal expected) =>
Assert.Equal(expected, DiscountPolicy.RateFor(subtotal, tier));
}
=== TEST EXECUTION SUMMARY ===
Bookstore.Domain.Tests Total: 8, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0, Time: 0.051s
Sekiz sınır değeri 51 milisaniyede. Aynı sekiz kombinasyonu tarayıcıdan doğrulamaya kalksaydın, bir önceki yazıda ölçtüğümüz test başına 0,4–0,5 saniye ve ortam için dört saniyeyle, kabaca 8 saniye harcardın; üstelik 499,99 TL'lik bir sepeti kurmak için sahte kitaplar üretmen gerekirdi. Mantığın büyük kısmı domain'deyse, yani fiyatlandırma, vergi, kampanya kuralları, rezervasyon algoritmaları varsa, pyramid gerçekten en iyi orana sahip şekil. İnce bir integration katmanı da bu kuralların endpoint'e doğru bağlandığını, birkaç E2E testi de ana akışın çalıştığını gösterir. Bu seviyede neyin unit, neyin integration test olacağına bir sonraki yazıda ayrıntılı bakacağız.
İnce CRUD API ve EF Core: Integration Ağırlıklı
Şimdi Önce Tahmin Et'teki arama endpoint'ine dönelim. Service'in bütün işi bir sorgu:
public sealed class BookSearch(BookstoreDb db)
{
public Task<List<Book>> SearchAsync(string term, CancellationToken ct) =>
db.Books
.Where(b => b.Title.Contains(term, StringComparison.OrdinalIgnoreCase))
.OrderBy(b => b.Title)
.ToListAsync(ct);
}
Pyramid refleksiyle önce unit test yazıyoruz. Gerçek veritabanı "yavaş" olduğu için EF Core'un InMemory provider'ını kullanıyoruz:
public class BookSearchTests
{
[Fact]
public async Task Finds_books_case_insensitively()
{
DbContextOptions<BookstoreDb> options = new DbContextOptionsBuilder<BookstoreDb>()
.UseInMemoryDatabase(nameof(Finds_books_case_insensitively))
.Options;
await using BookstoreDb db = new(options);
BookstoreDb.Seed(db);
List<Book> books = await new BookSearch(db).SearchAsync("clean", TestContext.Current.CancellationToken);
Assert.Equal(["Clean Architecture"], books.Select(b => b.Title));
}
}
Yanına aynı davranışı API üzerinden, gerçek SQLite ile doğrulayan bir integration test koyuyoruz:
[Trait("Size", "Medium")]
public class BookSearchApiTests
{
[Fact]
public async Task Finds_books_case_insensitively()
{
await using BookstoreApiFactory factory = new();
HttpClient client = factory.CreateClient();
List<Book>? books = await client.GetFromJsonAsync<List<Book>>("/api/books/search?q=clean",
TestContext.Current.CancellationToken);
Assert.Equal(["Clean Architecture"], books!.Select(b => b.Title));
}
}
tests/Bookstore.Api.UnitTests/bin/Debug/net10.0/Bookstore.Api.UnitTests.dll (net10.0|arm64) passed (334ms)
failed Bookstore.Api.Tests.BookSearchApiTests.Finds_books_case_insensitively (492ms)
from tests/Bookstore.Api.Tests/bin/Debug/net10.0/Bookstore.Api.Tests.dll (net10.0|arm64)
Xunit.MicrosoftTestingPlatform.XunitException: System.Net.Http.HttpRequestException : Response status code does not indicate success: 500 (Internal Server Error).
Unit test yeşil, integration test kırmızı. Sunucu logundaki asıl hata:
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.
InMemory provider sorguyu SQL'e (Structured Query Language) çevirmiyor, LINQ (Language Integrated Query) to Objects gibi bellekte çalıştırıyor; StringComparison parametreli Contains orada sorunsuz. SQLite provider ise bu overload'u çeviremiyor ve endpoint üretimde her istekte 500 dönecekti. Unit test, kodun tam da kırılabileceği yeri, yani sorgunun veritabanına çevrilmesini, test dışına çıkarmış. EF Core dokümantasyonu da InMemory provider'la test etmeyi önermiyor; nedenlerini EF Core serisinin "EF Core ile Test: SQLite In-Memory ve InMemory Provider Tuzakları" yazısı ayrıntılı anlatıyor.
Düzeltme tek satır, ve doğruluğunu bize integration test söylüyor:
.Where(b => EF.Functions.Like(b.Title, $"%{term}%"))
Test run summary: Passed!
tests/Bookstore.Api.Tests/bin/Debug/net10.0/Bookstore.Api.Tests.dll (net10.0|arm64) passed (719ms)
tests/Bookstore.Domain.Tests/bin/Debug/net10.0/Bookstore.Domain.Tests.dll (net10.0|arm64) passed (181ms)
tests/Bookstore.Api.UnitTests/bin/Debug/net10.0/Bookstore.Api.UnitTests.dll (net10.0|arm64) passed (421ms)
SQLite'ta LIKE ASCII (American Standard Code for Information Interchange) karakterlerde büyük küçük harfe duyarsız. SQL Server'da ise sonuç kolonun collation'ına bağlı; bu da yine ancak gerçek veritabanına karşı koşan bir testle doğrulanabilecek bir ayrıntı. Mantığın çoğu sorguda, mapping'de, validation attribute'larında ve middleware sırasında yaşayan bir API'de güveni en ucuza integration test verir. Unit test'e yalnız gerçekten hesap yapan küçük parçalar kalır. Bu bir trophy, ya da tek service için çizilmiş bir honeycomb.
Angular SPA: Trophy ve Static Katman
Trophy'nin en alt katmanı çoğu .NET geliştiricisinin "test" saymadığı bir şey: static analiz. Dodds bunu JavaScript'te tip denetimi varsayılan olmadığı için eklediğini söylüyor. Angular projesinde TypeScript derleyicisi bu katmanın ta kendisi. Sepete bir hediye kitap ekleyen yeni bir fonksiyonda fiyatı string yazdığımızda:
export function addGiftBook(cart: CartStore): void {
cart.add({ id: 9, title: 'Refactoring', price: '310', stock: 3 });
}
$ time npx tsc -p tsconfig.app.json --noEmit
src/app/gift-card.ts(4,43): error TS2322: Type 'string' is not assignable to type 'number'.
npx tsc -p tsconfig.app.json --noEmit 0.79s user 0.17s system 119% cpu 0.804 total
Tek bir test yazmadan, 0,8 saniyede. Aynı hatayı bir unit test de yakalardı, ama önce birinin o testi yazmayı akıl etmesi gerekirdi. C# tarafında Nullable açık bir proje ve analyzer'lar aynı görevi görüyor.
Bir SPA'da (Single Page Application) davranışın büyük kısmı component'lerin birbirine, service'lere ve template'e nasıl bağlandığında yaşar. Sepet component'ini store'u, template'i ve pipe'ıyla birlikte render eden bir önceki yazıdaki test, Dodds'un tanımıyla bir integration test; trophy'nin en geniş katmanı bu tür testler. Saf hesap yapan küçük fonksiyonlar unit test alır, birkaç kritik akış (giriş, sepet, ödeme) da Playwright ile E2E test. Angular tarafındaki araçlar ilerideki Frontend Testleri serisinin konusu.
Microservice: Honeycomb ve Contract
Bookstore büyüyüp sipariş, stok ve kargo ayrı service'lere bölündüğünde tablo yine değişir. Spotify'ın makalesindeki gözlem şu: microservice küçük olduğu için asıl karmaşıklık içeride değil, service'lerin birbiriyle konuşmasında. Her class için unit test yazmak testi implementation'a bağlar; her değişiklik test değişikliği getirir.
Honeycomb'un ortasındaki integration test, service'i kendi veritabanıyla birlikte ayağa kaldırır ve yalnız dışarıdan görünen API'si üzerinden konuşur. Sipariş service'i için bu, stok ve kargo service'lerinin yerine test double koyup kendi HTTP endpoint'lerini ve kendi veritabanını gerçek haliyle test etmek demek. Test double'ların gerçek service'lerle uyumlu kaldığını ise contract testler garanti eder: kargo service'inin ekibi, sipariş service'inin beklediği alanları kırdığı anda kendi pipeline'ında öğrenir. Consumer-driven contract'ın mekaniğini Microservices serisinin "Service Contract'ları ve Versioning: Expand-Contract, Tolerant Reader ve Consumer-Driven Contract Test" yazısı anlatıyor.
Honeycomb'un tepesindeki integrated testlerden, yani bütün service'lerin ortak bir ortamda ayakta olmasını gerektiren testlerden kaçınılır. Spotify'ın uyarı işaretleri tanıdık: testi koşmak için başka service'leri lokalde çalıştırmak, paylaşılan bir test ortamına karşı test etmek ve senin değişikliğinin başka bir ekibin testini kırması.
Üretimde Dikkat
Gerçek şeklini ölç. Her test projesinin test sayısını ve toplam süresini bir tabloya dök; seviyeye göre grupla. Çoğu ekip bunu ilk yaptığında çizdiğini sandığı pyramid yerine bir hourglass ya da bir ice-cream cone görüyor. Google kitabındaki yüzde 80 unit, yüzde 15 integration, yüzde 5 E2E oranı bir başlangıç tahmini; kitap da her ekibin karışımının farklı olacağını söylüyor.
Üretim olaylarını katmana göre etiketle. Son on olayın kök nedeni domain hesabında mıydı, sorguda mıydı, configuration'da mıydı, iki service arasındaki sözleşmede miydi? Bu dağılım hangi seviyeye yatırım yapman gerektiğini, herhangi bir şemadan daha iyi söyler.
Seviyeleri pipeline'da ayır. Small testler her commit'te, integration testler pull request'te, E2E ve contract doğrulamaları deploy öncesinde. Bunun ayrıntısı, Continuous Integration / Continuous Delivery (CI/CD) üzerine olan CI/CD Temelleri serisinin "Pipeline'da Test: Hızlı Geri Bildirim, Quality Gate'ler ve Flaky Test'ler" yazısında.
Sık Düşülen Tuzaklar
Şekli hedef yapmak. "Unit testlerimiz yüzde 70'in altına düştü" diye alarm kuran bir ekip, arama endpoint'indeki gibi değeri olmayan unit testler yazarak oranı düzeltir ve güveni azaltır. Şekil, doğru kararların sonucunda ortaya çıkan bir görüntüdür.
E2E'yi tek güvenlik ağı yapmak. Her şeyi tarayıcıdan test eden bir suite, bir önceki yazıda gördüğümüz gibi paylaşılan veriye takılır, yavaşlar ve flaky olur. Kırmızı yandığında da hatanın hangi katmanda olduğunu söylemez.
Microservice'te her şeyi E2E ile test etmek. On service'in aynı anda ayakta olduğu bir staging ortamı, her deploy'u bütün ekiplerin ortak sırasına sokar. Bağımsız deploy edilebilirliğin, yani microservice'e geçmenin asıl amacının, tam tersi.
InMemory provider'ı "hızlı integration test" sanmak. Yukarıdaki gibi, sorgu çevirisini test etmeyen bir test, ince bir CRUD API'de neredeyse hiçbir şeyi test etmez.
Static katmanı unutmak. Nullable uyarılarını kapatan ya da any ile dolu bir kod tabanı, trophy'nin en ucuz katmanını kendi eliyle atmış olur.
Küçük Bir Deneme
★☆☆ Kendi projendeki testleri seviyeye göre say ve her seviyenin toplam süresini yaz. Ortaya çıkan şekil dört şekilden hangisine benziyor?
★★☆ Son on üretim hatasının kök nedenini domain, sorgu, configuration, sözleşme ve UI olarak sınıflandır. En kalabalık sınıfı en ucuza yakalayacak test seviyesi hangisi?
★★★ BookSearchTests'i SQLite in-memory ile yeniden yaz ve StringComparison'lı sorguyu geri getir. Test kırmızı yanıyor mu? SQL Server'da Türkçe bir collation ile "ISTANBUL" araması "istanbul" kaydını bulur mu, bunu hangi test seviyesinde doğrularsın?
Şekli seçmek için sistemin karakterini okumak gerekiyor. Ama tek bir özellikte bile kararı satır satır vermen gerekecek: indirim hesabı unit test mi, stok düşme integration test mi, ödeme service'i mock mu, gerçek mi? Bir sonraki yazıda Vladimir Khorikov'un (Unit Testing Principles, Practices, and Patterns kitabının yazarı) kod dörtgeni ve managed/unmanaged bağımlılık ayrımıyla bu kararları veriyoruz.