Mutation testing menguji kualitas test suite dengan sengaja mengubah kode produksi, lalu memeriksa apakah pengujian mendeteksi perubahan tersebut. Jika seluruh pengujian tetap lulus setelah perilaku program diubah, test suite kemungkinan belum cukup kuat untuk mencegah regresi pada bagian itu.

Untuk proyek TypeScript, Stryker dapat mengotomatisasi proses pembuatan mutant, pemilihan pengujian, dan pelaporan mutation score. Pendekatan yang praktis di CI adalah menjalankannya pada kode yang berubah untuk setiap pull request, mempertahankan baseline agar kualitas tidak menurun, dan menjalankan cakupan penuh secara terjadwal.

Cara kerja mutation testing

Stryker memulai dengan menjalankan test suite tanpa perubahan. Tahap ini sering disebut initial test run atau dry run. Jika pengujian dasar gagal atau tidak stabil, proses mutation testing tidak dapat menghasilkan informasi yang dapat dipercaya.

Setelah itu, Stryker membuat perubahan kecil pada kode produksi. Setiap perubahan disebut mutant. Contohnya meliputi:

  • Mengganti >= menjadi >.
  • Mengganti nilai Boolean true menjadi false.
  • Mengubah operator logika || menjadi &&.
  • Menghapus isi pernyataan atau mengganti nilai kembalian.
  • Mengubah operasi aritmetika seperti penjumlahan menjadi pengurangan.

Test suite kemudian dijalankan terhadap setiap mutant yang relevan. Hasilnya biasanya dikelompokkan sebagai berikut:

  • Killed: setidaknya satu pengujian gagal. Test suite berhasil mendeteksi perubahan perilaku.
  • Survived: semua pengujian tetap lulus. Ini adalah kandidat celah pengujian yang perlu diperiksa.
  • Timeout: mutant menyebabkan pengujian melebihi batas waktu. Hasil ini umumnya dianggap terdeteksi, tetapi tetap perlu ditinjau karena dapat berasal dari loop tak berujung atau infrastruktur yang lambat.
  • No coverage: tidak ada pengujian yang menjalankan lokasi mutant. Masalahnya kemungkinan terletak pada cakupan eksekusi, bukan hanya kualitas assertion.
  • Compile error atau invalid: perubahan menghasilkan program yang tidak valid. Mutant seperti ini biasanya tidak dimasukkan sebagai bukti kualitas test suite.

Mutation score bukan code coverage

Mutation score menggambarkan proporsi mutant valid yang terdeteksi oleh pengujian. Detail perhitungannya dapat berbeda menurut status yang dimasukkan oleh alat, tetapi secara konseptual skor tersebut membandingkan mutant yang terdeteksi, seperti killed atau timeout, dengan mutant yang dapat dinilai.

Code coverage hanya menunjukkan bahwa suatu baris atau cabang pernah dijalankan. Mutation testing menguji apakah test suite benar-benar memperhatikan hasil eksekusi tersebut. Karena itu, sebuah proyek dapat memiliki line coverage tinggi tetapi mutation score rendah akibat assertion yang terlalu umum atau tidak menguji batas domain.

Equivalent mutant

Equivalent mutant adalah perubahan sintaksis yang tidak mengubah perilaku program untuk seluruh input yang valid. Misalnya, pada domain yang hanya menerima bilangan bulat, kondisi age > 17 dapat setara dengan age >= 18. Tidak ada pengujian yang dapat membunuh mutant yang benar-benar ekuivalen karena perilakunya memang sama.

Deteksi equivalent mutant secara umum merupakan masalah yang sulit dan tidak sepenuhnya dapat diotomatisasi. Akibatnya, equivalent mutant dapat muncul sebagai survived mutant dan menurunkan skor. Jangan menambahkan pengujian tanpa nilai hanya untuk mengejar angka. Dokumentasikan kasusnya, perjelas domain melalui tipe atau validasi, atau gunakan mekanisme pengecualian yang didukung versi Stryker Anda sebagai pilihan terakhir.

Menyiapkan Stryker pada proyek TypeScript dan Jest

Contoh berikut mengasumsikan proyek TypeScript sudah menggunakan Jest dan memiliki transformasi TypeScript yang berfungsi. Mutation testing sebaiknya ditambahkan setelah perintah pengujian biasa dapat dijalankan secara deterministik.

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

Perintah inisialisasi membantu membuat konfigurasi awal. Konfigurasi juga dapat ditulis secara manual dalam stryker.config.mjs:

const defaultMutate = [
  'src/**/*.ts',
  '!src/**/*.test.ts',
  '!src/**/*.spec.ts',
  '!src/**/*.d.ts',
  '!src/generated/**'
];

const changedFiles = process.env.MUTATE_FILES_JSON
  ? JSON.parse(process.env.MUTATE_FILES_JSON)
  : null;

export default {
  testRunner: 'jest',
  jest: {
    configFile: 'jest.config.cjs'
  },
  mutate: changedFiles?.length ? changedFiles : defaultMutate,
  coverageAnalysis: 'perTest',
  reporters: ['progress', 'clear-text', 'html', 'json'],
  thresholds: {
    high: 80,
    low: 60,
    break: 50
  },
  timeoutMS: 10000,
  timeoutFactor: 1.5
};

Nilai threshold tersebut hanya contoh awal, bukan target universal. Ukur proyek terlebih dahulu dan sesuaikan dengan baseline aktual. Opsi coverageAnalysis: 'perTest' membantu Stryker memilih pengujian yang berkaitan dengan mutant sehingga tidak selalu perlu menjalankan seluruh suite untuk setiap perubahan.

timeoutMS dan timeoutFactor menyediakan kelonggaran berdasarkan durasi normal pengujian. Batas yang terlalu kecil menghasilkan timeout palsu, sedangkan batas yang terlalu besar membuat mutant yang menyebabkan loop atau operasi sangat lambat memakan waktu CI. Mulailah dari hasil dry run, lalu sesuaikan berdasarkan test suite terlama.

Jalankan mutation testing secara lokal dengan:

npm test
npx stryker run

Selalu jalankan pengujian biasa terlebih dahulu. Mutation testing tidak dirancang untuk mendiagnosis test suite yang sudah gagal sebelum source code dimutasi.

Contoh memperbaiki survived mutant

Misalkan aplikasi memiliki aturan bahwa pesanan dengan nilai minimal Rp500.000 memperoleh diskon 10 persen:

export function calculateDiscount(total: number): number {
  if (total >= 500_000) {
    return total * 0.1;
  }

  return 0;
}

Pengujian awal hanya memeriksa nilai yang jauh di atas batas:

import { calculateDiscount } from './calculate-discount';

describe('calculateDiscount', () => {
  it('memberikan diskon untuk pesanan besar', () => {
    expect(calculateDiscount(600_000)).toBe(60_000);
  });
});

Test tersebut mencapai cabang diskon dan kemungkinan berkontribusi pada line coverage yang tinggi. Namun, mutant yang mengubah >= menjadi > tetap bertahan karena input 600_000 menghasilkan perilaku yang sama pada kedua kondisi.

Perbaikannya bukan sekadar menambah test acak, tetapi menguji batas bisnis secara eksplisit:

import { calculateDiscount } from './calculate-discount';

describe('calculateDiscount', () => {
  it.each([
    [499_999, 0],
    [500_000, 50_000],
    [600_000, 60_000]
  ])('menghitung diskon untuk total %i', (total, expected) => {
    expect(calculateDiscount(total)).toBe(expected);
  });
});

Kasus 500_000 membunuh mutant tersebut karena kondisi asli memberikan diskon, sedangkan kondisi hasil mutasi tidak. Kasus di bawah batas juga memastikan cabang tanpa diskon tetap benar. Pengujian ini bernilai karena mendokumentasikan kebijakan bisnis, bukan hanya meningkatkan skor.

Memilah survived mutant yang bernilai

Prioritaskan temuan berdasarkan dampak regresi, bukan hanya jumlah mutant. Urutan pemeriksaan yang praktis adalah:

  1. Otorisasi dan keamanan: perubahan kondisi akses, validasi izin, atau filter kepemilikan data.
  2. Aturan bisnis: batas harga, status transaksi, pembulatan, masa berlaku, dan transisi state.
  3. Penanganan error: fallback, retry, rollback, dan pemetaan exception.
  4. Integritas data: kondisi update, penghapusan, deduplikasi, dan validasi input.
  5. Kode presentasi atau formatting: tinjau berdasarkan dampak pengguna dan biaya pengujiannya.

Untuk setiap survived mutant, ajukan tiga pertanyaan:

  • Apakah mutant mengubah perilaku yang dapat diamati?
  • Apakah perilaku tersebut termasuk kontrak atau aturan bisnis aplikasi?
  • Bisakah pengujian yang stabil membedakan perilaku asli dan hasil mutasi?

Jika jawabannya ya, tambahkan pengujian pada level terendah yang masih mewakili kontrak tersebut. Unit test cocok untuk fungsi murni dan aturan lokal. Integration test lebih tepat jika perubahan hanya dapat diamati melalui database, antrean, atau batas API.

Memilih cakupan file dan mengendalikan durasi

Mutation testing jauh lebih mahal daripada test run biasa karena alat harus mengevaluasi banyak variasi kode. Jangan memulai dengan memutasi seluruh repository tanpa strategi cakupan.

File yang layak dimutasi

Fokuskan cakupan awal pada kode produksi yang mengandung keputusan atau transformasi penting:

  • Service dan domain logic.
  • Validasi serta kebijakan otorisasi.
  • Perhitungan harga, pajak, diskon, dan pembulatan.
  • State machine dan workflow transaksi.
  • Adapter yang memetakan data eksternal ke model internal.

Umumnya, jangan memutasi test, file deklarasi tipe, konfigurasi build, hasil code generation, fixture, atau barrel file yang hanya melakukan ekspor ulang. Mengecualikan kode hanya karena skornya rendah adalah praktik yang buruk; pengecualian harus didasarkan pada rendahnya nilai informasi atau karena file bukan source code yang dipelihara secara manual.

Menjalankan hanya kode yang berubah

Untuk pull request, daftar file produksi dapat diambil dari perbandingan branch dasar dengan commit saat ini. Konfigurasi di atas membaca daftar tersebut dari variabel lingkungan MUTATE_FILES_JSON.

BASE_SHA="origin/main"

MUTATE_FILES_JSON="$({
  git diff --name-only --diff-filter=ACMR "$BASE_SHA...HEAD" -- src
} | node -e "
  let input = '';
  process.stdin.on('data', chunk => input += chunk);
  process.stdin.on('end', () => {
    const files = input
      .split(/\r?\n/)
      .filter(Boolean)
      .filter(file => file.endsWith('.ts'))
      .filter(file => !/\.(test|spec)\.ts$/.test(file))
      .filter(file => !file.endsWith('.d.ts'));
    process.stdout.write(JSON.stringify(files));
  });
")"

if [ "$MUTATE_FILES_JSON" = "[]" ]; then
  echo "Tidak ada source TypeScript yang perlu dimutasi"
  exit 0
fi

export MUTATE_FILES_JSON
npx stryker run

Pendekatan changed-only menekan durasi, tetapi memiliki keterbatasan. Perubahan pada test, konfigurasi, dependency, atau kontrak bersama dapat memengaruhi kualitas kode yang tidak ikut berubah. Perubahan pada satu fungsi juga dapat berdampak pada pemanggilnya. Karena itu, changed-only sebaiknya menjadi pemeriksaan cepat pada pull request, bukan satu-satunya mutation run.

Gunakan kombinasi berikut:

  • Pull request: mutasikan file produksi yang berubah, serta file terkait jika dependency graph tersedia.
  • Branch utama: jalankan cakupan lebih luas setelah merge jika anggaran CI memungkinkan.
  • Terjadwal: jalankan seluruh cakupan pada malam hari atau secara periodik sebagai backstop.

Stryker juga menyediakan mode incremental pada versi yang mendukungnya. Mode ini dapat menggunakan hasil run sebelumnya untuk mengurangi pekerjaan berulang. Cache atau laporan incremental harus dipertahankan sebagai artifact CI dan diinvalidasi ketika konfigurasi test, transformasi TypeScript, dependency, atau cakupan source berubah. Periksa dokumentasi versi yang terpasang sebelum menetapkan lokasi file cache.

Baseline dan threshold bertahap di CI

Menetapkan threshold tinggi sejak hari pertama sering membuat tim mengabaikan hasil karena pipeline terus gagal. Mulailah dengan mengukur baseline pada source code yang memang ingin dijaga, lalu gunakan dua guardrail:

  • Floor absolut: pipeline gagal jika mutation score turun di bawah batas minimum.
  • Non-regression terhadap baseline: perubahan tidak boleh menurunkan skor tanpa alasan yang ditinjau.

Field break pada konfigurasi Stryker dapat digunakan sebagai floor absolut. Nilai low dan high membantu mengelompokkan tampilan laporan. Untuk perbandingan terhadap baseline historis, simpan laporan JSON sebagai artifact atau kirim metriknya ke sistem pelaporan internal, lalu bandingkan hasil dengan branch utama.

Strategi bertahap yang lebih aman adalah:

  1. Jalankan dalam mode informasional dan catat baseline.
  2. Perbaiki survived mutant berisiko tinggi dan test yang flaky.
  3. Tetapkan floor sedikit di bawah kondisi stabil agar regresi besar langsung ditolak.
  4. Naikkan threshold setelah perbaikan nyata masuk, bukan berdasarkan target arbitrer.
  5. Pertahankan threshold khusus untuk kode yang berubah jika sistem pelaporan mendukungnya.

Contoh job GitHub Actions untuk pull request berikut menjalankan test biasa sebelum mutation testing:

name: Mutation testing

on:
  pull_request:

jobs:
  changed-source:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci
      - run: npm test -- --runInBand

      - name: Run mutation testing for changed source
        shell: bash
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
        run: |
          MUTATE_FILES_JSON="$(
            git diff --name-only --diff-filter=ACMR "$BASE_SHA...HEAD" -- src |
            node -e "
              let s = '';
              process.stdin.on('data', c => s += c);
              process.stdin.on('end', () => {
                const files = s.split(/\r?\n/)
                  .filter(Boolean)
                  .filter(f => f.endsWith('.ts'))
                  .filter(f => !/\.(test|spec)\.ts$/.test(f))
                  .filter(f => !f.endsWith('.d.ts'));
                process.stdout.write(JSON.stringify(files));
              });
            "
          )"

          if [ "$MUTATE_FILES_JSON" = "[]" ]; then
            echo "No production TypeScript files changed"
            exit 0
          fi

          export MUTATE_FILES_JSON
          npx stryker run

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: mutation-report
          path: reports/

Versi runtime, action, dan perintah test harus diselaraskan dengan repository. Job terjadwal untuk cakupan penuh dapat memakai konfigurasi yang sama tanpa mengisi MUTATE_FILES_JSON.

Guardrail agar hasil tidak flaky

Mutation testing memperbanyak jumlah eksekusi test sehingga ketidakstabilan kecil akan semakin terlihat. Sebelum menjadikan skor sebagai quality gate, terapkan guardrail berikut:

  • Bekukan waktu atau gunakan fake timer untuk kode yang bergantung pada jam dan tanggal.
  • Gunakan seed tetap untuk data acak, lalu catat seed ketika test gagal.
  • Jangan mengakses jaringan eksternal dari unit test. Gunakan stub atau server pengujian yang dikendalikan.
  • Isolasi database, filesystem, environment variable, dan global state untuk setiap test.
  • Hindari ketergantungan pada urutan eksekusi test.
  • Pastikan worker paralel tidak memakai port, tabel, atau direktori sementara yang sama.
  • Gunakan timezone dan locale eksplisit untuk pengujian tanggal serta formatting.
  • Bedakan timeout akibat mutant dari timeout akibat resource runner CI yang tidak stabil.

Jika mutant kadang killed dan kadang survived, jalankan ulang test yang dipilih tanpa mutation testing, lalu ulangi dalam mode serial. Periksa shared state, timer, operasi asynchronous yang tidak ditunggu, mock yang tidak dipulihkan, dan assertion yang terjadi setelah test dianggap selesai.

Jangan langsung menaikkan timeoutMS untuk menyembunyikan masalah. Bandingkan durasi dry run lokal dan CI. Jika hanya satu test yang lambat, perbaiki isolasi atau fixture test tersebut. Jika seluruh runner melambat karena keterbatasan CPU CI, sesuaikan paralelisme dan batas waktu secara terukur.

Checklist adopsi mutation testing

  • Test suite biasa lulus secara konsisten dan bebas flaky test yang diketahui.
  • Jest dan transformasi TypeScript berjalan identik di lokal dan CI.
  • Cakupan mutasi hanya berisi source code yang dipelihara dan bernilai bisnis.
  • Baseline mutation score sudah dicatat sebelum menetapkan quality gate.
  • Survived mutant diprioritaskan berdasarkan risiko, bukan sekadar jumlah.
  • Threshold diterapkan bertahap dan tidak digunakan untuk mendorong test tanpa nilai.
  • Pull request memakai changed-only atau incremental run untuk feedback cepat.
  • Full mutation run tetap dijalankan secara periodik.
  • Laporan HTML atau JSON disimpan sebagai artifact agar temuan dapat ditinjau.
  • Equivalent mutant dan pengecualian didokumentasikan dalam code review.
  • Timeout, random seed, waktu, jaringan, database, dan parallel worker dikendalikan.

Kapan mutation testing layak digunakan?

Mutation testing paling layak digunakan ketika kegagalan logika memiliki dampak nyata, test suite sudah cukup cepat dan stabil, serta code coverage tidak lagi menjawab apakah assertion benar-benar kuat. Contoh yang baik adalah sistem pembayaran, otorisasi, perhitungan finansial, workflow transaksi, library bersama, dan domain dengan banyak aturan batas.

Mutation testing kurang efektif sebagai langkah pertama pada proyek yang belum memiliki test suite dasar, sering mengalami flaky test, atau didominasi kode hasil generasi dan integrasi end-to-end yang mahal. Dalam kondisi tersebut, perbaiki determinisme, unit testing, dan desain batas komponen terlebih dahulu.

Tujuan akhirnya bukan mencapai skor sempurna. Mutation testing bernilai ketika hasilnya membantu menemukan regresi yang sebelumnya lolos, memperjelas kontrak bisnis, dan memberi quality gate yang dapat dipertanggungjawabkan tanpa membuat durasi CI tidak terkendali.