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)danhitungOngkir(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
- Alat mutation testing mengubah satu bagian kecil kodemu, misalnya
>=jadi>. Satu versi yang sudah diubah itu disebut mutant. - Semua test dijalankan terhadap mutant itu.
- Ada test yang gagal: mutant-nya killed (terbunuh). Test-mu menangkap perubahannya.
- Semua test tetap lulus: mutant-nya survived (lolos). Perilakunya berubah dan gak ada test yang sadar. Itu tanda test-mu lemah di bagian itu.
- 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.
- Mutant 3, 4, dan 5 lolos karena Test A memang member, tapi belanjanya 150.000. Apa pun yang terjadi di baris 4, baris 7 menimpanya jadi 0. Baris 4 jalan, tapi hasilnya gak pernah sampai ke
expect. - Mutant 8 lolos karena gak ada test yang belanja tepat 100.000. Ini persis bug
>yang lolos di pelajaran coverage. - Tambah
hitungOngkir(50000, true)harus 10000: mutant 3, 4, dan 5 terbunuh, skornya 90%. TambahhitungOngkir(100000, false)harus 0: mutant 8 juga terbunuh, skornya 100%.
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 }
}
- File konfigurasinya
stryker.config.json.mutateadalah file yang dimutasi; tanda!di depan artinya dikecualikan, jadi file test-nya sendiri gak ikut dimutasi. thresholds.break: 50: kalau skornya di bawah 50, Stryker keluar dengan exit code 1, jadi CI gagal.- Reporter
clear-textmencetak tiap mutant yang lolos di terminal, seperti ini untuk mutant 8:
[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
testCases.some(...)bernilaitruekalau ada satu saja input yang memenuhi syarat di dalamnya.original(input) !== mutant(input)membandingkan hasil keduanya untuk input yang sama.- Dengan
[20]:20 >= 18dan20 > 18sama-samatrue, jadi gak ada yang beda:"survived". - Dengan
[18, 20]:18 >= 18itutruetapi18 > 18itufalse. Satu beda sudah cukup:"killed". - Dua function yang selalu sama, misalnya
(n) => n * 2dua kali, gak bisa dibedakan input apa pun:"survived".
⚠️ Jebakan yang sering ketemu
- Mengira coverage 100% berarti test-nya kuat. Coverage mencatat baris yang jalan. Mutation score mengukur apakah perubahan di baris itu ketahuan.
- Mengejar skor 100%. Ada equivalent mutant: mutasi yang perilakunya tetap sama. Contohnya
return a > b ? a : bjadireturn a >= b ? a : b: kalauasama denganb, dua-duanya mengembalikan angka yang sama. Gak ada test yang bisa membunuhnya. Target yang umum 75 sampai 85%. - Menjalankan di semua file tiap commit. Tiap mutant menjalankan test lagi, jadi mutation testing bisa puluhan kali lebih lama dari test biasa. Mulai dari kode penting seperti harga, pembayaran, dan validasi. Jalankan terjadwal, atau pakai
npx stryker run --incrementalsupaya cuma kode yang berubah yang dimutasi ulang. - Di latihan, mewajibkan semua input berbeda. Satu input yang beda sudah membunuh mutant. Pakai
some, bukanevery, dan bandingkan dengan!==.
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".