Mutation Testing — Testing

100% Coverage Tidak Sama dengan Test yang Baik Line coverage 100% cuma berarti setiap baris pernah dieksekusi test. Tidak berarti test itu benar. Contoh…

Mutation testing menguji test-mu sendiri. Alatnya sengaja bikin kesalahan kecil di kodemu, satu per satu, lalu menjalankan test. Kalau test-nya jadi gagal, bagus: kesalahan itu ketahuan. Kalau test-nya tetap hijau, test-mu gak memperhatikan bagian itu. Mirip petugas bandara yang diuji atasannya: koper berisi barang terlarang palsu sengaja dimasukkan ke mesin x-ray. Kalau petugasnya menemukan, dia lulus. Kalau koper itu lolos, ada yang perlu dibenahi.

🧪 Tebakan cepat: dua test hitungOngkir(150000, true) dan hitungOngkir(50000, false) memberi coverage 100% di keempat angka. Stryker membuat 10 mutant dari function itu. Berapa yang lolos dari kedua test: 0, 4, atau 10? Jawabannya ada di bagian "Coverage 100%, skornya cuma 60%".

Cara kerjanya

  1. Alat mutation testing mengubah satu bagian kecil kodemu, misalnya >= jadi >. Satu versi yang sudah diubah itu disebut mutant.
  2. Semua test dijalankan terhadap mutant itu.
  3. Ada test yang gagal: mutant-nya killed (terbunuh). Test-mu menangkap perubahannya.
  4. Semua test tetap lulus: mutant-nya survived (lolos). Perilakunya berubah dan gak ada test yang sadar. Itu tanda test-mu lemah di bagian itu.
  5. Mutant di baris yang gak pernah dijalankan test mana pun diberi status NoCoverage. Gak ada test yang bisa membunuhnya.

Mutation score = mutant yang killed dibagi semua mutant, dikali 100%. 6 killed dari 10 mutant berarti skornya 60%.

Mutant dari hitungOngkir

Function-nya sama dengan pelajaran coverage: ongkir Rp20.000, member bayar setengah, mulai Rp100.000 gratis.

// src/ongkir.js
export function hitungOngkir(total, member) {
  let ongkir = 20000;
  if (member) {
    ongkir = ongkir / 2;
  }
  if (total >= 100000) {
    ongkir = 0;
  }
  return ongkir;
}

Stryker, alat mutation testing untuk JavaScript dan TypeScript, membuat 10 mutant dari kode ini. Nama di kolom kedua adalah nama mutator-nya, jenis perubahan yang dia pakai:

# Mutator Baris Asli Mutant
1 BlockStatement 1 isi function {} (mengembalikan undefined)
2 ConditionalExpression 3 if (member) if (true)
3 ConditionalExpression 3 if (member) if (false)
4 BlockStatement 3 { ongkir = ongkir / 2; } {}
5 ArithmeticOperator 4 ongkir / 2 ongkir * 2
6 ConditionalExpression 6 if (total >= 100000) if (true)
7 ConditionalExpression 6 if (total >= 100000) if (false)
8 EqualityOperator 6 total >= 100000 total > 100000
9 EqualityOperator 6 total >= 100000 total < 100000
10 BlockStatement 6 { ongkir = 0; } {}

Jenis mutator lain yang sering muncul: LogicalOperator (a && b jadi a || b), BooleanLiteral (true jadi false), StringLiteral ("halo" jadi ""), dan UpdateOperator (i++ jadi i--).

Coverage 100%, skornya cuma 60%

Test A: hitungOngkir(150000, true) harus 0. Test B: hitungOngkir(50000, false) harus 20000.

# Mutant Hasil A Hasil B Status
1 isi function {} undefined killed
2 if (true) di baris 3 0 10000 killed
3 if (false) di baris 3 0 20000 survived
4 blok baris 3 jadi {} 0 20000 survived
5 ongkir * 2 0 20000 survived
6 if (true) di baris 6 0 0 killed
7 if (false) di baris 6 10000 killed
8 total > 100000 0 20000 survived
9 total < 100000 10000 killed
10 blok baris 6 jadi {} 10000 killed

Kolom yang kosong artinya mutant-nya sudah terbunuh oleh test sebelumnya. 4 mutant lolos dari 10, jadi mutation score-nya 60%, padahal coverage-nya 100%. Itu jawaban tebakannya.

Menjalankan Stryker

npm install -D @stryker-mutator/core @stryker-mutator/vitest-runner
npx stryker run
{
  "testRunner": "vitest",
  "mutate": ["src/**/*.js", "!src/**/*.test.js"],
  "reporters": ["clear-text", "progress", "html"],
  "thresholds": { "high": 80, "low": 60, "break": 50 }
}
[Survived] EqualityOperator
src/ongkir.js:6:7
-     if (total >= 100000) {
+     if (total > 100000) {
Tests ran:
    member, belanja 150 ribu: gratis
    bukan member, belanja 50 ribu: 20 ribu

Baris - itu kode asli, baris + itu mutant-nya, dan Tests ran adalah test yang dijalankan terhadapnya tanpa satu pun yang gagal. Cara membacanya: "di baris 6 kolom 7, >= bisa diganti > dan gak ada test yang protes". Tambahkan test yang membedakan keduanya.

Alat untuk bahasa lain: Infection untuk PHP (vendor/bin/infection --min-msi=80, MSI artinya mutation score indicator), PIT atau Pitest untuk Java dan Kotlin, dan mutmut untuk Python.

Latihan: runMutation

Di latihan, kamu bikin versi mini dari langkah 2 dan 3. Anggap tiap test case mengecek hasil yang persis sama dengan kode asli. Jadi mutant terbunuh kalau ada minimal satu input yang membuat hasil original dan mutant berbeda.

function runMutation(original, mutant, testCases) {
  const terbunuh = testCases.some((input) => original(input) !== mutant(input));
  return terbunuh ? "killed" : "survived";
}

function weakTest() {
  const original = (age) => age >= 18;
  const mutant = (age) => age > 18;
  return runMutation(original, mutant, [20]); // test case lemah
}

console.log(weakTest()); // survived
console.log(runMutation((age) => age >= 18, (age) => age > 18, [18, 20])); // killed

⚠️ Jebakan yang sering ketemu

TL;DR: Mutation testing mengubah kodemu sedikit demi sedikit (mutant) lalu menjalankan test. Test gagal berarti killed, test tetap lulus berarti survived: perubahan itu gak terdeteksi, dan test-mu lemah di sana. Mutation score = killed dibagi semua mutant. Coverage 100% bisa saja skornya 60%, karena baris yang jalan belum tentu hasilnya dicek. Alatnya Stryker untuk JavaScript dan TypeScript (npx stryker run), Infection untuk PHP, PIT untuk Java, mutmut untuk Python. Fokus di kode penting, terima bahwa equivalent mutant gak bisa dibunuh, dan di latihan: some dengan !==, satu beda berarti "killed".

Yang akan kamu pelajari