Suite test yang hijau terasa meyakinkan, sampai kita bertanya satu hal: kode mana saja yang benar-benar pernah dijalankan oleh test-test itu? Code coverage menjawabnya dengan mengukur baris, cabang, dan jalur mana yang dilalui saat suite berjalan. Angkanya memikat — mudah dibaca, mudah dipamerkan — dan justru di situlah bahayanya: coverage tinggi sering disalahartikan sebagai bukti kode teruji padahal ia hanya membuktikan kode terjalani. Pada Chapter ini kita bedah apa yang diukur coverage, cara menjalankannya di PHPUnit beserta driver yang dibutuhkan, cara membaca laporannya, dan bagaimana memakainya secara sehat di pipeline CI.

Apa yang Diukur Coverage #

Coverage dihitung dengan instrumentasi: driver menyisipkan pencatat di setiap baris yang bisa dieksekusi, lalu PHPUnit menjalankan suite dan mengumpulkan catatan siapa saja yang terlewati. Tiga metrik yang paling sering muncul mengajukan pertanyaan yang berbeda-beda:

MetrikPertanyaan yang dijawabKelemahan khasnya
Line coverageBaris mana yang pernah dieksekusi?Baris dengan if tanpa else bisa “tercakup” padahal hanya satu jalurnya jalan
Branch coverageSetiap percabangan (if, match, ?:) pernah diambil dari kedua arah?Kombinasi beberapa kondisi dalam satu baris bisa tetap lolos
Path coverageSetiap jalur eksekusi yang mungkin pernah dilalui?Jumlah jalur meledak kombinatorial; jarang jadi target penuh

Ketiganya menurun dalam kegunaan berbanding biaya: line coverage murah dan kasar, branch coverage menangkap lubuk yang lebih dalam, path coverage paling teliti namun paling mahal. Perlu dicatat juga bahwa driver yang dipakai menentukan metrik apa yang tersedia — pcov cepat tetapi hanya mengukur baris, sedangkan Xdebug 3 mampu melaporkan branch dan path.

Seratus Persen Baris, Satu Bug Utuh #

Mari kita rasakan sendiri batas line coverage dengan simulasi kecil. Class Jejak berperan sebagai instrumentasi buatan tangan: setiap kali sebuah bagian fungsi dilalui, penandanya dicatat. Fungsi tarifOngkir() adalah “kode produksi” kita, dan tarifOngkirSalah() adalah versi yang mengandung bug nyata — operator && tertulis ||:

php
<?php

declare(strict_types=1);

// Pelacakan manual: penanda dicatat setiap kali baris dilalui
final class Jejak
{
    public static array $dilalui = [];
}

function tarifOngkir(int $beratGram, bool $ekspres): int
{
    Jejak::$dilalui[] = 'baris fungsi';

    if ($beratGram > 1000 && $ekspres) {
        Jejak::$dilalui[] = 'cabang mahal';

        return 30000;
    }

    Jejak::$dilalui[] = 'cabang hemat';

    return 10000;
}

// Versi dengan bug: && tertulis || — perhatikan bedanya
function tarifOngkirSalah(int $beratGram, bool $ekspres): int
{
    return ($beratGram > 1000 || $ekspres) ? 30000 : 10000;
}

// "Suite" dua test, seperti dua test method
$kasus = [
    [2000, true],
    [500, false],
];

foreach ($kasus as [$berat, $ekspres]) {
    $benar = tarifOngkir($berat, $ekspres);
    $salah = tarifOngkirSalah($berat, $ekspres);

    printf(
        "input (%4d, %5s): asli=%d salah=%d -> %s\n",
        $berat,
        $ekspres ? 'true' : 'false',
        $benar,
        $salah,
        $benar === $salah ? 'keduanya lulus' : 'BUG KETAHUAN',
    );
}

echo "\nLaporan ala coverage:\n";

foreach (['baris fungsi', 'cabang mahal', 'cabang hemat'] as $penanda) {
    printf("  %-14s %s\n", $penanda . ':', in_array($penanda, Jejak::$dilalui, true) ? 'tercakup' : 'bocor');
}

echo "  line coverage: 3 dari 3 penanda tercakup = 100%\n";
echo "  padahal kombinasi (2000, false) tidak pernah dijalankan suite\n";

Output yang Dihasilkan:

text
input (2000,  true): asli=30000 salah=30000 -> keduanya lulus
input ( 500, false): asli=10000 salah=10000 -> keduanya lulus

Laporan ala coverage:
  baris fungsi:  tercakup
  cabang mahal:  tercakup
  cabang hemat:  tercakup
  line coverage: 3 dari 3 penanda tercakup = 100%
  padahal kombinasi (2000, false) tidak pernah dijalankan suite

Baca outputnya perlahan. Dua kasus uji — paket berat dengan ekspres, dan paket ringat tanpa ekspres — membuat ketiga penanda tercatat: baris fungsi, cabang mahal, cabang hemat. Line coverage melaporkan 100%. Namun versi yang salah, yang memberi tarif 30000 untuk semua paket berat atau semua paket ekspres, lolos dari kedua test: pada kedua input, hasil salah kebetulan sama dengan hasil asli. Kombinasi (berat besar, tanpa ekspres) — satu-satunya input yang membedakan && dari || — tidak pernah dijalankan suite. Inilah kelemahan mendasar coverage: ia mencatat kode berjalan, bukan perilaku diverifikasi.

Perbaikannya sederhana dan justru itulah gunanya laporan coverage: tambahkan kasus (2000, false) ke data provider — persis seperti yang dilakukan suite pada bundle Chapter ini (berkas tests/TarifOngkirTest.php di bawah halaman memuat tiga kasus). Branch coverage akan menandai lubuk ini lebih awal, karena cabang && $ekspres yang bernilai false pada $beratGram > 1000 tidak pernah diambil oleh suite dua kasus tadi.

Menjalankan Coverage di PHPUnit #

PHPUnit tidak menghitung coverage sendiri; ia bergantung pada driver yang memasang instrumentasi. Ada tiga pilihan di ekosistem PHP: ekstensi Xdebug (paling kaya fitur, termasuk branch dan path), ekstensi pcov (paling cepat, baris saja), dan phpdbg (SAPI bawaan distribusi PHP yang bisa dipakai tanpa ekstensi tambahan). Sebelum menyalakan coverage, pasti perlu tahu driver mana yang hadir di lingkungan kita:

php
<?php

declare(strict_types=1);

foreach (['xdebug', 'pcov', 'phpdbg'] as $driver) {
    $tersedia = match ($driver) {
        'xdebug' => extension_loaded('xdebug'),
        'pcov' => extension_loaded('pcov'),
        default => PHP_SAPI === 'phpdbg',
    };

    printf("%-8s %s\n", $driver . ':', $tersedia ? 'tersedia' : 'tidak tersedia');
}

Output yang Dihasilkan:

text
xdebug:  tidak tersedia
pcov:    tidak tersedia
phpdbg:  tidak tersedia

Pada lingkungan tempat chapter ini ditulis (PHP 8.4.23 statis tanpa ekstensi tambahan), ketiganya absen — dan PHPUnit akan menolaknya secara jujur. Subjek ujinya adalah fungsi tarifOngkir() yang sama, kini tersimpan di src/TarifOngkir.php dengan suite tiga kasus di tests/TarifOngkirTest.php. Konfigurasi phpunit.xml berikut membatasi pengukuran hanya pada direktori src — tanpa batasan ini, berkas test sendiri ikut terhitung dan angkanya menipu:

xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         bootstrap="tests/bootstrap.php">
    <source>
        <include>
            <directory>src</directory>
        </include>
    </source>
</phpunit>

Tanpa coverage, suite berjalan normal:

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

...                                                                 3 / 3 (100%)

Time: 00:00.001, Memory: 28.05 MB

OK (3 tests, 3 assertions)

Begitu flag coverage ditambahkan, ketergantungan pada driver langsung terlihat:

bash
php /home/z/my-project/bin/phpunit.phar --bootstrap tests/bootstrap.php tests --coverage-text
text
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.23

There was 1 PHPUnit test runner warning:

1) No code coverage driver available

No tests executed!

Itu output nyata dari lingkungan ini: PHPUnit memperingatkan tidak ada driver dan memilih tidak menjalankan apa pun daripada melaporkan coverage kosong yang menyesatkan. Solusinya ada tiga: pasang pcov untuk line coverage yang cepat di CI, pasang xdebug (versi 3) bila butuh branch dan path, atau gunakan phpdbg yang biasanya sudah menyertai instalasi PHP — polanya phpdbg -qrr vendor/bin/phpunit --coverage-text. Ketiganya tidak tersedia pada build PHP statis ini, sehingga angka-angka pada bagian berikut kita baca dari struktur laporan, bukan dari eksekusi di sini.

Membaca Laporan dan Ambang Batas CI #

PHPUnit menawarkan beberapa format sekaligus: --coverage-text mencetak ringkasan ke terminal (persentase per berkas dan per class, plus daftar baris yang tidak tercakup), --coverage-html menghasilkan laporan web yang mewarnai baris hijau-merah dan bisa diklik per berkas, sedangkan --coverage-clover menghasilkan XML — format yang dimengerti alat CI seperti SonarQube dan CodeClimate. Format clover inilah yang biasanya dipakai sebagai bahan ambang batas: skrip kecil memproses XML, menghitung persentasenya, dan menggagalkan pipeline bila di bawah ambang.

Berikut inti mesinnya — memparsing clover, menjumlahkan atribut count tiap baris, lalu membandingkan hasilnya dengan ambang:

php
<?php

declare(strict_types=1);

// Cuplikan clover.xml seperti hasil --coverage-clover (disederhanakan)
$clover = <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<coverage generated="0">
  <project timestamp="0">
    <file name="src/TarifOngkir.php">
      <line num="6" type="stmt" count="1"/>
      <line num="7" type="stmt" count="0"/>
      <line num="8" type="stmt" count="0"/>
      <line num="11" type="stmt" count="1"/>
    </file>
  </project>
</coverage>
XML;

$xml = simplexml_load_string($clover);
$jumlahBaris = [];

foreach ($xml->project->file->line as $baris) {
    $jumlahBaris[] = (int) $baris['count'];
}

$total = count($jumlahBaris);
$tercakup = count(array_filter($jumlahBaris, static fn (int $c): bool => $c > 0));
$persen = round($tercakup / $total * 100, 2);

printf("Baris tercakup: %d dari %d (%.2f%%)\n", $tercakup, $total, $persen);

$ambang = 80.0;

echo 'Ambang batas ', $ambang, '%: ', $persen >= $ambang ? 'LULUS' : 'CI MERAH', "\n";

Output yang Dihasilkan:

text
Baris tercakup: 2 dari 4 (50.00%)
Ambang batas 80%: CI MERAH

Setiap elemen <line> membawa atribut count — berapa kali baris itu dieksekusi selama suite berjalan; count="0" berarti tidak pernah. Dari empat baris pada cuplikan di atas hanya dua yang tercakup (50%), pipeline pun merah. Dua hal membuat ambang seperti ini sehat: pertama, posisinya sebagai lantai yang tidak boleh turun, bukan langit-langit yang harus dicapai — saat coverage turun karena ada fitur baru tanpa test, CI memaksa kita sadar; kedua, angkanya realistis. Banyak tim sehat bekerja di kisaran 70–90% untuk line coverage, menyisakan ruang bagi kode yang memang tidak layak diuji (bootstrap, definisi konstanta, deklarasi DTO polos).

Mitos Seratus Persen #

Mengejar 100% coverage sebagai target tim hampir selalu berakhir buruk, karena cara termurah menaikkan angka itu bukan menulis test baik melainkan menulis test kosong: panggil fungsinya, jangan tegaskan apa pun, baris pun “tercakup”. Suite semacam itu hijau di dashboard dan mati saat bug datang — persis seperti simulasi tarifOngkirSalah() di awal Chapter. Kode yang mencapai 100% line coverage pun belum tentu 100% branch coverage, dan keduanya tetap diam tentang kualitas assertion.

Sikap yang sehat memperlakukan coverage sebagai alarm asap: penurunan mendadak menandakan ada perubahan yang luput dari test, dan area yang konsisten tak tercakup menandakan utang pengujian yang menumpuk. Coverage menunjukkan ke mana test belum menjangkau; kekuatan test setelah menjangkau di sana diukur dengan cara lain. Teknik pengukurannya bernama mutation testing, dan itulah Chapter berikutnya.

Ringkasan #

Code coverage mengukur baris, cabang, dan jalur yang dilalui suite test — dijawab oleh driver (Xdebug, pcov, atau phpdbg) yang menempelkan instrumentasi pada kode. Line coverage bisa membaca 100% sementara bug operator bertahan hidup, karena coverage mencatat eksekusi, bukan verifikasi. Laporan text, HTML, dan clover masing-masing punya peran; clover menjadi bahan ambang batas CI yang sehat diposisikan sebagai lantai, bukan target. Dengan coverage sebagai peta lubuk, langkah logis berikutnya adalah menguji apakah test kita benar-benar membunuh bug: mutation testing.

Referensi #

Berkas Sumber

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

src/TarifOngkir.php

Unduh berkas
php
<?php

declare(strict_types=1);

function tarifOngkir(int $beratGram, bool $ekspres): int
{
    if ($beratGram > 1000 && $ekspres) {
        return 30000;
    }

    return 10000;
}

tests/bootstrap.php

Unduh berkas
php
<?php

declare(strict_types=1);

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

tests/TarifOngkirTest.php

Unduh berkas
php
<?php

declare(strict_types=1);

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class TarifOngkirTest extends TestCase
{
    #[DataProvider('provideTarif')]
    public function testTarif(int $berat, bool $ekspres, int $harapan): void
    {
        $this->assertSame($harapan, tarifOngkir($berat, $ekspres));
    }

    public static function provideTarif(): array
    {
        return [
            'berat besar ekspres' => [2000, true, 30000],
            'berat kecil reguler' => [500, false, 10000],
            'berat besar reguler' => [2000, false, 10000],
        ];
    }
}