Tes Anda lolos. Laporan coverage Anda menyatakan 87%. Tapi skor mutasi Anda 40%, dan separuh mutan Anda masih hidup.

Angka 40% itu bukan berarti kode Anda rusak. Itu berarti tes Anda yang rusak. Coverage mengukur baris mana yang dieksekusi selama test run. Mutation testing mengukur apakah tes Anda akan menyadari jika baris-baris tersebut mulai melakukan hal yang salah. Skor mutasi 40% berarti 60% dari bug yang bisa saja diperkenalkan ke kode Anda akan lewat begitu saja melewati CI.

Apa Sebenarnya Mutan yang Bertahan Itu

Mutan yang bertahan adalah bug kecil buatan yang tidak berhasil ditangkap oleh tes Anda.

Tool mutation testing bekerja dengan mengambil source code Anda dan menerapkan serangkaian transformasi yang telah ditentukan, satu per satu. Mereka bisa membalik > menjadi >=, mengubah + menjadi -, atau mengganti kondisi boolean dengan true. Setiap versi kode yang ditransformasi adalah sebuah mutan. Tool tersebut menjalankan test suite Anda terhadap setiap mutan. Jika ada tes yang gagal, mutan tersebut “terbunuh” (killed). Jika semua tes lolos, mutan tersebut “bertahan” (survives).

Mutan yang bertahan berarti salah satu dari dua hal. Entah tes Anda sebenarnya tidak memverifikasi perilaku yang dirusak oleh mutan tersebut, atau mutan tersebut “equivalent” (transformasi menghasilkan kode yang semantiknya identik, yang merupakan masalah sulit yang dikenal dalam mutation testing).

Sebagian besar mutan yang bertahan tidak equivalent. Sebagian besar adalah bug berjalan yang masih hidup.

Contoh Konkret: Password Validator

Berikut adalah fungsi yang memeriksa apakah sebuah password memenuhi persyaratan kebijakan:

// password.js
function isValidPassword(password) {
  if (password.length < 8) {
    return false;
  }
  if (!/[A-Z]/.test(password)) {
    return false;
  }
  if (!/[0-9]/.test(password)) {
    return false;
  }
  return true;
}

module.exports = { isValidPassword };

Dan berikut adalah test suite yang memberikan Anda 100% line coverage:

// password.test.js
const { isValidPassword } = require('./password');

test('accepts a valid password', () => {
  expect(isValidPassword('Hello1')).toBe(true);
});

test('rejects a short password', () => {
  expect(isValidPassword('Hi1')).toBe(false);
});

test('rejects a password without uppercase', () => {
  expect(isValidPassword('hello1')).toBe(false);
});

test('rejects a password without a digit', () => {
  expect(isValidPassword('Hellooo')).toBe(false);
});

Tunggu. isValidPassword('Hello1') mengembalikan true, tapi 'Hello1' hanya enam karakter. Pemeriksaan pertama seharusnya menolaknya. Tesnya salah, tapi lolos karena tes itu sendiri menegaskan perilaku yang salah.

Tool mutation testing seperti Stryker akan menangkap ini. Salah satu mutasinya akan membalik < menjadi <= di pemeriksaan panjang. Mutan itu akan bertahan karena tes yang ada sebenarnya tidak memverifikasi batasan di 8 karakter. Mutasi lain mungkin menghapus seluruh blok if pertama. Mutan itu juga akan bertahan, karena tes tidak menyertakan password delapan karakter tanpa huruf besar atau digit. Batas atas panjang tidak pernah diuji dalam kombinasi dengan aturan lainnya.

Berikut adalah test suite yang sebenarnya membunuh mutan-mutan tersebut:

// password.test.js
const { isValidPassword } = require('./password');

test('rejects password shorter than 8 chars', () => {
  expect(isValidPassword('Hello1')).toBe(false);
});

test('accepts password exactly 8 chars with uppercase and digit', () => {
  expect(isValidPassword('Hello1!@')).toBe(true);
});

test('rejects password without uppercase', () => {
  expect(isValidPassword('hello1!@')).toBe(false);
});

test('rejects password without digit', () => {
  expect(isValidPassword('Helloooo')).toBe(false);
});

test('rejects password missing both uppercase and digit', () => {
  expect(isValidPassword('helloooo')).toBe(false);
});

Sekarang batasan di 8 karakter diuji secara eksplisit. Mutan <= gagal karena 'Hello1!@' (8 karakter) harus diterima. Mutan penghapusan gagal karena 'helloooo' akan lolos tanpa pemeriksaan panjang.

Bagaimana Mutation Testing Bekerja di Balik Layar

Mutation testing secara komputasi mahal karena menjalankan test suite lengkap Anda sekali per mutan.

Jika codebase Anda memiliki 10.000 baris dan tool mutasi Anda menghasilkan 3.000 mutan, itu berarti 3.000 kali test suite dijalankan. Implementasi akademik awal pada dasarnya tidak dapat digunakan pada codebase nyata karena alasan ini. Tool modern sudah menjadi lebih cerdas.

Stryker, framework mutation testing yang paling banyak digunakan untuk JavaScript dan TypeScript, menggunakan beberapa optimasi:

  1. Mutant scoping: Stryker hanya menjalankan subset tes yang mungkin saja mencapai baris yang dimutasi, berdasarkan data coverage dari dry run awal.

  2. Parallel execution: Mutan dievaluasi di seluruh worker process.

  3. Incremental mode: Stryker menyimpan cache hasil dan hanya mengevaluasi ulang mutan untuk kode yang berubah sejak run terakhir.

  4. Checkers: Untuk bahasa yang dikompilasi, Stryker dapat memverifikasi mutan di level AST tanpa mengkompilasi ulang seluruh proyek.

Bahkan dengan optimasi ini, test run mutation testing penuh pada codebase besar masih bisa memakan waktu 10-30 menit. Itulah sebabnya sebagian besar tim menjalankan mutation testing di CI pada pull request atau nightly build, bukan pada setiap save.

Trade-Off yang Jarang Dibahas

Mutation testing tidak gratis, dan tidak selalu merupakan tool yang tepat.

Equivalent mutant problem adalah keterbatasan teoretis terbesar. Beberapa mutasi tidak mengubah perilaku yang observable. Pertimbangkan:

const timeout = 1000 * 60;

Mutasi yang mengubah ini menjadi 1000 * 61 secara semantik berbeda. Tapi mutasi yang mengubahnya menjadi 60 * 1000 adalah equivalent. Tidak ada tes yang bisa membunuhnya karena nilainya identik. Membedakan mutan equivalent dari survivor yang sebenarnya adalah undecidable dalam kasus umum. Tool modern menggunakan heuristik untuk melewati kasus-kasus yang jelas, tapi Anda masih akan melihat beberapa di antaranya.

Performa itu nyata. Pada proyek TypeScript berukuran menengah, Stryker mungkin menghasilkan 2.000 mutan dan membutuhkan waktu 15 menit untuk mengevaluasinya. Itu 15 menit waktu CI pada setiap run jika Anda mengaktifkannya untuk pull request. Tim biasanya memulai dengan threshold (misalnya, gagalkan build jika skor mutasi turun di bawah 60%) dan menjalankan analisis penuh setiap malam.

False confidence bekerja dua arah. Skor mutasi 100% tidak berarti kode Anda bebas bug. Itu berarti tidak ada bug yang cocok dengan mutation operator tool tersebut yang akan lolos. Mutation testing tidak bisa menemukan bug yang tidak tahu cara dibuatnya. Ia tidak akan menangkap kesalahan logika dalam requirements Anda, race condition yang tidak bisa disimulasikan, atau kegagalan integrasi lintas service boundary.

Cara Memulai Mutation Testing

Jika Anda menulis JavaScript atau TypeScript, Stryker adalah tempat yang tepat untuk memulai.

Instal:

npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner

Buat stryker.config.mjs:

// @ts-check
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
const config = {
  packageManager: 'npm',
  reporters: ['html', 'clear-text', 'progress'],
  testRunner: 'jest',
  coverageAnalysis: 'perTest',
  mutate: ['src/**/*.js'],
  threshold: {
    break: 60,
  },
};

export default config;

Jalankan:

npx stryker run

Mulailah dengan melihat HTML report, bukan skornya. Report menunjukkan setiap mutan yang bertahan secara inline dengan source code Anda. Baca sepuluh survivor pertama. Untuk masing-masing, tanyakan: apakah bug nyata di lokasi ini akan menyebabkan masalah produksi? Jika ya, tulis tes yang akan menangkapnya. Jika tidak, pertimbangkan apakah kode tersebut over-engineered.

Jangan mengejar 100%. Pada codebase yang matang, 70-80% adalah skor yang kuat. Di bawah 50%, Anda mungkin memiliki tes yang mengeksekusi kode tanpa menegaskan apa pun yang berarti. Di atas 90%, Anda kemungkinan besar menemui diminishing returns dan pajak equivalent-mutant yang semakin besar.

Apa yang Harus Dilakukan dengan Skor 40% Anda

Skor mutasi 40% adalah sebuah anugerah. Ia memberi tahu Anda persis di mana tes Anda hanya bersifat dekoratif.

Pilih tiga file dengan mutan yang bertahan paling banyak. Baca setiap survivor dan tanyakan assertion apa yang hilang. Seringkali perbaikannya sederhana: Anda memanggil fungsi dalam tes tapi tidak pernah memeriksa nilai kembaliannya. Atau Anda melewatkan data melalui parser tapi tidak pernah memverifikasi output yang diparse. Atau Anda menguji happy path tiga kali dengan input berbeda tapi tidak pernah menguji cabang error.

Mutan-mutan itu bukan noise. Mereka adalah daftar yang diurutkan berdasarkan tempat paling mungkin bagi bug yang belum diuji untuk bersembunyi. Mulailah dari atas.


FAQ

Apa perbedaan antara code coverage dan mutation testing? Code coverage mengukur baris mana yang dieksekusi. Mutation testing mengukur apakah tes Anda akan gagal jika baris-baris tersebut mengandung bug. Coverage 100% dengan skor mutasi 40% berarti Anda menjalankan setiap baris, tapi tes Anda tidak akan menyadari jika sebagian besar dari mereka salah.

Apakah mutation testing bisa menemukan bug di kode yang sudah ada? Tidak. Mutation testing mengevaluasi tes Anda, bukan source code Anda. Ia memberi tahu Anda di mana tes Anda tidak mencukupi. Ia tidak memberi tahu Anda apakah kode Anda benar, hanya apakah tes Anda akan menangkap kelas-kelas kesalahan tertentu.

Bahasa mana yang memiliki tool mutation testing yang bagus? JavaScript/TypeScript (Stryker), Java (PIT), C# (Stryker.NET), Python (mutmut), dan Rust (cargo-mutants) semuanya memiliki tool yang matang. Ekosistemnya bervariasi dalam hal performa dan mutation operator yang didukung.

Apakah mutation testing harus menggantikan code coverage? Tidak. Coverage murah dan cepat. Gunakan untuk feedback cepat selama development. Gunakan mutation testing sebagai quality gate berkala untuk menemukan blind spot yang tidak bisa dilihat coverage.