Pipeline'da Test: Hızlı Geri Bildirim, Quality Gate'ler ve Flaky Test'ler
Testleri pipeline'a ucuzdan pahalıya dizmek (format, unit, Testcontainers ile integration, Playwright ile e2e) ve uyarı, coverage ve bundle budget gibi quality gate'leri eklemek hızlı ve güvenilir geri bildirim verir; flaky test'i retry ile örtmek yerine kökünü bulmak gerekir. Assertion'sız testin coverage'ı nasıl şişirdiği, 30 koşuda 2 kez kırılan gerçek bir flaky test ve boş kalan Playwright shard'ı.
Sürüm notu: Bu yazıdaki örnekler .NET 10 (SDK 10.0.402), xUnit 2.9.3, coverlet 10.1.0, Testcontainers 4.16.0, PostgreSQL 18, Angular 22.2, Playwright 1.65 ve act 0.2.97 ile denenmiştir.
Önce Tahmin Et
Bookstore'un test projesine hiçbir Assert içermeyen tek bir test ekliyorsun: üç endpoint'e istek atıyor ve sonuca bakmıyor. Line coverage ve branch coverage ne olur? İkinci soru: Fiyat hesaplayan iki test var ve 30 koşudan 2'sinde kırılıyorlar. Pipeline'a "kırılırsa iki kez daha dene" ayarı eklemek problemi çözer mi? Cevapları yazının ortasındaki gerçek çıktılarda göreceksin.
Test Pyramid'ini Pipeline Sırasına Dökmek
Test pyramid'inin söylediği şey basit: altta çok sayıda hızlı ve ucuz test, üstte az sayıda yavaş ve pahalı test. Pipeline'da bu bir sıralamaya dönüşüyor; en ucuz kontrol en önde, çünkü hata ne kadar erken yakalanırsa geri bildirim o kadar hızlı. Fowler'ın CI (Continuous Integration) makalesindeki on dakikalık build hedefi de buradan geliyor: commit'ten sonra geliştiricinin "başka işe geçmeden" sonucu görebileceği süre.
Bookstore'da katmanlar ve benim koşularımda ölçtüğüm süreler:
| Katman | Bookstore'da | Ölçülen süre | Ne zaman koşar |
|---|---|---|---|
| Format ve derleme uyarıları | dotnet format, -warnaserror | 11 sn + 3 sn (act) | Her push ve PR (pull request) |
| Unit test | xUnit, 6 test | yaklaşık 1 sn (act) | Her push ve PR |
| Integration test | Testcontainers ile PostgreSQL | yaklaşık 3 sn (lokal, image hazırken) | Her push ve PR |
| E2E (end-to-end) test | Playwright, Chromium | 3,7 sn (lokal, 1 smoke test) | PR'da smoke, main'de tamamı |
Sayılar küçük çünkü Bookstore küçük. Oranlar ise gerçek projelerde de benzer kalır: e2e (end-to-end) testleri, sayıca az olmalarına rağmen pipeline süresinin en büyük kalemine dönüşür. Her katmana bir süre bütçesi koymak işe yarıyor: "unit testler 2 dakikayı, integration 5 dakikayı geçerse konuşuruz" gibi.
Integration Test: Gerçek PostgreSQL ile
Unit test, kodun mantığını izole eder; ama SQL'in (Structured Query Language) doğru yazılıp yazılmadığını, migration'ın tabloyu doğru kurup kurmadığını ancak gerçek bir veritabanı söyler. In-memory provider ya da SQLite ile PostgreSQL'i taklit etmek, PostgreSQL'e özgü davranışları kaçırır. Testcontainers, test başlarken gerçek bir PostgreSQL container'ı başlatır ve bittiğinde siler:
using Bookstore.Api.Data;
using Npgsql;
using Testcontainers.PostgreSql;
namespace Bookstore.Api.IntegrationTests;
public sealed class BookStockRepositoryTests : IAsyncLifetime
{
// yerel denemede başka container'larla çakışmasın diye sabit ad ve port; CI'da bu iki satırı sil
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:18-alpine")
.WithName("cicdwork-pg-it")
.WithPortBinding(18120, 5432)
.Build();
public async Task InitializeAsync()
{
await _postgres.StartAsync();
await using NpgsqlDataSource dataSource = NpgsqlDataSource.Create(_postgres.GetConnectionString());
await using NpgsqlCommand command = dataSource.CreateCommand("""
CREATE TABLE books (id int PRIMARY KEY, title text NOT NULL, stock int NOT NULL);
INSERT INTO books VALUES (1, 'Refactoring', 12), (2, 'Continuous Delivery', 4), (3, 'Accelerate', 0);
""");
await command.ExecuteNonQueryAsync();
}
public Task DisposeAsync() => _postgres.DisposeAsync().AsTask();
[Fact]
public async Task CountInStock_ignores_sold_out_books()
{
await using NpgsqlDataSource dataSource = NpgsqlDataSource.Create(_postgres.GetConnectionString());
BookStockRepository repository = new(dataSource);
long count = await repository.CountInStockAsync();
Assert.Equal(2, count);
}
}
Kodu yerelde koşturduğum haliyle veriyorum. WithName ve WithPortBinding satırları sadece benim makinemdeki diğer container'larla çakışmamak için var; pipeline'da sabit port, aynı runner'da paralel koşan iki job'ın birbirini kırması demek. O iki satır olmadan Testcontainers boş bir port seçer ve GetConnectionString() onu döner.
$ dotnet test tests/Bookstore.Api.IntegrationTests
Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1, Duration: 1 s - Bookstore.Api.IntegrationTests.dll (net10.0)
Container'ın ayağa kalkması dahil bir saniye. Image runner'da yoksa ilk koşuda indirme süresi eklenir; bu, integration job'ında image'ı cache'lemenin ya da küçük (alpine) image seçmenin neden önemli olduğunu gösteriyor. HTTP (Hypertext Transfer Protocol) katmanını WebApplicationFactory ile uçtan uca test etmeyi ASP.NET Core Web API (Application Programming Interface) serisinin "ASP.NET Core Web API'de Integration Test: WebApplicationFactory ile Uçtan Uca Test" yazısında anlattım.
Katmanları Pipeline'a Dizmek
Bookstore'un test workflow'u katmanları ayrı job'lara koyuyor:
name: test
on:
pull_request:
push:
branches: [main]
jobs:
unit:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- run: dotnet format --verify-no-changes
- run: dotnet build -c Release -warnaserror
- run: >-
dotnet test tests/Bookstore.Api.Tests -c Release --no-build
-p:CollectCoverage=true -p:Threshold=80 -p:ThresholdType=line
integration:
needs: unit
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- run: dotnet test tests/Bookstore.Api.IntegrationTests -c Release
e2e:
needs: unit
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
matrix:
shard: [1, 2]
defaults:
run:
working-directory: web/bookstore-web
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm ci
- run: npx playwright install --with-deps chromium
- if: github.event_name == 'pull_request'
run: npx playwright test --grep @smoke --shard=${{ matrix.shard }}/2
- if: github.event_name == 'push'
run: npx playwright test --shard=${{ matrix.shard }}/2
integration ve e2e ikisi de sadece unit'e bağlı, yani unit katmanı yeşilse ikisi paralel başlıyor. PR'da e2e'nin sadece @smoke etiketli testleri koşuyor; main'e push'ta tamamı. unit job'ını act ile koşturdum:
[test/unit] ✅ Success - Main dotnet format --verify-no-changes [11.370395917s]
[test/unit] | 0 Warning(s)
[test/unit] | 0 Error(s)
[test/unit] ✅ Success - Main dotnet build -c Release -warnaserror [3.084910625s]
[test/unit] | Passed! - Failed: 0, Passed: 6, Skipped: 0, Total: 6, Duration: 115 ms - Bookstore.Api.Tests.dll (net10.0)
[test/unit] | | Bookstore.Api | 81.25% | 50% | 62.5% |
[test/unit] ✅ Success - Main dotnet test tests/Bookstore.Api.Tests -c Release --no-build -p:CollectCoverage=true -p:Threshold=80 -p:ThresholdType=line [1.325313333s]
[test/unit] 🏁 Job succeeded
integration ve e2e job'larını act'te koşturmadım: ilki job container'ının içinden Docker'a erişmeyi, ikincisi tarayıcı bağımlılıklarının kurulmasını istiyor. İkisinin test komutlarını yukarıda ve aşağıda gördüğün gibi lokalde koşturdum; workflow'un tamamı actionlint'ten geçti. GitHub'ın Ubuntu runner'larında Docker hazır geldiği için Testcontainers orada ek ayar istemez.
Ucuz kontrolün neden önde olduğuna bak: dotnet format 11 saniye, testler 1,3 saniye. Burada format kontrolü testten pahalı görünüyor; ama formatting hatası olan bir commit'te build ve test hiç başlamıyor, ve gerçek projelerde test süresi dakikalarla ölçülürken format kontrolü saniyelerde kalıyor.
Quality Gate'ler
Quality gate, testlerin geçmesinin ötesinde bir kalite koşulunu pipeline'ı kıracak şekilde zorunlu kılmak. Bookstore'da dört tane var.
Uyarıları hataya çevirmek
Nullable reference type uyarısı veren bir satır ekledim ve önce normal, sonra -warnaserror ile build aldım. İkinci build sessizce başarılı oldu, çünkü proje değişmediği için derleyici hiç çalışmadı; incremental build uyarıyı yeniden üretmedi. --no-incremental ile:
$ dotnet build src/Bookstore.Api -c Release -warnaserror --no-incremental
src/Bookstore.Api/Catalog/TitleFormatter.cs(5,45): error CS8602: Dereference of a possibly null reference. [src/Bookstore.Api/Bookstore.Api.csproj]
1 Error(s)
Pipeline'da her job temiz bir checkout ile başladığı için bu problem orada yok; ama lokalde "bende uyarı yok" diyen geliştiricinin bir sebebi bu olabilir. Uyarıları kalıcı olarak hataya çevirmek için Directory.Build.props'a TreatWarningsAsErrors koymak, pipeline'a özel bir flag'den daha tutarlı.
Coverage eşiği, ve neden tek başına yanıltıcı
coverlet ile eşiği 85'e çektiğimde lokal koşu:
$ dotnet test tests/Bookstore.Api.Tests -p:CollectCoverage=true -p:Threshold=85 -p:ThresholdType=line
| Total | 80% | 50% | 62.5% |
.../coverlet.msbuild/10.1.0/buildMultiTargeting/coverlet.msbuild.targets(73,5): error : The minimum line coverage is below the specified 85 [tests/Bookstore.Api.Tests/Bookstore.Api.Tests.csproj]
Şimdi giriş sorusundaki testi ekleyelim:
[Fact]
public async Task Endpoints_do_not_throw()
{
HttpClient client = factory.CreateClient();
await client.GetAsync("/api/books");
await client.GetAsync("/api/books/1");
await client.GetAsync("/api/info");
}
Passed! - Failed: 0, Passed: 7, Skipped: 0, Total: 7, Duration: 149 ms - Bookstore.Api.Tests.dll (net10.0)
| Module | Line | Branch | Method |
| Bookstore.Api | 82.85% | 83.33% | 75% |
Line coverage 80'den 82,85'e, branch coverage 50'den 83,33'e çıktı. Test hiçbir şeyi doğrulamıyor; endpoint 500 dönse de geçer. Coverage "hangi satırların çalıştığını" ölçer, "hangilerinin doğrulandığını" değil. Eşik, test yazılmayan alanları bulmak için faydalı bir alarm; hedef haline geldiğinde ise bu tür testler üretir. Eşiği mevcut değerin biraz altında tutup düşüşü engellemek, yüksek bir sayıyı zorlamaktan daha sağlıklı.
Bundle budget
Angular tarafında aynı fikir bundle boyutu için. angular.json'daki budget'ı denemek için 150 kB'a indirdiğimde:
$ npx ng build
Application bundle generation failed. [0.757 seconds] - 2026-11-08T03:33:13.367Z
✘ [ERROR] bundle initial exceeded maximum budget. Budget 150.00 kB was not met by 41.16 kB with a total of 191.16 kB.
Budget, kimsenin fark etmeden 2 MB'lık bir kütüphane eklemesini yakalar. Detaylarını, SPA (Single Page Application) routing'iyle birlikte Angular serisinin "Angular Build ve Deploy: ng build, Environment Dosyaları, Budgets ve SPA Routing" yazısında anlattım.
Sonucu PR'da görünür kılmak
Kırmızı bir job'ın log'unda 300 satır arasında kırılan testi aramak zaman kaybı. Test sonuçlarını JUnit XML (Extensible Markup Language) ya da TRX (Visual Studio Test Results) formatında üretip PR'da özet olarak gösteren action'lar ve Jenkins'in junit step'i bunun için var. Playwright config'inde CI ortamında JUnit raporunu açtım: reporter: process.env['CI'] ? [['list'], ['junit', { outputFile: 'test-results/e2e.xml' }]] : 'list'.
Flaky Test'ler
Flaky test, kod değişmeden bazen geçen bazen kırılan test. Bookstore'da kampanya indirimini test eden iki test vardı:
public static class Campaign
{
// süreç içindeki bütün testlerin paylaştığı, global ve değiştirilebilir state
public static decimal DiscountRate { get; set; }
public static decimal Apply(decimal price) => Math.Round(price * (1 - DiscountRate), 2);
}
public class BlackFridayTests
{
[Fact]
public async Task Black_friday_price_is_discounted()
{
Campaign.DiscountRate = 0.20m;
await Task.Delay(10);
Assert.Equal(40.00m, Campaign.Apply(50.00m));
Campaign.DiscountRate = 0m;
}
}
public class RegularPriceTests
{
[Fact]
public void Regular_price_is_not_discounted() =>
Assert.Equal(50.00m, Campaign.Apply(50.00m));
}
Her test tek başına doğru. Ama xUnit farklı test class'larını varsayılan olarak paralel koşturur; RegularPriceTests tam BlackFridayTests'in indirim oranını ayarladığı anda koşarsa yanlış fiyatı görür. Flaky test'i tespit etmenin en basit yolu, aynı testi değişmeyen kodla defalarca koşturmak:
fails=0
for i in $(seq 1 30); do
dotnet test -c Release --no-build > run$i.log 2>&1 || fails=$((fails+1))
done
echo "failed runs: $fails/30"
failed runs: 2/30
Failed Bookstore.Pricing.Tests.RegularPriceTests.Regular_price_is_not_discounted [3 ms]
Assert.Equal() Failure: Values differ
Expected: 50.00
Actual: 40.00
Failed! - Failed: 1, Passed: 1, Skipped: 0, Total: 2, Duration: 15 ms - Bookstore.Pricing.Tests.dll (net10.0)
30 koşudan 2'si, yani yaklaşık yüzde 7. Giriş sorusuna dönelim: pipeline'a "iki kez daha dene" eklersen kırmızı build'ler büyük ölçüde kaybolur, ama hata kaybolmaz. Retry, test kodundaki bir yarış durumunu gizler; aynı yarış production kodunda da varsa (static state, paylaşılan cache) onu da gizler. Retry bir pansuman: geçici altyapı sorunları (registry'nin bir anlık 503 dönmesi) için makul, test mantığındaki sorun için değil.
Gerçek düzeltme, paylaşılan state'i kaldırmak:
public sealed class Campaign(decimal discountRate)
{
public decimal Apply(decimal price) => Math.Round(price * (1 - discountRate), 2);
}
Testler artık kendi Campaign instance'ını oluşturuyor. Aynı döngü bu sefer failed runs: 0/30 verdi.
Düzeltme hemen yapılamıyorsa karantina: testi [Trait("Category", "Quarantine")] ile işaretleyip ana pipeline'da --filter "Category!=Quarantine" ile dışarıda bırak, ayrı bir job'da koşturmaya devam et ve her karantinaya bir sahip ile bir son tarih yaz. Sahipsiz karantina, testin sessizce silinmesinin kibar adı.
Paralelleştirme ve Sharding
Test süresi büyüdüğünde testleri birden fazla makineye bölmek (sharding) duvar saatini kısaltır. Playwright'ın --shard seçeneği bunu yapıyor, ama bir tuzağı var. Bookstore'un iki e2e testi aynı dosyada; fullyParallel kapalıyken:
$ CI=1 npx playwright test --shard=1/2
Running 2 tests using 1 worker, shard 1 of 2
✓ 1 e2e/home.spec.ts:3:1 › home page shows the store name @smoke (215ms)
✓ 2 e2e/home.spec.ts:8:1 › home page reads the runtime config (107ms)
2 passed (2.6s)
$ CI=1 npx playwright test --shard=2/2
[WebServer] Prebundling has been configured but will not be used because caching has been disabled.
$ echo $?
0
Playwright bu ayarla testleri dosya bazında bölüyor; tek dosya olduğu için ikinci shard hiçbir test koşturmadı ve 0 ile çıktı. Pipeline'da bu, yeşil görünen ama hiçbir şey yapmayan bir job demek. Config'e fullyParallel: true eklediğimde testler test bazında bölündü:
Running 1 test using 1 worker, shard 1 of 2
✓ 1 e2e/home.spec.ts:3:1 › home page shows the store name @smoke (217ms)
1 passed (2.5s)
Running 1 test using 1 worker, shard 2 of 2
✓ 1 e2e/home.spec.ts:8:1 › home page reads the runtime config (132ms)
1 passed (2.3s)
Sharding'e geçtiğinde her shard'ın gerçekten test koşturduğunu, örneğin rapordaki test sayılarını toplayarak, doğrula.
Üretimde Dikkat
Test sonuçlarının geçmişini tut. Tek bir kırmızı koşu flaky mi gerçek mi, bunu ancak aynı testin son 50 koşudaki geçme oranı söyler. CI tool'larının çoğu bu geçmişi gösteren eklentilere ya da test analitiği özelliklerine sahip.
Zamana bağlı testlere dikkat et. DateTime.Now'a bakan bir test, saat 14:00'ten sonra ya da ay sonunda kırılabilir. .NET 8 ile gelen TimeProvider soyutlamasını kod tarafında kullanıp testte FakeTimeProvider vermek bu sınıf flaky test'leri baştan engelliyor.
Testcontainers varsayılan olarak temizlik için Ryuk adlı küçük bir yardımcı container başlatır; test süreci çökse bile container'ları o siler. Ben lokal denemede container'ları açıkça dispose ettiğim ve ek bir container istemediğim için TESTCONTAINERS_RYUK_DISABLED=true ile kapattım. CI'da açık bırak; yarıda kesilen bir job'ın arkasında sahipsiz veritabanı container'ları kalmasın.
Sık Düşülen Tuzaklar
Her commit'te e2e testlerinin tamamını koşturmak: pipeline 40 dakikaya çıkar, geliştiriciler beklemeyi bırakır. PR'da smoke, main'de ve gece tam set çoğu ekip için iyi bir denge.
PR'da integration testi atlamak: "çok yavaş, main'de koşsun" denildiğinde SQL ve migration hataları ancak merge'den sonra yakalanır ve main kırılır.
Coverage yüzdesini hedef yapmak: yukarıda gördüğün gibi, doğrulamayan testlerle sayı kolayca şişirilir.
Her şeye retry eklemek: flaky test'i görünmez yapar, düzeltilmesini sonsuza kadar erteler.
Sharding'i doğrulamamak: boş shard da yeşil biter.
Küçük Bir Deneme
★☆☆ Kendi test projende coverage'ı ölç, sonra assertion içermeyen bir test ekleyip tekrar ölç. Ne kadar arttı?
★★☆ Yukarıdaki döngü script'iyle kendi test suite'ini 30 kez koştur. Kırılan test oldu mu? Olduysa sebebi static state mi, zaman mı, sıralama mı?
★★★ Projendeki integration testlerinden birini Testcontainers'a taşı ve pipeline'a ayrı bir job olarak ekle. Unit job'ı ile integration job'ının sürelerini karşılaştır ve ikisine birer süre bütçesi yaz.
Bir sonraki yazıda artifact'ın gittiği yerlere bakacağız: dev, test, staging ve production environment'ları, aynı artifact'ın aralarında promotion'ı ve "staging'de çalışıyordu" cümlesinin neden yeterli olmadığı.