Neden Test Yazarız? Unit, Integration, Contract, E2E, Smoke ve Diğer Test Türleri
Test yazmanın asıl getirisi regression'a karşı güven, değişiklik hızı ve çalışan dokümantasyondur; bedeli yazma, bakım, süre ve flaky testtir. Test türlerini scope (unit, component, integration, contract, E2E) ve amaç (smoke, regression, acceptance, exploratory) eksenlerinde ayırıyor, Google'ın small/medium/large sınıflandırmasının scope'tan bağımsız olduğunu Bookstore'daki altı gerçek test ve ölçülen sürelerle gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.402), xUnit.net v3 4.0.2, Angular 22.2, Vitest 5.1 ve Playwright 1.65 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'da altı test var: indirim kuralını kontrol eden bir test, fiyat listesini diskteki bir CSV (Comma-Separated Values) dosyasından okuyan bir test, Angular sepet component'ini render eden bir test, POST /api/orders endpoint'ini bellekteki bir veritabanıyla çağıran bir test, tarayıcıda gerçek bir sipariş veren bir test ve deploy sonrası iki adrese istek atan bir script. Üç soru: Bunlardan hangisi "unit test"? Hangisi Google'ın tanımıyla "small" test? Tarayıcıdaki test ilk koşuda geçip ikinci koşuda kırılıyor; kodda hiçbir şey değişmedi. Neden? Cevaplar yazının içindeki gerçek çıktılarda.
Test Neyi Satın Alır, Faturası Ne?
"Neden test yazıyoruz?" sorusuna verilen ilk cevap genelde "hataları bulmak için" olur. Eksik bir cevap. Testlerin çoğu yeni hata bulmaz; zaten çalışan bir davranışın çalışmaya devam ettiğini her commit'te tekrar tekrar söyler. Asıl satın aldığın üç şey var.
Birincisi regression'a karşı güven. Sipariş indirimini değiştiren bir pull request'in stok düşme kodunu bozmadığını, birinin üretimde fark etmesini beklemeden öğrenirsin. İkincisi değişiklik yapabilme hızı. Testi olmayan kodda herkes dokunmaktan çekinir; refactoring ertelenir, kod çürür. İyi bir test suite'i "bunu değiştirsem ne kırılır?" korkusunu birkaç saniyelik bir komuta indirir. Üçüncüsü çalışan dokümantasyon: Gold_customer_over_threshold_gets_fifteen_percent adlı test, wiki sayfasından farklı olarak eskiyemez; eskirse kırmızı yanar.
Faturası da dört kalemden oluşur. Yazma maliyeti: testi yazmak çoğu zaman kodu yazmak kadar sürer. Bakım maliyeti: davranış değişmediği halde implementation değişti diye kırılan her test, ekibe vergi gibi yansır. Süre: bir suite on dakika sürüyorsa geliştirici sonucu beklemeden başka işe geçer ve geri bildirimin değeri düşer. Son kalem güvensizlik: arada bir sebepsiz kırılan, yani flaky bir test, suite'in tamamına olan inancı aşındırır. Software Engineering at Google kitabının (Titus Winters, Tom Manshreck ve Hyrum Wright, O'Reilly, 2020) 11. bölümü bu eşiği yaklaşık yüzde 1 olarak veriyor; flaky oranı buna yaklaştığında mühendisler kırmızıya tepki vermeyi bırakıyor.
Bu yüzden test stratejisi "daha çok test" demek değildir. Her testin getirdiği güvenle bu dört kalemin dengesini kurmak demektir. Bu seri o dengeyi kurmanın dilini veriyor; bu yazıda önce terimleri yerine oturtuyoruz.
Scope'a Göre: Unit, Component, Integration, Contract, E2E
İlk eksen, testin doğruladığı kodun genişliği, yani scope. En dar ucunda unit test var. Bookstore'un indirim kuralı saf bir fonksiyon; veritabanı, ağ, saat yok:
[Trait("Size", "Small")]
public class DiscountPolicyTests
{
[Fact]
public void Gold_customer_over_threshold_gets_fifteen_percent()
{
OrderLine[] lines = [new("978-0134494166", 320m, 2)];
decimal total = DiscountPolicy.TotalFor(lines, CustomerTier.Gold);
Assert.Equal(544m, total);
}
}
640 TL'lik sepet 500 TL eşiğini geçtiği için yüzde 10, Gold müşteri olduğu için ek yüzde 5 indirim alıyor: 544 TL. "Unit" kelimesinin bir method mu, bir class mı, yoksa bir davranış mı olduğu ayrı bir tartışma; o tartışmayı ilerideki Unit Test serisinin ilk yazısı ayrıntısıyla ele alıyor. Burada önemli olan, testin tek bir mantık parçasını dış dünyadan yalıtık doğrulaması.
Bir basamak yukarıda component test var. Martin Fowler'ın (Thoughtworks'ün baş bilim insanı, Refactoring kitabının yazarı) tanımıyla component test, sistemin bir parçasını sınırlayıp geri kalanını bilerek dışarıda bırakır. Frontend tarafında bu genelde tek bir UI (User Interface) component'ini render edip ekranda ne gördüğünü kontrol etmek demek:
describe('Cart', () => {
it('shows the total and does not exceed stock', async () => {
const store = TestBed.inject(CartStore);
const book = { id: 2, title: 'Domain-Driven Design', price: 450, stock: 2 };
store.add(book);
store.add(book);
store.add(book);
const fixture = TestBed.createComponent(Cart);
await fixture.whenStable();
const text = (fixture.nativeElement as HTMLElement).textContent;
expect(text).toContain('Domain-Driven Design x 2');
expect(text).toContain('Total: 900.00');
});
});
Stokta iki kitap varken üç kez "Add" denince sepette iki kitap kalmalı. Test template'i, signal'ı ve pipe'ı birlikte çalıştırıyor ama HTTP (Hypertext Transfer Protocol) yok, API (Application Programming Interface) yok. Angular component testlerinin nasıl kurulduğu ilerideki Frontend Testleri serisinin konusu.
Integration test birden fazla parçanın birlikte doğru çalıştığını doğrular. Bookstore'da bu, endpoint'i gerçek routing, gerçek model binding, gerçek Entity Framework Core (EF Core) sorgularıyla ama bellekteki bir SQLite veritabanı ve sahte bir e-posta service'iyle çağırmak demek:
[Trait("Size", "Medium")]
public class OrdersApiTests
{
[Fact]
public async Task Placing_an_order_lowers_stock_and_sends_confirmation()
{
await using BookstoreApiFactory factory = new();
HttpClient client = factory.CreateClient();
HttpResponseMessage response = await client.PostAsJsonAsync("/api/orders",
new { customerEmail = "ada@example.com", tier = "Gold", items = new[] { new { bookId = 1, quantity = 2 } } },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
List<Book>? books = await client.GetFromJsonAsync<List<Book>>("/api/books", TestContext.Current.CancellationToken);
Assert.Equal(3, books![0].Stock);
Assert.Equal(["ada@example.com: Order #1 confirmed"], factory.Emails.Sent);
}
}
BookstoreApiFactory'nin içinde ne olduğu bu yazının konusu değil; WebApplicationFactory'yi ASP.NET Core Web API serisinin 30. yazısı anlatıyor, daha derin hali ilerideki Integration ve E2E (End-to-End) Test serisinde. Dikkat etmen gereken şey şu: bu tek test, unit test'in göremeyeceği şeyleri yakalıyor. JSON (JavaScript Object Notation) içindeki "Gold" string'inin enum'a çevrilmesi, stok düşüşünün gerçekten veritabanına yazılması, e-postanın commit'ten sonra gönderilmesi.
Contract test, iki tarafın, örneğin Angular istemcisinin ve API'nin, aralarındaki sözleşmede anlaştığını doğrular. Consumer-driven contract yaklaşımında istemci "benim için POST /api/orders şu alanları dönmeli" diye bir beklenti yazar; Pact gibi bir araç bu beklentiyi bir dosyaya döker, API tarafı da kendi pipeline'ında o dosyayı doğrular. Böylece iki taraf da diğerini ayağa kaldırmadan sözleşmeyi kırıp kırmadığını öğrenir. Ham Vocke'nin (Thoughtworks danışmanı) 2018 tarihli "The Practical Test Pyramid" makalesi bunu ayrıntılı anlatıyor; uygulamasını Integration ve E2E Test serisinde yapacağız.
En geniş uçta E2E test var: gerçek tarayıcı, gerçek Angular build'i, gerçek API, gerçek veritabanı:
test('a gold customer buys two books and sees the confirmation', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Add Clean Architecture' }).click();
await page.getByRole('button', { name: 'Add Clean Architecture' }).click();
await expect(page.getByTestId('cart-total')).toHaveText('Total: 640.00');
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByRole('status')).toHaveText('Order #1 confirmed, paid 544');
});
Bu test, kullanıcının yaptığı şeye en çok benzeyen test. Aynı zamanda en çok hareketli parçaya sahip olan: Playwright'ın iki ayrı process'i (API ve ng serve) başlatması, CORS (Cross-Origin Resource Sharing) ayarının doğru olması, tarayıcının açılması gerekiyor.
Amaca Göre: Smoke, Regression, Acceptance, Exploratory
İkinci eksen testin neden koştuğu. Bu isimler scope'tan bağımsızdır; bir smoke test unit test de olabilir, E2E test de.
Smoke test "uygulama ayağa kalktı mı, en temel yolu çalışıyor mu?" sorusunu soran, saniyeler süren, sığ bir kontroldür. Deploy'dan hemen sonra koşar:
#!/usr/bin/env bash
set -euo pipefail
base_url="${1:?usage: smoke.sh <base-url>}"
for path in /health /api/books; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$base_url$path")
echo "$path -> $code"
[ "$code" = "200" ] || exit 1
done
echo "smoke OK"
$ time ./deploy/smoke.sh http://localhost:19903
/health -> 200
/api/books -> 200
smoke OK
./deploy/smoke.sh http://localhost:19903 0.01s user 0.01s system 5% cpu 0.226 total
API'yi durdurup aynı script'i tekrar çalıştırdığımızda hiçbir satır basılmadan 7 koduyla çıktı; bu curl'ün "bağlanamadım" kodu ve set -e sayesinde pipeline orada durur. Smoke test'in işi davranışı doğrulamak değil, "bu build'i daha fazla test etmeye değer mi?" sorusuna çeyrek saniyede cevap vermek.
Regression test ayrı bir tür değil, bir rol: daha önce çalışan bir davranışın hâlâ çalıştığını doğrulayan her test regression test'tir. Bir bug'ı düzeltirken önce onu yeniden üreten bir test yazmak, o bug'ın bir daha geri gelmemesini garanti eden en ucuz regression testidir. Acceptance test, bir gereksinimin iş tarafının diliyle karşılandığını doğrular: "Gold müşteri 500 TL üstü siparişte yüzde 15 indirim görür." Genelde E2E ya da API seviyesinde yazılır, iş birimiyle birlikte okunur. Exploratory testing ise otomatik değildir: Cem Kaner'ın (yazılım test uzmanı, Testing Computer Software kitabının yazarı) 1984'te adını koyduğu, bir insanın sistemi öğrenirken aynı anda test tasarlayıp yürüttüğü çalışma biçimi. Otomatik testlerin hiç aklına gelmeyen soruları, mesela "sepete 0 adet eklenirse ne olur?" sorusunu genelde bu oturumlar bulur ve o bulgular sonra regression test'e dönüşür.
Fonksiyonel Olmayan Testler
Yukarıdakilerin hepsi "doğru sonucu veriyor mu?" sorusunu sordu. Üç test türü ise "nasıl veriyor?" sorusunu sorar. Performance ve load testing, POST /api/orders'ın saniyede 200 istekte yüzde 99'luk dilimde kaç milisaniyede döndüğünü ölçer; ilerideki Performance ve Load Testing serisinin konusu. Security testing, OWASP (Open Worldwide Application Security Project) Top 10 listesindeki açıkları arar: başkasının siparişini ID değiştirerek görmek, SQL (Structured Query Language) injection, eksik authorization. Web Güvenliği (OWASP Top 10) serisi bu açıkları tek tek anlatıyor. Accessibility testing, sepet ekranının ekran okuyucuyla ve yalnız klavyeyle kullanılabildiğini, WCAG (Web Content Accessibility Guidelines) kriterlerine uyduğunu kontrol eder. Bu üçünün ortak yanı, fonksiyonel testler yeşil yanarken sessizce kötüleşebilmeleri; ayrıca ölçülmezlerse kimse fark etmez.
Google'ın Size Sınıflandırması: Small, Medium, Large
Scope ekseni tek başına bir sorunu çözmüyor: "integration test" yavaş mı, flaky mi, CI'da (Continuous Integration) her commit'te koşabilir mi? Google bu soruyu ikinci bir eksenle cevaplıyor: testin boyutu, yani neye erişmesine izin verildiği. Software Engineering at Google kitabına göre small test tek process'te koşar; sleep, disk ya da ağ I/O'su (Input/Output) yapamaz. Medium test birden fazla process kullanabilir, ama ağ erişimi yalnız localhost'a; makineden dışarı çıkamaz. Large test bu sınırı kaldırır, birden fazla makineye yayılabilir.
Kitabın vurguladığı nokta, size ile scope'un bağımsız olması. Bookstore'daki altı testi iki eksene yerleştirince bu açıkça görünüyor:

Fiyat listesi testi scope olarak bir unit test: tek bir static method'u doğruluyor. Ama diskten prices.csv okuduğu için Google'ın tanımıyla small değil, medium. Sepet component testi gerçek tarayıcı yerine jsdom içinde koşuyor; test ve test edilen kod aynı process'te, ağ ve disk olmadan çalıştığı için small. OrdersApiTests ağsız, tek process'te çalışıyor; ama host ayağa kalkarken content root'tan appsettings.json okuyor, katı yorumla medium. Playwright testi ise E2E olmasına rağmen large değil: iki process de aynı makinede, localhost'ta. Staging'e karşı koşan smoke script'i başka bir makineye çıktığı için large. Contract satırındaki kesikli kutu bu yazıda koşmadığımız, kavramsal bir örnek: Pact'in consumer testi localhost'ta bir mock server açtığı için medium sayılır.
Bu ayrımı koda dökmek kolay. Testlere bir trait ekleyip pipeline'da yalnız small olanları her commit'te koşabilirsin. Microsoft Testing Platform (MTP) modunda bunu ilk denediğimizde şu çıktıyı aldık:
$ dotnet test --filter-trait "Size=Small"
Running tests from tests/Bookstore.Api.Tests/bin/Debug/net10.0/Bookstore.Api.Tests.dll (net10.0|arm64)
Running tests from tests/Bookstore.Domain.Tests/bin/Debug/net10.0/Bookstore.Domain.Tests.dll (net10.0|arm64)
tests/Bookstore.Api.Tests/bin/Debug/net10.0/Bookstore.Api.Tests.dll (net10.0|arm64) Zero tests ran (92ms)
Exit code: 8
tests/Bookstore.Domain.Tests/bin/Debug/net10.0/Bookstore.Domain.Tests.dll (net10.0|arm64) passed (131ms)
Test run summary: Failed!
tests/Bookstore.Domain.Tests/bin/Debug/net10.0/Bookstore.Domain.Tests.dll (net10.0|arm64) passed (131ms)
tests/Bookstore.Api.Tests/bin/Debug/net10.0/Bookstore.Api.Tests.dll (net10.0|arm64) Zero tests ran (92ms)
error: 1
total: 2
failed: 0
succeeded: 2
skipped: 0
duration: 273ms
Test run completed with non-success exit code: 8 (see: https://aka.ms/testingplatform/exitcodes)
İki test geçti ama koşu "Failed!" diye bitti. Sebep: API test projesinde small etiketli test yok ve MTP hiç test koşmayan bir test uygulamasını 8 koduyla bitiriyor. Filtreyle kategori ayırıyorsan bu kodu bilerek yok saymalısın: dotnet test --filter-trait "Size=Small" --ignore-exit-code 8. Yoksa pipeline'daki ilk "small" job'ı yeşil testlerle kırmızı yanar.
Bookstore'da Hepsi Yan Yana
Altı testi aynı makinede (Apple Silicon bir dizüstü, ısınmış build) çalıştırdık. Süreler xUnit'in verbose raporundan, Vitest'in ve Playwright'ın kendi özetinden:
| Test | Scope | Size | Araç | Ölçülen süre |
|---|---|---|---|---|
DiscountPolicyTests | Unit | Small | xUnit.net v3 | 0–6 ms / test |
PriceListParserTests | Unit | Medium | xUnit.net v3 | 6 ms |
cart.spec.ts | Component | Small | Vitest + jsdom | 509 ms (testin kendisi yüzde 4) |
OrdersApiTests | Integration | Medium | xUnit.net v3 + WebApplicationFactory | 524 ms |
checkout.spec.ts | E2E | Medium | Playwright | 406–508 ms test, 4,6–4,7 sn toplam |
smoke.sh | Sistem | Large (staging'de) | curl | 0,23 sn |
Rakamlardan iki ders çıkıyor. Birincisi, maliyet testin kendisinde değil ortamında. Vitest çıktısı süreyi "environment 71%, setup 19%, transform 6%, tests 4%" diye bölüyor; Playwright testi yarım saniye sürüyor ama iki sunucuyu ayağa kaldırmak dört saniye. Bu yüzden integration ve E2E testlerde ortamı testler arasında paylaşmak, test sayısından daha çok önemli. İkincisi, bu rakamlar küçük bir uygulamanın rakamları. Gerçek bir projede E2E suite'i Angular production build'i, gerçek veritabanı migration'ları ve onlarca sayfa demek; dakikalar sürer. Small testlerin milisaniyelerde kalması ise proje büyüdükçe değişmez.
Üretimde Dikkat
Ekip için yazılı bir test sözlüğü tut. "Integration test" Fowler'ın deyişiyle bulanıklaşmış bir terim: kimine göre tek bir dış bağımlılığa giden dar bir test, kimine göre bütün service'lerin ayakta olduğu geniş bir test. Sözlükte her tür için scope, size, araç ve ne zaman koştuğu tek satırda yazsın. Bu serinin üçüncü yazısında bunu tek sayfalık bir test stratejisi dokümanına dönüştüreceğiz.
Testleri size'a göre pipeline'a dağıt. Small testler her commit'te, medium testler pull request'te, large testler deploy sonrası. Pipeline'da test sırası, süre bütçesi ve flaky test yönetimi CI/CD Temelleri serisinin "Pipeline'da Test: Hızlı Geri Bildirim, Quality Gate'ler ve Flaky Test'ler" yazısının konusu.
Süreyi ölç, tahmin etme. Yukarıdaki tablo bir öğleden sonralık iş; ama ekipte "E2E çok yavaş" ya da "integration testler hızlı" gibi cümleler genelde ölçüm yerine izlenimle kuruluyor. Her test projesinin raporundaki toplam süreyi CI'da bir yerde biriktirmek, suite'in nereye doğru şiştiğini erkenden gösterir.
Flaky oranını izle. Google'ın verdiği örnek açıklayıcı: günde 10.000 test koşan bir ekipte binde 1'lik flaky oranı, her gün incelenmesi gereken 10 sahte kırmızı demek.
Sık Düşülen Tuzaklar
E2E testlerin paylaşılan veriye bağlı kalması. Playwright testimiz ilk koşuda geçti, ikincisinde hiçbir kod değişmeden kırıldı:
1) e2e/checkout.spec.ts:3:1 › a gold customer buys two books and sees the confirmation
Error: expect(locator).toHaveText(expected) failed
Locator: getByRole('status')
Expected: "Order #1 confirmed, paid 544"
Received: "Order #2 confirmed, paid 544"
Timeout: 5000ms
API her koşuda aynı e2e.db dosyasını kullanıyordu; ilk koşunun siparişi ve düşen stok orada kaldı. Baştaki üçüncü sorunun cevabı bu. Üçüncü koşuda stok 1'e indiği için ikinci "Add" tıklaması hiçbir şey yapmayacak, toplam da tutmayacaktı. Çözüm, her koşuya temiz bir veritabanı vermek:
{
command: 'dotnet run --project ../../src/Bookstore.Api --urls http://localhost:19901',
url: 'http://localhost:19901/health',
// her koşuda yeni veritabanı: testler önceki koşunun bıraktığı stoğa bağlı kalmaz
env: { ConnectionStrings__Bookstore: `Data Source=e2e-${Date.now()}.db`, WebOrigin: 'http://localhost:19902' },
},
Bu değişiklikten sonra iki ardışık koşu da geçti (406 ms ve 508 ms).
Terimi tanım sanmak. Bir ekip arkadaşın "integration testleri yazdım" dediğinde neyi kastettiğini sormadan pipeline'ın hangi aşamasına koyacağını bilemezsin. Kent C. Dodds'un (JavaScript eğitmeni, Testing Library'nin yazarı) "The Testing Trophy and Testing Classifications" yazısındaki tespit yerinde: testçiler arasındaki tartışmaların çoğu hedef farkından değil, tanım farkından çıkıyor.
Scope'u size sanmak. "Unit test hızlıdır" cümlesi ancak test small ise doğru. Diske yazan, Thread.Sleep kullanan ya da gerçek saate bakan bir unit test, dar scope'una rağmen yavaş ve flaky olabilir.
Exploratory testing'i "otomasyon yetişene kadar elle test" diye görmek. Otomatik testler senin aklına gelen soruları cevaplar. Exploratory oturumlar aklına gelmeyenleri bulur; ikisi birbirinin yerine geçmez.
Küçük Bir Deneme
★☆☆ Kendi projendeki test projelerini listele ve her biri için scope ve size'ı yaz. Kaç tanesinde bu ikisi farklı çıktı?
★★☆ PriceListParserTests'i small yapacak şekilde yeniden tasarla: dosya okumayı testin dışına itip Parse(IEnumerable<string>)'i doğrudan test et. Dosya okuma kodunu doğrulayan kaç test kaldı ve hangi size'da?
★★★ Projen için bir sayfalık test sözlüğü yaz: her test türü için scope, size, araç, ne zaman koştuğu ve sahibi. Ekipte iki kişiye ayrı ayrı okut ve "integration test" satırı üzerinde aynı fikirde olup olmadıklarına bak.
Türleri ve iki ekseni yerine oturttuk. Ama "hangisinden ne kadar?" sorusu hâlâ açıkta. Bir sonraki yazıda test pyramid, testing trophy ve honeycomb modellerini yan yana koyup hangi şeklin hangi sisteme uyduğuna bakacağız.