Code Coverage Ne Söyler, Ne Söylemez: Line, Branch Coverage ve Mutation Testing
Code coverage yalnız hangi kodun çalıştığını söyler, testlerin bir şey doğrulayıp doğrulamadığını söylemez: hiç assertion içermeyen bir test yüzde 87,5 line ve yüzde 100 branch coverage verdi, Stryker.NET ile mutation score'u yüzde 6,67 çıktı. MTP coverage extension'ı, ReportGenerator ve Angular'da coverage, sınır değerlerini bulan survived mutant'lar, --since ile yalnız değişen koda mutation testing ve coverage eşiği tuzakları.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.402), xUnit.net v3 4.0.2, Microsoft.Testing.Extensions.CodeCoverage 18.12.0, ReportGenerator 5.5.12, Stryker.NET 5.0.0, Angular 22.2 ve Vitest 5.1 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un fiyat hesabı üç kurala dayanıyor: 500 TL ve üstüne yüzde 10 indirim, Gold müşteriye ek yüzde 5, 300 TL ve üstüne ücretsiz kargo. Bu kurallar için tek bir test yazıyoruz; üç farklı sepet için fiyat teklifi oluşturuyor ama sonuca hiç bakmıyor, tek bir Assert yok. Line coverage kaç çıkar? Branch coverage? Ve kodu bilerek bozan bir araç, 15 bozulmuş versiyondan kaçında bu testin kırmızı yandığını söyler?
Line, Branch ve Method Coverage
Code coverage, testler koşarken kodun hangi kısımlarının çalıştığını ölçer. Üç yaygın birimi var. Line coverage, çalışan satırların oranı. Branch coverage, her koşullu dallanmanın (if, ?:, &&, switch) her iki yönünün de en az bir kez çalışıp çalışmadığı. Method coverage, en az bir kez çağrılan method'ların oranı. Farkı tek satırlık bir kural en iyi gösteriyor:
public static class ShippingFee
{
public static decimal For(decimal subtotal, bool express) =>
express ? 60m : subtotal >= 300m ? 0m : 30m;
}
[Fact]
public void Standard_shipping_is_free_over_300() =>
Assert.Equal(0m, ShippingFee.For(400m, express: false));
Yalnız bu testi koşup coverage aldığımızda, raporun ilgili satırları:
<line number="23" hits="1" branch="True" condition-coverage="50% (2/4)">
|Bookstore.Pricing.ShippingFee|100%|50%|
Satır çalıştı, yani line coverage yüzde 100. Ama dört yönden ikisi denendi: express kargo ve 300 TL altı sepet hiç çalışmadı. Line coverage'a bakan biri bu method'u "test edilmiş" sanar; branch coverage yarısının hiç koşmadığını söylüyor. Bu yüzden ikisinden birini seçeceksen branch coverage daha dürüst metrik. Microsoft Learn'deki coverage sayfası da aynı örneği veriyor: iki dallı bir kodda yalnız birini doğrulayan test yüzde 50 branch coverage raporlar.
.NET'te Ölçmek: MTP Extension ve ReportGenerator
Microsoft Testing Platform (MTP) üzerinde koşan bir test projesinde coverage, bir extension paketiyle gelir:
<ItemGroup>
<PackageReference Include="xunit.v3" Version="4.0.2" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.12.0" />
</ItemGroup>
--coverage bir MTP seçeneği olduğu için dotnet test'in MTP modunda koşması gerekiyor; .NET 10 SDK'da bu, solution kökündeki global.json'a "test": { "runner": "Microsoft.Testing.Platform" } eklemek demek. Çıktı formatı olarak Cobertura XML (Extensible Markup Language) istiyoruz, çünkü rapor araçlarının ortak dili bu:
$ dotnet test --coverage --coverage-output-format cobertura --coverage-output coverage.cobertura.xml
Running tests from tests/Bookstore.Pricing.Tests/bin/Debug/net10.0/Bookstore.Pricing.Tests.dll (net10.0|arm64)
tests/Bookstore.Pricing.Tests/bin/Debug/net10.0/Bookstore.Pricing.Tests.dll (net10.0|arm64) passed (184ms)
In process file artifacts produced:
- TestResults/coverage.cobertura.xml
XML'i okunur hale getirmek için ReportGenerator'ı global tool olarak değil, repo'daki tool manifest'ine local tool olarak kuruyoruz; böylece CI'da (Continuous Integration) ve her geliştiricide aynı sürüm çalışır:
dotnet new tool-manifest
dotnet tool install dotnet-reportgenerator-globaltool --version 5.5.12
dotnet reportgenerator -reports:TestResults/coverage.cobertura.xml -targetdir:coverage-report -reporttypes:"TextSummary;Html"
Açık kaynak alternatif olarak Coverlet'in MTP sürümü coverlet.MTP da aynı Cobertura çıktısını üretir; hangisini seçtiğin rapor tarafında fark etmez. Kurulumun ayrıntısı ilerideki Unit Test serisinde, pipeline'a coverage raporu eklemek GitHub Actions serisinin "GitHub Actions ile .NET CI: setup-dotnet, global.json, NuGet Cache, Test Raporu ve Coverage" yazısında.
Angular'da Coverage
Angular'ın Vitest tabanlı test builder'ında coverage tek bir bayrak. @vitest/coverage-v8 paketini dev dependency olarak ekleyip çalıştırdığımızda:
$ npx ng test --watch=false --coverage
% Coverage report from v8
---------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
---------------|---------|----------|---------|---------|-------------------
All files | 88.37 | 95.65 | 77.77 | 89.47 |
cart-store.ts | 93.75 | 92.3 | 85.71 | 90 | 33
cart.ts | 85.18 | 100 | 50 | 88.88 | 13
---------------|---------|----------|---------|---------|-------------------
Yüzde 89 güzel görünüyor, ama listede iki dosya var. app.ts hiç yok, çünkü hiçbir test onu yüklemiyor ve rapor yalnız testlerin yüklediği dosyaları sayıyor. Bütün uygulama koduna bakmasını söylediğimizde tablo değişiyor:
$ npx ng test --watch=false --coverage --coverage-include 'src/app/**/*.ts'
-------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------------|---------|----------|---------|---------|-------------------
All files | 63.33 | 95.65 | 50 | 50 |
app.config.ts | 0 | 100 | 100 | 0 | 6
app.routes.ts | 0 | 100 | 100 | 0 | 3
app.ts | 0 | 100 | 0 | 0 | 7-33
cart-store.ts | 93.75 | 92.3 | 85.71 | 90 | 33
cart.ts | 85.18 | 100 | 50 | 88.88 | 13
runtime-config.ts | 0 | 100 | 100 | 0 | 8
-------------------|---------|----------|---------|---------|-------------------
Line coverage yüzde 89,47'den yüzde 50'ye indi; kodda hiçbir şey değişmedi. Coverage'ı bir sayı olarak raporlayacaksan, önce paydanın ne olduğunu sabitle.
Goodhart Yasası: Assertion'sız Yüzde 87,5
Ekonomist Charles Goodhart'ın 1975'te dile getirdiği, antropolog Marilyn Strathern'in sonradan bu cümleye döktüğü gözlem: "When a measure becomes a target, it ceases to be a good measure." Ölçüt hedef olunca iyi bir ölçüt olmaktan çıkar. Önce Tahmin Et'teki teste bakalım:
[Fact]
public void Creates_quotes()
{
PriceQuote.Create(600m, CustomerTier.Gold, express: false);
PriceQuote.Create(100m, CustomerTier.Standard, express: true);
PriceQuote.Create(100m, CustomerTier.Standard, express: false);
}
Line coverage: 87.5%
Covered lines: 14
Uncovered lines: 2
Coverable lines: 16
Total lines: 35
Branch coverage: 100% (8 of 8)
Covered branches: 8
Total branches: 8
Method coverage: 66.6% (4 of 6)
Full method coverage: 66.6% (4 of 6)
Covered methods: 4
Fully covered methods: 4
Total methods: 6
Bookstore.Pricing 87.5%
Bookstore.Pricing.DiscountPolicy 88.8%
Bookstore.Pricing.PriceQuote 83.3%
Bookstore.Pricing.ShippingFee 100%
Baştaki ilk iki sorunun cevabı: yüzde 87,5 line, yüzde 100 branch coverage. Bu test indirimi yanlış hesaplayan, kargoyu ters veren, toplamı eksi çıkaran bir kodla da yeşil yanar. Fowler'ın (Martin Fowler, Thoughtworks'ün baş bilim insanı) 2012 tarihli "TestCoverage" yazısındaki tespit tam olarak bu: coverage test edilmemiş kodu bulmak için iyi bir araç, testlerin ne kadar iyi olduğunu ölçmek için kötü bir sayı. Fowler, düşünerek test yazan bir ekipte coverage'ın seksenlerin üstü ya da doksanlar civarında çıkmasını beklediğini, yüzde 100'ü ise metriği tatmin etmek için yazılmış testlerin işareti olarak şüpheyle karşılayacağını söylüyor. CI/CD (Continuous Integration / Continuous Delivery) Temelleri serisinin "Pipeline'da Test" yazısında da assertion'sız bir testin coverage'ı nasıl şişirdiğini pipeline tarafından görmüştük.
Mutation Testing: Testlerin Testi
Coverage "bu satır çalıştı mı?" sorusunu soruyor. Mutation testing ise "bu satır yanlış olsaydı bir test fark eder miydi?" sorusunu. Araç koda küçük hatalar sokar: >='yi > yapar, +='yi -= yapar, bir koşulu sabit true'ya çevirir. Her bozulmuş versiyona mutant denir. Testler mutant'la koşulur; en az bir test kırılırsa mutant killed, hepsi geçerse survived sayılır. Mutation score, öldürülen mutant'ların test edilebilir mutant'lara oranı.
.NET'te bunun aracı Stryker.NET. Onu da local tool olarak kurup test projesinin klasöründen çalıştırıyoruz. Test projemiz MTP üzerinde koştuğu için runner'ı açıkça seçiyoruz; varsayılan hâlâ vstest:
dotnet tool install dotnet-stryker --version 5.0.0
cd tests/Bookstore.Pricing.Tests
dotnet stryker --test-runner mtp -r cleartexttree -r progress -r html
Assertion'sız test için sonuç:
[10:16:41 INF] 18 mutants created
[10:16:42 INF] 2 mutants got status NoCoverage. Reason: Not covered by any test.
[10:16:42 INF] 3 mutants got status Ignored. Reason: Removed by block already covered filter
[10:16:42 INF] 13 total mutants will be tested
Killed: 1
Survived: 12
[10:16:43 INF] The final mutation score is 6.67 %
Üçüncü sorunun cevabı: 15 mutant'tan (12 survived, 2 hiç kapsanmayan, 1 killed) yalnız birinde test kırıldı, score yüzde 6,67. Kırılan tek mutant da ilginç:
├── [Killed] Arithmetic mutation on line 32
│ ├── [-] subtotal * DiscountPolicy.RateFor(subtotal, tier)
│ └── [+] subtotal / DiscountPolicy.RateFor(subtotal, tier)
Standart müşterinin indirim oranı 0, sıfıra bölme exception fırlattı ve test kırıldı. Assertion'sız bir testin yakaladığı tek hata türü bu: exception. Yüzde 87,5 line coverage'ın arkasında, gerçekte yalnız "bu kod patlamıyor" bilgisi vardı.
Sınırları Bulan Metrik
Testi düzgün yazalım: üç senaryo, her birinde indirim ve toplam doğrulanıyor.
[Fact]
public void Gold_customer_over_threshold_gets_fifteen_percent_and_free_shipping()
{
PriceQuote quote = PriceQuote.Create(600m, CustomerTier.Gold, express: false);
Assert.Equal(90m, quote.Discount);
Assert.Equal(510m, quote.Total);
}
Diğer ikisi 100 TL'lik standart sepeti kargolu (toplam 130) ve express (toplam 160) doğruluyor. Coverage yüzde 93,7 line, yüzde 100 branch. Stryker ise iki mutant'ın hâlâ hayatta olduğunu söylüyor:
Killed: 13
Survived: 2
[10:17:34 INF] The final mutation score is 86.67 %
├── [Survived] Equality mutation on line 11
│ ├── [-] subtotal >= ThresholdAmount
│ └── [+] subtotal > ThresholdAmount
├── [Survived] Equality mutation on line 23
│ ├── [-] subtotal >= 300m
│ └── [+] subtotal > 300m
Coverage'ın asla gösteremeyeceği bir şey bu: hiçbir test tam 500 TL'lik ya da tam 300 TL'lik bir sepete bakmıyor. Birisi yanlışlıkla >='yi > yapsa, 500 TL'lik sepeti olan müşteri indirimini kaybeder ve bütün testler yeşil kalır. Sınırları ekleyelim:
[Theory]
[InlineData(499.99, 0)]
[InlineData(500, 0.10)]
public void Discount_starts_exactly_at_threshold(decimal subtotal, decimal expected) =>
Assert.Equal(expected, DiscountPolicy.RateFor(subtotal, CustomerTier.Standard));
Kargo için de 299,99 ve 300 değerleriyle aynı Theory. Sonuç:
[10:17:47 INF] 18 mutants created
[10:17:47 INF] 3 mutants got status Ignored. Reason: Removed by block already covered filter
[10:17:47 INF] 15 total mutants will be tested
Killed: 15
Survived: 0
[10:17:49 INF] The final mutation score is 100.00 %
Line coverage bu adımda hiç değişmedi; yüzde 93,7'de kaldı. Testlerin kalitesi arttı, coverage bunu göremedi.
Üretimde Dikkat
Coverage'ı test edilmemiş yeri bulmak için kullan. Fowler'ın önerdiği okuma biçimi: raporu açıp kapsanmayan satırlara bak ve "bu satırın test edilmemiş olması beni endişelendiriyor mu?" diye sor. Ödeme callback'indeki imza doğrulaması kapsanmıyorsa endişelenmelisin; bir DTO'nun (Data Transfer Object) ToString'i kapsanmıyorsa endişelenmen gerekmez.
Hedef koyacaksan yeni koda koy. Bütün kod tabanı için yüzde 80 eşiği, eski ve test edilmesi zor kodu olan bir ekibi anlamsız testler yazmaya iter. Diff coverage (patch coverage ya da "coverage on new code" diye de geçer) yalnız pull request'te değişen satırlara bakar: "bu PR'daki (pull request) yeni satırların en az yüzde 80'i test edilmiş olsun." Codecov ve SonarQube gibi araçlar bunu doğrudan raporluyor. Eşiğin pipeline'da quality gate olarak nasıl uygulanacağı CI/CD Temelleri serisinin aynı yazısında.
Mutation testing yavaştır, onu da yalnız değişen koda uygula. Her mutant için ilgili testler yeniden koşar; büyük bir projede tam koşu saatler sürebilir. Stryker'ın --since seçeneği git farkına bakar. Hediye paketi ücretini değiştiren bir branch'te:
$ dotnet stryker --test-runner mtp --since:gift-wrap
[10:18:36 INF] 1 files changed
[10:18:36 INF] Changed file src/Bookstore.Pricing/GiftWrap.cs
[10:18:36 INF] 18 mutants got status Ignored. Reason: Removed by since filter
[10:18:36 INF] 18 total mutants are skipped for the above mentioned reasons
[10:18:36 INF] 5 mutants will be tested because: Mutant changed compared to target commit
[10:18:36 INF] 5 total mutants will be tested
Killed: 3
Survived: 2
23 mutant'tan yalnız değişen dosyadaki 5'i test edildi ve iki survived mutant, yeni hediye paketi kuralının tavan ücretinin ve 3 kitaplık sınırının hiç test edilmediğini hemen gösterdi. Bir gözlem daha: aynı denemede test dosyası da değiştiğinde Stryker "One or more covering tests changed" gerekçesiyle eski dosyanın 15 mutant'ını da yeniden test etti. Ayrıca bu küçük projede her koşu 5 saniye civarında sürdü ve sürenin çoğu build'di; kazanç, test süresinin build süresini geçtiği büyük projelerde görünür. Stryker'ın MTP runner'ı 5.0.0'da hâlâ preview olarak işaretli, koşu başında bunu bir uyarıyla söylüyor; sonuçları vstest runner'ıyla karşılaştırmadan pipeline'ı kıran bir eşik (--break-at) koymamak iyi olur.
Sık Düşülen Tuzaklar
Coverage eşiği yüzünden test yazdırmak. Eşik hedef olunca ekip, yukarıdaki Creates_quotes gibi testler yazar. Coverage artar, güven artmaz. Eşik varsa, yanında assertion'sız testleri code review'da reddetme kuralı da olmalı.
Üretilmiş kodu ve DTO'ları paydaya katmak. Microsoft'un extension'ı auto-property'leri ve record'ların derleyicinin ürettiği member'larını zaten saymadı; ölçtüğümüzde altı property'li bir DTO yalnız Currency'nin başlangıç değeri kadar, tek satır ekledi. Ama Entity Framework Core (EF Core) migration'ları, OpenAPI tanımından üretilmiş client'lar ve source generator çıktıları yüzlerce satır demek. Bunları [ExcludeFromCodeCoverage] attribute'u ya da araç ayarlarıyla dışarıda tut.
Yüzde 100'e takılmak. DiscountPolicy sınıfı her testten sonra yüzde 88,8'de kaldı. Sebep public const decimal ThresholdAmount = 500m; satırı: decimal sabiti IL'de (Intermediate Language) static constructor ile oluşturuluyor, değer ise kullanıldığı yere gömüldüğü için o constructor hiç çalışmıyor. Raporda bu satır "uncovered" görünüyor. Böyle bir satırı kapsamak için test yazmak, yüzde 100 hedefinin ürettiği tam bir israf.
Paydayı kontrol etmemek. Angular örneğinde olduğu gibi, hiç yüklenmeyen dosyaları saymayan bir rapor yüzde 89 derken gerçek oran yüzde 50 olabilir.
Mutation score'u yeni bir yüzde 100 hedefi yapmak. Bazı mutant'lar davranışı değiştirmez (equivalent mutant), bazıları önemsiz kodda yaşar. Survived mutant listesi, coverage raporu gibi okunacak bir yapılacaklar listesi; tek bir sayıya indirildiğinde Goodhart yasası bu kez onu vurur.
Küçük Bir Deneme
★☆☆ Kendi projende coverage al ve kapsanmayan satırlardan beşini incele. Kaçı seni gerçekten endişelendiriyor?
★★☆ GiftWrap.FeeFor için survived kalan iki mutant'ı öldüren testleri yaz. Tam 3 ve 4 kitap için beklenen ücret ne olmalı?
★★★ Projendeki en riskli dosyaya (para, güvenlik, sık değişen) Stryker.NET uygula. Mutation score'u ve survived mutant'ların listesini, aynı dosyanın line coverage'ıyla karşılaştır. Hangisi sana daha çok şey söyledi?
Test Stratejisi serisinin sonuna geldik: türleri ve iki ekseni, sistemine uyan şekli, neyi hangi seviyede test edeceğini ve testlerin gerçekten bir şey doğrulayıp doğrulamadığını nasıl ölçeceğini konuştuk. Sıradaki adım bu kararları koda dökmek: ilerideki Unit Test serisi xUnit.net v3 ile test projesini kurmaktan, test double'lara ve mock'un zarar verdiği yerlere kadar unit test'in mekaniğini ele alıyor.