%90 Test Coverage, %0 Güvence

Merhaba Arkadaşlar, test dünyasında çoğu kişinin doğru sandığı bir şey var. Ben de öyle sanıyordum. Ta ki production’da patlayana kadar.

Test yazıyoruz.
Coverage’ye bakıyoruz.
Rapor yeşil, CI geçiyor, herkes mutlu, özgüven tavan.

Ama gerçek şu: Coverage çoğu zaman hiçbir şey ifade etmiyor.

Bunu production’da canın yanınca anlıyorsun.

Bir gün premium hesaplamasında yanlış rakam dönmeye başladı. Test var. Coverage var. Her şey yeşil.

Test ne yapıyor?

var result = premiumService.calculate(policyId);
assertThat(result).isNotNull();

Yani?

Hiçbir şey.

Yanlış hesapla, yine geç.
0 dön, yine geç.
Çöp dön, yine geç.

Ama coverage %90 👍

Mutation Testing Nedir?

Kısaca:
Kodunu bozuyor.
Testine bakıyor.

Eğer testin bunu fark etmiyorsa -> testin işe yaramıyor.

Pitest Ne Yapıyor?

// Orijinal
if (amount > 0) {
    premium.setValid(true);
}

Bunu alıyor, bozuyor:

if (amount >= 0) { ... }  // boundary değiştirdi
if (amount < 0) { ... }   // koşulu tersine çevirdi
// premium.setValid(true); // setter'ı sildi

Sonra testleri çalıştırıyor.

  • Test fail -> iyi (killed)
  • Test geçti -> geçmiş olsun (survived)

Kurulum

Spring Boot + Maven + JUnit 5:

<properties>
    <pitest.version>1.23.0</pitest.version>
    <pitest-junit5.version>1.2.3</pitest-junit5.version>
</properties>

<plugin>
    <groupId>org.pitest</groupId>
    <artifactId>pitest-maven</artifactId>
    <version>${pitest.version}</version>
    <dependencies>
        <dependency>
            <groupId>org.pitest</groupId>
            <artifactId>pitest-junit5-plugin</artifactId>
            <version>${pitest-junit5.version}</version>
        </dependency>
    </dependencies>
    <configuration>
        <targetClasses>
            <param>com.example.myapp.modules.*</param>
        </targetClasses>
        <excludedClasses>
            <param>*.dto.*</param>
            <param>*.entity.*</param>
            <param>*.config.*</param>
            <param>*.controller.*</param>
        </excludedClasses>
        <targetTests>
            <param>com.example.myapp.unit.**</param>
        </targetTests>
        <mutationThreshold>50</mutationThreshold>
        <outputFormats>
            <param>HTML</param>
        </outputFormats>
        <timestampedReports>false</timestampedReports>
    </configuration>
</plugin>
mvn test pitest:mutationCoverage
# rapor: target/pit-reports/index.html

targetClasses, hangi production kodu mutasyona uğrayacak.
excludedClasses, dto, entity gibi test edilmesi anlamsız şeyler.
mutationThreshold, bu oranın altında build fail eder.

Bizde Ne Oldu?

641 test vardı.
JaCoCo: %66.

Güzel görünüyor.

Pitest çalıştırdık:

1704 mutation
1061 killed (%62)
429 no coverage
Test strength: %83

Demek ki test yazdığımız yerlerin bir kısmı aslında hiçbir şeyi doğrulamıyormuş.

1704 mutant oluştu. 1061’ini testlerimiz yakaladı.
429’u zaten test yazmadığımız satırlarda, onları yakalamamız mümkün değildi.
Geriye kalan 214 mutant? Test var ama yakalayamamış. Testin zayıf olduğu yerler.

Yani: test yazdığımız yerlerin %83’ü güçlü. %17’si işe yaramıyor.

MetrikBizdeNe Söylüyor
Mutation Coverage%62Toplam mutant kill oranı
Test Strength%83Test olan yerlerde kill oranı (asıl önemli olan bu)
No Coverage429Hiç test yok, coverage sorunu


Gerçek Problemler

1. Neyi kaydettiğini doğrulamıyorsun

// Zayıf
verify(repository).save(any());

Ne kaydedildi? Bilmiyorsun.

// Güçlü
var captor = ArgumentCaptor.forClass(PolicyPremium.class);
verify(repository).save(captor.capture());

assertThat(captor.getValue().getApplicationNo()).isEqualTo("APP-001");
assertThat(captor.getValue().getPolicy()).isEqualTo(policy);

Artık setter silinirse test fail eder.

2. Boundary test yok

if (nationalId.length() < 5) {
    return "***";
}

Peki length == 5? Test yok. Pitest < yerine <= koyuyor, test hala geçiyor.

@Test
void shouldHandleExactly5Chars() {
    assertThat(maskTckn("12345")).isEqualTo("123**45");
}

3. “Crash olmadı” testi

assertThat(result).isNotNull();

Bu test sadece “null dönmedi” diyor. Yanlış rakam dönse de geçer. Neyi test ettiği belirsiz.

// Bunun yerine
assertThat(result.getAmount()).isEqualByComparingTo(BigDecimal.valueOf(6000);
assertThat(result.getCurrency()).isEqualTo("TRY");

JaCoCo vs Pitest

JaCoCoPitest
Soru“Kod çalıştırıldı mı?”“Doğru çalıştığı kontrol ediliyor mu?”
ÖlçtüğüSatır/branch coverageTest assertion kalitesi
Ne zamanHer buildTest yazdıktan sonra, kritik modüllerde

Sonuç

Coverage seni kandırır.
Mutation testing yakalar.

Görüşmek üzere

Kaynaklar