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 asliOperator mutanBug yang disimulasikan
<<=syarat batas meleset satu nilai
+-operasi aritmetika tertukar
&&||logika menjadi terlalu longgar
1020konstanta salah ketik
return $x;return null;nilai balik hilang
panggilan methoddihapusefek 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
<?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
<?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
<?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:

bash
php /home/z/my-project/bin/phpunit.phar --bootstrap tests/bootstrap.php tests
text
PHPUnit 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:

bash
php /home/z/my-project/bin/phpunit.phar --bootstrap tests/bootstrap.php tests/HitungDiskonTest.php
text
PHPUnit 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)
bash
php /home/z/my-project/bin/phpunit.phar --bootstrap tests/bootstrap.php tests/HitungDiskonLemahTest.php
text
PHPUnit 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
<?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:

bash
php /home/z/my-project/bin/phpunit.phar --bootstrap tests/bootstrap.php tests/HitungDiskonTest.php
text
PHPUnit 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:

bash
php /home/z/my-project/bin/phpunit.phar --bootstrap tests/bootstrap.php tests/HitungDiskonLemahTest.php
text
PHPUnit 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:

bash
php /home/z/my-project/bin/phpunit.phar --bootstrap tests/bootstrap.php tests/HitungDiskonTest.php
text
PHPUnit 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:

text
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:

MutanPerubahan pada kodeSuite kuatSuite lemah
M1< menjadi <=terbunuh — 2 test gagalselamat
M2intdiv(…, 10) menjadi intdiv(…, 20)terbunuh — 2 test gagalselamat
M3: 5000 menjadi : 0terbunuh — 1 test gagalselamat

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.

text
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:

bash
composer require --dev infection/infection

Konfigurasi minimalnya di infection.json5 membatasi sumber mutasi pada direktori src:

json
{
    "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:

bash
vendor/bin/infection --min-msi=85

Ada 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:

AspekCode coverageMutation testing
Pertanyaan inti“Apakah baris ini pernah dijalankan test?”“Apakah test gagal bila baris ini dirusak?”
Test tanpa assertiontetap menaikkan angkamenghasilkan mutan selamat
Test yang menegaskan nilaiangka sama seperti test kosongmutan mati di titik assertion
Biaya eksekusimurah — suite berjalan sekalimahal — suite berjalan per mutan
Posisi ideal di pipelinesetiap pushterjadwal 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 #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

src/HitungDiskon.php

Unduh berkas
php
<?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
<?php

declare(strict_types=1);

require __DIR__ . '/../src/HitungDiskon.php';

tests/HitungDiskonLemahTest.php

Unduh berkas
php
<?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
<?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],
        ];
    }
}