Tes Anda Lulus. Kode Anda Masih Salah.
Anda memiliki 100% line coverage. Setiap branch tercapai. Setiap function dipanggil. Lalu seseorang mengubah + menjadi - di logika pricing Anda, menjalankan tes, dan semuanya lulus.
Itu bukan masalah teoritis. Itulah yang terjadi ketika tes Anda mengeksekusi kode tetapi tidak benar-benar memverifikasi perilakunya. Coverage mengukur baris mana yang berjalan, bukan output mana yang diperiksa. Mutation testing menutup celah tersebut dengan sengaja memperkenalkan bug kecil dan memverifikasi bahwa tes Anda menangkapnya.
Pertanyaan bagi tim Rust bukanlah apakah mutation testing merupakan ide yang bagus. Melainkan apakah cargo-mutants, tool dominan di ekosistemnya, praktis diberikan waktu kompilasi dan type system Rust. Jawabannya ya, dengan caveat yang penting.
Apa yang Sebenarnya Dilakukan Mutation Testing
Mutation testing sederhana secara konsep. Tool tersebut membuat perubahan kecil pada source code Anda, menjalankan test suite, dan memeriksa apakah ada yang gagal.
Jika test suite gagal, mutant tersebut “killed.” Itulah yang Anda inginkan. Artinya tes Anda menyadari bug tersebut.
Jika test suite lulus, mutant tersebut “survives.” Artinya tes Anda mengeksekusi kode yang dimutasi dan tidak menyadari ada yang salah. Anda memiliki tes yang lemah.
Mutasi umum mencakup mengganti arithmetic operators (+ menjadi -), menukar comparison operators (> menjadi >=), mengganti boolean literals (true menjadi false), dan menghapus function calls yang mengembalikan nilai. Setiap perubahan cukup kecil sehingga manusia akan mengenalinya sebagai bug. Test suite pun harusnya mengenalinya.
Cara Kerja cargo-mutants pada Kode Rust
cargo-mutants adalah tool mutation testing yang dibangun khusus untuk Rust. Ia tidak mengharuskan Anda untuk menganotasi tes atau mengubah build system. Anda instal dan jalankan.
cargo install cargo-mutants
cargo mutants
Tool tersebut memindai source file Anda, menghasilkan mutant dengan menerapkan transformation rules pada AST, dan menjalankan cargo test untuk masing-masing. Ia melacak mutant mana yang survive dan mencetak laporan.
Berikut adalah sebuah function dengan tes yang terlihat solid tetapi sebenarnya tidak:
pub fn apply_discount(price: f64, rate: f64) -> f64 {
price * (1.0 - rate)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_apply_discount() {
let result = apply_discount(100.0, 0.2);
// We ran the function. Coverage is 100%.
// But we never asserted the result.
}
}
cargo mutants akan menghasilkan mutant yang mengubah * menjadi / atau mengganti 1.0 - rate dengan 1.0 + rate. Tes tersebut masih akan lulus karena tidak pernah memeriksa result. Mutant yang survive menandai masalah tersebut.
Tes sebenarnya yang membunuh mutant terlihat seperti ini:
#[test]
fn test_apply_discount() {
assert_eq!(apply_discount(100.0, 0.2), 80.0);
assert_eq!(apply_discount(50.0, 0.0), 50.0);
}
Sekarang setiap arithmetic mutant gagal karena assertions menangkap output yang salah.
Seperti Apa Outputnya
Jalankan cargo mutants dan Anda mendapatkan ringkasan:
Found 42 mutants
Killed 38 mutants
Missed 4 mutants
Timeout 0 mutants
Unviable 0 mutants
Missed mutant adalah yang survive. cargo mutants menulis masing-masing ke mutants.out/ dengan diff dan file path. Anda membaca diff dan menambahkan assertion yang hilang.
Timeout terjadi ketika mutant menyebabkan infinite loop. cargo-mutants mendeteksi ini dan menandainya sebagai killed by timeout, yang dianggap sebagai sukses.
Unviable mutant adalah perubahan yang tidak dapat dikompilasi. Type system Rust menolaknya bahkan sebelum tes dijalankan.
Type System Rust Adalah Pedang Bermata Dua
Di JavaScript atau Python, tool mutation testing dapat mengganti hampir semua operator dan kode masih akan berjalan. Ia hanya akan menghasilkan hasil yang salah. Di Rust, banyak mutasi ditangkap oleh compiler bahkan sebelum tes dijalankan.
Ganti + dengan - pada unsigned integer dan Anda mungkin mendapatkan overflow, tetapi kodenya dikompilasi. Ganti > dengan < dalam konteks generic dan compiler mungkin menolaknya jika trait bounds tidak mendukung comparison tersebut. Hapus function call yang mengembalikan nilai yang diharapkan caller, dan compiler akan error.
Ini berarti cargo-mutants menghasilkan lebih sedikit viable mutant dibandingkan tool setara di bahasa lain. Sebuah proyek Python mungkin melihat 200 mutant untuk sebuah module. Proyek Rust mungkin melihat 40. Mutant yang berhasil dikompilasi adalah yang sebenarnya bisa lolos ke production. Type system menyaring noise.
Trade-off-nya adalah waktu kompilasi. Setiap viable mutant memicu rebuild. Proyek dengan test suite lima menit mungkin menghabiskan satu jam menjalankan cargo mutants.
Pajak Waktu Kompilasi Itu Nyata
Inilah alasan utama tim ragu-ragu. Mutation testing secara teori sangat parallel. Setiap mutant independen. Dalam praktiknya, build system Rust tidak memparallelkan dengan bersih di puluhan compiler invocation pada source tree yang sama.
cargo-mutants memiliki flag --jobs, tetapi disk I/O dan crate graph locking menjadi bottleneck. Pada CI runner tipikal dengan dua core, job tersebut scale dengan buruk.
Anda dapat mengurangi ini. Gunakan --in-place untuk menghindari menyalin source tree untuk setiap mutant. Gunakan --file atau --exclude untuk menargetkan module tertentu. Jalankan mutation testing setiap malam atau mingguan, bukan pada setiap push.
Apa yang Tidak Ditangkap cargo-mutants
Tidak ada tool mutation testing yang menangkap semuanya. cargo-mutants memiliki keterbatasan spesifik yang harus Anda ketahui.
Ia tidak memutasi macro expansions. Jika logika kritis Anda berada di dalam macro, tool tersebut melihat invocation, bukan kode yang dihasilkan.
Ia tidak memahami semantic equivalence. Beberapa mutant menghasilkan perilaku yang berbeda tetapi masih benar untuk semua input yang valid. Sebuah + 0 yang redundant mungkin survive karena tes tidak peduli, meskipun mutasi tersebut bukan bug yang nyata. Anda harus melakukan triage secara manual.
Kapan Mutation Testing Sepadan dengan Biayanya
Anda tidak perlu menjalankan cargo mutants pada setiap commit. Anda membutuhkannya ketika test suite Anda cukup besar sehingga Anda tidak lagi mempercayai assertions Anda sendiri.
Jalankan ketika module kritis memiliki coverage tinggi tetapi Anda tetap mengirimkan bug di dalamnya, atau ketika refactor mengubah logika secara halus dan Anda ingin yakin bahwa assertions ketat.
Jangan jalankan ketika test suite Anda sudah flaky atau waktu kompilasi Anda adalah bottleneck yang dikeluhkan semua orang. Perbaiki dasar-dasarnya terlebih dahulu.
Menambahkannya ke CI Tanpa Merusak Pipeline
Setup praktisnya adalah scheduled job, bukan gate pada setiap pull request.
Berikut adalah workflow GitHub Actions yang berjalan setiap minggu:
name: Mutation Testing
on:
schedule:
- cron: "0 3 * * 1"
workflow_dispatch:
jobs:
mutants:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install cargo-mutants
run: cargo install cargo-mutants
- name: Run mutation testing
run: cargo mutants --in-place
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: mutants-report
path: mutants.out/
Flag --in-place menjaga penggunaan disk tetap masuk akal. rust-cache memotong waktu build awal. Trigger terjadwal menghindari pemblokiran developer. Upload laporan sebagai artifact sehingga Anda dapat meninjau mutant yang survive tanpa harus menggulir log CI.
Mulai dari Satu Module
Anda tidak perlu memutasi seluruh codebase. Pilih satu module dengan logika business-critical dan riwayat bug. Jalankan cargo mutants --file src/pricing.rs. Baca laporannya. Perbaiki tes terlemah.
Jalankan pertama selalu yang terburuk. Anda akan menemukan tes yang mengeksekusi kode tetapi tidak melakukan assert apa pun. Anda akan menemukan branch yang dicover oleh tes yang tidak memeriksa hasil branch. Anda akan bertanya-tanya bagaimana tes-tes tersebut pernah terasa memadai.
Itulah intinya. Mutation testing tidak menemukan bug di kode Anda. Ia menemukan bug di tes Anda. Di Rust, di mana compiler sudah menangkap kesalahan yang jelas, itulah feedback loop yang tepat yang Anda butuhkan.
Pertanyaan yang Sering Diajukan
Apa itu mutation testing?
Mutation testing mengevaluasi test suite Anda dengan memperkenalkan bug kecil yang disengaja ke source code Anda. Jika tes Anda gagal, mutant tersebut “killed.” Jika tes Anda lulus, mutant tersebut “survives” dan Anda memiliki celah.
Apa bedanya mutation testing dengan code coverage?
Coverage mengukur baris mana yang dieksekusi. Mutation testing mengukur apakah tes Anda akan mendeteksi output yang salah dari baris-baris tersebut. Sebuah tes dapat memiliki 100% coverage dan menangkap nol mutant.
Apakah mutation testing lambat untuk semua proyek Rust?
Biayanya scale dengan waktu kompilasi dan jumlah tes. Library kecil dapat selesai dalam hitungan menit. Proyek workspace besar memakan waktu significantly lebih lama. Gunakan --file dan --exclude untuk membatasi run ke module tertentu.
Bisakah saya mengabaikan false positive mutant?
Ya. cargo-mutants mendukung file konfigurasi mutants.toml untuk mengecualikan file, function, atau tipe mutasi tertentu. Gunakan ini secara hemat sehingga Anda tidak menyembunyikan celah tes yang nyata.