Pada Chapter code coverage
kita sudah melihat betapa mudahnya angka coverage menipu: suite dua kasus mencapai 100% line coverage padahal bug operator && versus || lolos tanpa terdeteksi. Pertanyaan yang benar-benar penting bukan “berapa persen kode saya dijalankan test?”, melainkan “kalau kode ini salah, apakah test saya akan gagal?”. Mutation testing menjawab pertanyaan itu secara brutal: alat mengubah kode produksi sedikit demi sedikit — mengganti operator, membolos konstanta, menukar kondisi — lalu mengamati apakah suite test ikut merah. Perubahan buatan tersebut disebut mutan, dan satu-satunya akhir yang baik bagi mutan adalah mati.
Mutan Harus Mati #
Mekanisme dasarnya sederhana dan bisa kita jalankan manual. Satu mutan diterapkan pada kode, suite test dijalankan, lalu hasilnya dinilai:
- Mutan terbunuh — minimal satu test gagal. Suite terbukti peka terhadap bug yang dimutoskan itu.
- Mutan selamat — seluruh test tetap hijau. Ini lubang nyata: bug dengan pola persis itu akan lolos ke produksi tanpa suara.
Setiap mutan mensimulasikan kesalahan ketik yang benar-benar terjadi di dunia nyata. Tabel berikut memuat operator mutasi yang paling sering dipakai:
| Operator asli | Operator mutan | Bug yang disimulasikan |
|---|---|---|
< | <= | syarat batas meleset satu nilai |
+ | - | operasi aritmetika tertukar |
&& | || | logika menjadi terlalu longgar |
10 | 20 | konstanta salah ketik |
return $x; | return null; | nilai balik hilang |
| panggilan method | dihapus | efek samping tidak terjadi |
Perhatikan bahwa mutan bukan bug acak yang aneh-aneh; setiap mutan adalah regresi kecil yang masuk akal. Suite test yang membunuh mayoritas mutan adalah suite yang juga akan menangkap regresi manusia yang polanya serupa.
Kode dan Dua Suite Uji #
Seluruh percobaan pada Chapter ini memakai bundle kecil di bawah halaman: satu fungsi produksi dan dua suite test dengan kepribadian yang sangat berbeda. Fungsi produksinya menghitung diskon: di bawah ambang Rp100.000 tidak ada diskon, member mendapat 10% dari total belanja, dan non-member mendapat potongan tetap Rp5.000:
<?php
declare(strict_types=1);
function hitungDiskon(int $totalBelanja, bool $member): int
{
if ($totalBelanja < 100000) {
return 0;
}
return $member ? intdiv($totalBelanja, 10) : 5000;
}Suite pertama — sebut saja suite kuat — memakai data provider dengan empat kasus yang menekan titik-titik kritis: di bawah ambang, tepat di ambang untuk kedua jenis pelanggan, dan jauh di atas ambang:
<?php
declare(strict_types=1);
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class HitungDiskonTest extends TestCase
{
#[DataProvider('provideHitungDiskon')]
public function testHitungDiskon(int $belanja, bool $member, int $harapan): void
{
$this->assertSame($harapan, hitungDiskon($belanja, $member));
}
public static function provideHitungDiskon(): array
{
return [
'di bawah ambang' => [50000, false, 0],
'tepat ambang non-member' => [100000, false, 5000],
'tepat ambang member' => [100000, true, 10000],
'jauh di atas ambang member' => [250000, true, 25000],
];
}
}Suite kedua — suite lemah — adalah contoh test yang tampak rajin tetapi kosong: memanggil fungsi lalu hanya menegaskan bahwa hasilnya bertipe int, tanpa pernah membandingkan nilai:
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class HitungDiskonLemahTest extends TestCase
{
public function testHitungDiskonMengembalikanInt(): void
{
$hasil = hitungDiskon(250000, true);
// hanya "menyentuh" kode — tidak ada pengecekan nilai
$this->assertIsInt($hasil);
}
}Baseline: Semua Hijau #
Sebelum memutoskan apa pun, mari kita jalankan kedua suite pada kode yang masih asli. Suite lengkap (kedua berkas) terlebih dahulu:
php /home/z/my-project/bin/phpunit.phar --bootstrap tests/bootstrap.php testsPHPUnit 12.5.39 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.23
..... 5 / 5 (100%)
Time: 00:00.002, Memory: 28.05 MB
OK (5 tests, 5 assertions)Kemudian masing-masing suite secara terpisah:
php /home/z/my-project/bin/phpunit.phar --bootstrap tests/bootstrap.php tests/HitungDiskonTest.phpPHPUnit 12.5.39 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.23
.... 4 / 4 (100%)
Time: 00:00.001, Memory: 28.05 MB
OK (4 tests, 4 assertions)php /home/z/my-project/bin/phpunit.phar --bootstrap tests/bootstrap.php tests/HitungDiskonLemahTest.phpPHPUnit 12.5.39 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.23
. 1 / 1 (100%)
Time: 00:00.001, Memory: 28.05 MB
OK (1 test, 1 assertion)Lima test hijau, empat test hijau, satu test hijau. Sampai titik ini kedua suite terlihat sama-sama sehat — dan inilah masalahnya: indikator standar seperti jumlah test, assertion, atau coverage tidak sanggup membedakan keduanya secara meyakinkan.
Mutan Pertama: Batas Ambang Bergeser #
Mutan pertama mengubah satu karakter: operator < menjadi <= pada pemeriksaan ambang. Akibatnya, belanja tepat Rp100.000 yang semula berhak mendapat diskon kini ditolak:
<?php
declare(strict_types=1);
function hitungDiskon(int $totalBelanja, bool $member): int
{
// mutan M1: '<' diganti '<=' — belanja tepat ambang kini ditolak
if ($totalBelanja <= 100000) {
return 0;
}
return $member ? intdiv($totalBelanja, 10) : 5000;
}Jalankan suite kuat terhadap mutan tersebut:
php /home/z/my-project/bin/phpunit.phar --bootstrap tests/bootstrap.php tests/HitungDiskonTest.phpPHPUnit 12.5.39 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.23
.FF. 4 / 4 (100%)
Time: 00:00.001, Memory: 28.05 MB
There were 2 failures:
1) HitungDiskonTest::testHitungDiskon@tepat ambang non-member with data (100000, false, 5000)
Failed asserting that 0 is identical to 5000.
/tmp/mutasi/tests/HitungDiskonTest.php:13
2) HitungDiskonTest::testHitungDiskon@tepat ambang member with data (100000, true, 10000)
Failed asserting that 0 is identical to 10000.
/tmp/mutasi/tests/HitungDiskonTest.php:13
FAILURES!
Tests: 4, Assertions: 4, Failures: 2.Mutan terbunuh. Dua test gagal sekaligus, dan keduanya bukan test sembarangan: kasus tepat ambang non-member serta tepat ambang member memang sengaja ditempatkan di titik batas Rp100.000 karena di sanalah < berbeda dari <=. Pesan Failed asserting that 0 is identical to 5000 menunjukkan persis perilaku rusaknya — mutan mengembalikan 0 untuk belanja yang seharusnya berdiskon.
Bagaimana dengan suite lemah? Perintah yang sama diarahkan ke berkasnya:
php /home/z/my-project/bin/phpunit.phar --bootstrap tests/bootstrap.php tests/HitungDiskonLemahTest.phpPHPUnit 12.5.39 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.23
. 1 / 1 (100%)
Time: 00:00, Memory: 28.05 MB
OK (1 test, 1 assertion)Mutan selamat. Test lemah memanggil hitungDiskon(250000, true); karena 250000 > 100000, mutan ambang tidak berpengaruh pada jalur itu, dan assertIsInt tetap puas dengan hasil 25000 yang bertipe int. Bug batas hidup damai di dalam suite yang hijau.
Dua Mutan Lain dan Papan Skor #
Mutan kedua menyabet pembagi diskon member: intdiv($totalBelanja, 10) menjadi intdiv($totalBelanja, 20), sehingga diskon member tinggal setengahnya. Suite kuat merespons dengan dua kegagalan:
php /home/z/my-project/bin/phpunit.phar --bootstrap tests/bootstrap.php tests/HitungDiskonTest.phpPHPUnit 12.5.39 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.23
..FF 4 / 4 (100%)
Time: 00:00.001, Memory: 28.05 MB
There were 2 failures:
1) HitungDiskonTest::testHitungDiskon@tepat ambang member with data (100000, true, 10000)
Failed asserting that 5000 is identical to 10000.
/tmp/mutasi/tests/HitungDiskonTest.php:13
2) HitungDiskonTest::testHitungDiskon@jauh di atas ambang member with data (250000, true, 25000)
Failed asserting that 12500 is identical to 25000.
/tmp/mutasi/tests/HitungDiskonTest.php:13
FAILURES!
Tests: 4, Assertions: 4, Failures: 2.Mutan ketiga menghapus potongan non-member: konstanta 5000 menjadi 0. Kali ini satu test saja yang gagal — kasus tepat ambang non-member yang memang satu-satunya jalur menuju nilai 5000:
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.23
.F.. 4 / 4 (100%)
Time: 00:00.001, Memory: 28.05 MB
There was 1 failure:
1) HitungDiskonTest::testHitungDiskon@tepat ambang non-member with data (100000, false, 5000)
Failed asserting that 0 is identical to 5000.
/tmp/mutasi/tests/HitungDiskonTest.php:13
FAILURES!
Tests: 4, Assertions: 4, Failures: 1.Suite lemah diuji terhadap kedua mutan tersebut dan tetap hijau pada keduanya — hasilnya identik dengan blok OK (1 test, 1 assertion) di atas, karena 5000, 12500, maupun 25000 sama-sama lolos assertIsInt. Papan skor lengkap tiga mutan dirangkum berikut:
| Mutan | Perubahan pada kode | Suite kuat | Suite lemah |
|---|---|---|---|
| M1 | < menjadi <= | terbunuh — 2 test gagal | selamat |
| M2 | intdiv(…, 10) menjadi intdiv(…, 20) | terbunuh — 2 test gagal | selamat |
| M3 | : 5000 menjadi : 0 | terbunuh — 1 test gagal | selamat |
Setelah eksperimen selesai, kode asli dipulihkan dan suite lengkap kembali hijau — lima test, lima assertion, seperti baseline awal.
Skor Mutasi #
Hasil papan skor diringkas menjadi satu angka bernama Mutation Score Indicator (MSI): persentase mutan yang terbunuh dari seluruh mutan yang dihasilkan.
MSI = mutan terbunuh / total mutan × 100%
Suite kuat : 3 / 3 × 100% = 100%
Suite lemah: 0 / 3 × 100% = 0%Dari lima test hijau yang tadinya tak terbedakan, kini kita punya angka yang memisahkan keduanya dengan tegas. Perlu diingat bahwa simulasi manual tiga mutan ini hanya ilustrasi; alat mutation testing sungguhan menghasilkan puluhan hingga ratusan mutan dari kode sekecil hitungDiskon(); setiap kombinasi operator, konstanta, nilai balik, dan panggilan method yang bisa diganti akan dicobakan satu per satu.
Infection: Mutasi Otomatis #
Memutoskan berkas secara manual seperti di atas menyenangkan untuk belajar, tetapi tidak menyelesaikan pekerjaan nyata. Di ekosistem PHP, alat standarnya adalah Infection: sekali dijalankan, Infection menghasilkan seluruh mutan, menjalankan suite untuk tiap mutan, lalu melaporkan MSI beserta daftar mutan yang selamat beserta baris kodenya. Instalasinya sebagai dependensi dev lewat Composer:
composer require --dev infection/infectionKonfigurasi minimalnya di infection.json5 membatasi sumber mutasi pada direktori src:
{
"source": {
"directories": ["src"]
},
"mutators": {
"@default": true
}
}Kunci @default mengaktifkan set operator mutasi standar. Eksekusinya kemudian satu perintah, lengkap dengan ambang MSI yang membuat pipeline gagal bila kualitas test menurun:
vendor/bin/infection --min-msi=85Ada satu prasyarat penting: Infection bekerja di atas laporan code coverage untuk memetakan mutan ke test yang relevan, sehingga driver coverage — Xdebug, pcov, atau phpdbg — harus tersedia. Lingkungan statis tempat buku ini ditulis tidak memuat driver tersebut (lihat kembali Chapter code coverage ), sehingga perintah di atas tidak bisa didemonstrasikan langsung di sini; mekanisme mutan-terbunuh versus mutan-selamat yang kita jalankan manual adalah inti kerjanya yang sama.
Mutation Testing versus Coverage #
Kedua metrik ini sering diperbandingkan karena sama-sama menilai suite test, padahal keduanya mengukur hal yang berbeda:
| Aspek | Code coverage | Mutation testing |
|---|---|---|
| Pertanyaan inti | “Apakah baris ini pernah dijalankan test?” | “Apakah test gagal bila baris ini dirusak?” |
| Test tanpa assertion | tetap menaikkan angka | menghasilkan mutan selamat |
| Test yang menegaskan nilai | angka sama seperti test kosong | mutan mati di titik assertion |
| Biaya eksekusi | murah — suite berjalan sekali | mahal — suite berjalan per mutan |
| Posisi ideal di pipeline | setiap push | terjadwal atau sesekali |
Cara membaca keduanya pun berbeda. Coverage adalah alat navigasi: laporannya menunjukkan ke mana test belum menjangkau. Mutation score adalah alat audit: mutan yang selamat menunjukkan jangkauan yang ada tetapi tidak menggigit. Proyek yang sehat memakai coverage setiap hari dan mutation testing secara berkala untuk memastikan jangkauan itu bermakna.
Biaya dan Sikap yang Sehat #
Mahalnya mutation testing berasal dari aritmetika sederhana: seratus mutan berarti hingga seratus kali penjalanan suite. Beberapa praktik menjaga biaya ini tetap rasional: jalankan Infection hanya pada berkas yang berubah dalam sebuah pull request, jadwalkan eksekusi penuh secara berkala (misalnya tiap malam), dan sisihkan sesi khusus saat akan merefaktor kode inti. Terakhir, ingat pelajaran dari suite lemah: mutan mati karena test menegaskan perilaku — nilai yang teramati oleh pemanggil. Test yang hanya memeriksa “method dipanggil” tanpa memeriksa akibatnya, sebagaimana dibahas pada Chapter mocking , membiarkan banyak mutan hidup. Kebiasaan menulis assertion yang berani sejak awal adalah setengah dari skor mutasi yang baik.
Ringkasan #
Mutation testing menguji test itu sendiri: setiap mutan adalah bug kecil yang disuntikkan ke kode, dan suite yang sehat harus membunuhnya semua. Simulasi manual pada hitungDiskon() menunjukkan suite kuat mencapai MSI 100% sementara suite lemah jatuh di 0% — padahal keduanya sama-sama hijau dan sama-sama menaikkan coverage. Skor mutasi dihitung sebagai mutan terbunuh dibagi total mutan, diotomatisasi oleh Infection dengan prasyarat driver coverage, dan diposisikan sebagai audit berkala, bukan pengganti coverage. Sejauh ini seluruh perangkat QA kita bekerja dengan menjalankan kode; Chapter berikutnya membenturkan pendekatan sebaliknya — static analysis yang menemukan bug tanpa menjalankan kode sama sekali.
Referensi #
- Dokumentasi resmi PHPUnit: https://docs.phpunit.de/en/12.x/
- Panduan pengujian PHP the right way: https://www.phptherightway.com/
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
src/HitungDiskon.php
Unduh berkas<?php
declare(strict_types=1);
function hitungDiskon(int $totalBelanja, bool $member): int
{
if ($totalBelanja < 100000) {
return 0;
}
return $member ? intdiv($totalBelanja, 10) : 5000;
}
tests/bootstrap.php
Unduh berkas<?php
declare(strict_types=1);
require __DIR__ . '/../src/HitungDiskon.php';
tests/HitungDiskonLemahTest.php
Unduh berkas<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class HitungDiskonLemahTest extends TestCase
{
public function testHitungDiskonMengembalikanInt(): void
{
$hasil = hitungDiskon(250000, true);
// hanya "menyentuh" kode — tidak ada pengecekan nilai
$this->assertIsInt($hasil);
}
}
tests/HitungDiskonTest.php
Unduh berkas<?php
declare(strict_types=1);
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class HitungDiskonTest extends TestCase
{
#[DataProvider('provideHitungDiskon')]
public function testHitungDiskon(int $belanja, bool $member, int $harapan): void
{
$this->assertSame($harapan, hitungDiskon($belanja, $member));
}
public static function provideHitungDiskon(): array
{
return [
'di bawah ambang' => [50000, false, 0],
'tepat ambang non-member' => [100000, false, 5000],
'tepat ambang member' => [100000, true, 10000],
'jauh di atas ambang member' => [250000, true, 25000],
];
}
}