Pada Chapter test doubles kita membuat fake secara manual: menulis class tiruan yang mengimplementasi interface yang sama. Cara itu transparan dan mudah dibaca, tetapi menjadi repetitif ketika proyek tumbuh dan jumlah dependensi bertambah. PHPUnit menyertakan mocking framework yang menghasilkan test double secara otomatis saat test berjalan, sehingga kita hanya perlu mendeskripsikan perilaku yang diinginkan. Pada Chapter ini kita akan memakai createMock() dan createStub(), menetapkan expectation pada interaksi, membatasi argumen dengan constraint, sampai mengenali situasi ketika mocking justru membuat test menjadi rapuh.

Dua Peran Test Double: Stub dan Mock #

Sebelum menyentuh API PHPUnit, kita luruskan dulu dua istilah yang sering tertukar. Stub adalah test double yang memberi jawaban terkontrol: kita atur nilai baliknya, lalu sistem yang diuji mengonsumsi jawaban tersebut. Mock adalah test double yang merekam interaksi: kita nyatakan panggilan mana yang harus (atau tidak boleh) terjadi, dan PHPUnit memverifikasinya setelah test selesai. PHPUnit 12 menyediakan dua factory terpisah untuk kedua peran itu.

AspekStubMock
Dibuat dengancreateStub()createMock()
Pertanyaan yang dijawab“Nilai baliknya apa?”“Apakah dipanggil dengan benar?”
API utamamethod(), willReturn...()expects(), with()
Cocok untukmenyiapkan input terkontrolmemverifikasi efek samping

Aturan praktisnya sederhana: gunakan stub ketika sistem yang diuji hanya membaca dari dependensi, dan gunakan mock ketika perilaku yang benar justru terletak pada cara sistem memanggil dependensi. Versi fake tulisan tangan dari keduanya sudah kita bahas pada Chapter test doubles ; Chapter ini berfokus pada versi yang dihasilkan otomatis oleh PHPUnit.

Kelas yang Akan Diuji #

Kita akan memakai satu studi kasus kecil sepanjang Chapter: Invoice yang mengirim email tagihan melalui MailerInterface. Interface inilah seam (titik sambung yang bisa diganti) yang memungkinkan kita menyingkirkan ketergantungan pada SMTP sungguhan selama unit test berjalan.

php
<?php

declare(strict_types=1);

interface MailerInterface
{
    public function send(string $kepada, string $subjek, string $isi): bool;
}

Implementasi nyata MailerInterface akan bicara ke server SMTP, tetapi untuk unit test kita hanya butuh objek apa pun yang memenuhi kontrak send() tersebut. Sekarang mari kita lihat sistem yang diuji:

php
<?php

declare(strict_types=1);

final class Invoice
{
    public function __construct(
        private readonly MailerInterface $mailer,
    ) {
    }

    public function settle(string $emailPelanggan, float $total): bool
    {
        if ($total <= 0.0) {
            return false;
        }

        return $this->mailer->send(
            $emailPelanggan,
            'Invoice Pesanan Anda',
            'Total tagihan: Rp' . number_format($total, 0, ',', '.'),
        );
    }
}

Mari kita bedah perilaku Invoice baris per baris:

  1. Constructor menerima MailerInterface lewat constructor promotion, sehingga dependensi selalu disuntikkan dari luar — inilah syarat utama agar mock bisa dipasang.
  2. Guard clause $total <= 0.0 mengembalikan false tanpa mengirim email apa pun untuk invoice kosong.
  3. Panggilan send() mendelegasikan pengiriman ke mailer, lalu nilai baliknya menjadi nilai balik settle() — keberhasilan Invoice sepenuhnya ditentukan keberhasilan mailer.

Karena contoh ini tidak memakai Composer autoload, kita siapkan bootstrap.php yang memuat semua berkas src/ sebelum test berjalan:

php
<?php

declare(strict_types=1);

require __DIR__ . '/src/MailerInterface.php';
require __DIR__ . '/src/Invoice.php';
require __DIR__ . '/src/ShippingTariffInterface.php';
require __DIR__ . '/src/OngkirCalculator.php';
require __DIR__ . '/src/PaymentGatewayInterface.php';
require __DIR__ . '/src/PaymentRetry.php';
require __DIR__ . '/src/GreetingService.php';

Pada proyek nyata, isi bootstrap.php ini biasanya cukup satu baris require __DIR__ . '/vendor/autoload.php';. Kita tetap memakai require eksplisit agar contoh Chapter ini bisa dijalankan tanpa instalasi apa pun.

Mock Pertama dengan createMock #

Berikut test lengkap untuk Invoice yang memadukan mock (verifikasi interaksi) dan stub (jawaban terkontrol):

php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class InvoiceTest extends TestCase
{
    public function testSettleMengirimInvoiceMelaluiMailer(): void
    {
        $mailer = $this->createMock(MailerInterface::class);

        $mailer->expects($this->once())
            ->method('send')
            ->with(
                $this->equalTo('[email protected]'),
                $this->equalTo('Invoice Pesanan Anda'),
                $this->stringContains('Rp150.000'),
            )
            ->willReturn(true);

        $invoice = new Invoice($mailer);

        $this->assertTrue($invoice->settle('[email protected]', 150000.0));
    }

    public function testInvoiceKosongTidakMengirimEmail(): void
    {
        $mailer = $this->createMock(MailerInterface::class);
        $mailer->expects($this->never())->method('send');

        $invoice = new Invoice($mailer);

        $this->assertFalse($invoice->settle('[email protected]', 0.0));
    }

    public function testSettleMelaporkanKegagalanPengiriman(): void
    {
        $mailer = $this->createStub(MailerInterface::class);
        $mailer->method('send')->willReturn(false);

        $invoice = new Invoice($mailer);

        $this->assertFalse($invoice->settle('[email protected]', 150000.0));
    }
}

Rangkaian konfigurasi pada test pertama selalu berpola sama dan penting dibaca berurutan:

  1. createMock(MailerInterface::class) menghasilkan objek anonim yang mengimplementasi interface tersebut — PHPUnit menulis class turunannya di memori secara otomatis.
  2. expects($this->once()) menyatakan kontrak interaksi: send() harus dipanggil tepat satu kali; bila tidak, test gagal.
  3. method('send') menentukan method mana yang diberi kontrak tersebut.
  4. with(...) membatasi argumen yang sah: penerima harus [email protected], subjek harus persis Invoice Pesanan Anda, dan isi email harus memuat teks Rp150.000.
  5. willReturn(true) mengatur stub sekaligus: ketika dipanggil dengan benar, kembalikan true.

Test kedua memakai $this->never() untuk memastikan guard clause benar-benar memblokir pengiriman email untuk invoice kosong. Test ketiga adalah kontras yang disengaja: createStub() membuat test double tanpa kontrak interaksi, sehingga kita hanya mengatur willReturn(false) dan membiarkan PHPUnit memverifikasi bahwa kegagalan mailer diteruskan ke pemanggil.

Menetapkan Expectation Interaksi #

Expectation adalah inti yang membedakan mock dari stub. PHPUnit menyediakan beberapa aturan jumlah pemanggilan yang bisa dikombinasikan dengan expects():

AturanMakna
$this->once()harus dipanggil tepat satu kali
$this->never()tidak boleh dipanggil sama sekali
$this->exactly(n)harus dipanggil tepat n kali
$this->any()jumlah pemanggilan bebas

Expectation dievaluasi setelah body test selesai, jadi kegagalan kontrak baru terlihat di akhir test. Untuk merasakannya, bayangkan seorang developer salah paham requirement dan menulis test berikut:

php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class InvoiceFailTest extends TestCase
{
    public function testInvoiceKosongMasihMengirimEmail(): void
    {
        $mailer = $this->createMock(MailerInterface::class);

        $mailer->expects($this->once())
            ->method('send')
            ->willReturn(true);

        $invoice = new Invoice($mailer);

        // Bug yang disimulasikan: developer mengira invoice kosong
        // tetap dikirim, padahal settle() memblokirnya
        $invoice->settle('[email protected]', 0.0);

        $this->addToAssertionCount(1);
    }
}

Test di atas memanggil settle() dengan total 0.0, sehingga guard clause mengembalikan false tanpa pernah menyentuh send(). Mari kita jalankan dan lihat PHPUnit menegakkan kontraknya:

bash
php /home/z/my-project/bin/phpunit.phar --bootstrap bootstrap.php tests-fail

Berikut output nyata dari perintah tersebut:

text
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.23

F                                                                   1 / 1 (100%)

Time: 00:00.001, Memory: 28.05 MB

There was 1 failure:

1) InvoiceFailTest::testInvoiceKosongMasihMengirimEmail
Expectation failed for method name is "send" when invoked 1 time.
Method was expected to be called 1 time, actually called 0 times.

FAILURES!
Tests: 1, Assertions: 2, Failures: 1.

Baris Method was expected to be called 1 time, actually called 0 times memberi tahu dua hal sekaligus: kontrak apa yang dilanggar dan seberapa jauh pelanggarannya. Huruf F pada progress bar dan exit code 1 menandakan suite gagal, sehingga pipeline CI akan ikut merah. Inilah nilai utama mock: bug “lupa memanggil dependensi” yang biasanya diam-diam berhasil kini tertangkap secara otomatis.

Membatasi Argumen dengan Constraint #

Bagian with() menerima daftar constraint yang dicek berurutan terhadap argumen yang lewat. Melewatkan nilai mentah seperti '[email protected]' secara implisit berarti equalTo, sehingga kedua bentuk berikut setara: with('[email protected]') sama dengan with($this->equalTo('[email protected]')). Constraint eksplisit berguna ketika kita butuh kecocokan yang lebih longgar daripada kesamaan persis.

ConstraintCocok ketika nilai
equalTo($x)sama dengan $x (perbandingan longgar ==)
identicalTo($x)identik dengan $x (perbandingan ketat ===)
stringContains($s)string yang memuat $s
stringStartsWith($s) / stringEndsWith($s)string berawalan/berakhiran $s
anything()nilai apa pun
callback(fn)nilai yang lolos pemeriksaan kustom

Constraint paling fleksibel adalah callback, yang menerima closure pemeriksa kita sendiri. Test berikut memverifikasi isi email tanpa harus menyalin seluruh teksnya secara harfiah:

php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class InvoiceConstraintTest extends TestCase
{
    public function testIsiEmailMemakaiFormatRupiah(): void
    {
        $mailer = $this->createMock(MailerInterface::class);

        $mailer->expects($this->once())
            ->method('send')
            ->with(
                '[email protected]',
                $this->anything(),
                $this->callback(
                    static fn (string $isi): bool => str_starts_with($isi, 'Total tagihan:')
                        && str_contains($isi, 'Rp150.000'),
                ),
            )
            ->willReturn(true);

        $invoice = new Invoice($mailer);

        $this->assertTrue($invoice->settle('[email protected]', 150000.0));
    }
}

Closure pada callback menerima satu argumen dan mengembalikan bool, di sini memeriksa bahwa isi email dibuka dengan Total tagihan: dan memuat hasil number_format() untuk 150000.0. Ketahanannya ada pada keseimbangan: constraint terlalu ketat membuat test pecah karena perubahan kosmetik, sedangkan anything() di semua posisi sama sekali tidak memverifikasi apa pun. Batasi callback untuk kasus yang benar-benar butuh logika, dan jangan menaruh assertion assertSame di dalam closure karena kegagalannya akan tampak sebagai exception tanpa konteks.

Mengatur Nilai Balik yang Bervariasi #

willReturn() hanya mengembalikan satu nilai tetap untuk setiap panggilan. Untuk skenario yang lebih kaya, PHPUnit menyediakan beberapa varian pengatur nilai balik. Kita mulai dari willReturnMap(), yang memetakan kombinasi argumen masukan ke nilai keluaran:

php
<?php

declare(strict_types=1);

interface ShippingTariffInterface
{
    public function ongkir(string $kota): int;
}
php
<?php

declare(strict_types=1);

final class OngkirCalculator
{
    private const ASURANSI_PERSEN = 0.01;

    public function __construct(
        private readonly ShippingTariffInterface $tarif,
    ) {
    }

    public function totalDenganAsuransi(string $kota, float $nilaiBarang): int
    {
        $asuransi = (int) ceil($nilaiBarang * self::ASURANSI_PERSEN);

        return $this->tarif->ongkir($kota) + $asuransi;
    }
}
php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class OngkirCalculatorTest extends TestCase
{
    public function testTotalSesuaiTarifKotaDitambahAsuransi(): void
    {
        $tarif = $this->createStub(ShippingTariffInterface::class);
        $tarif->method('ongkir')->willReturnMap([
            ['Bandung', 15000],
            ['Surabaya', 25000],
        ]);

        $kalkulator = new OngkirCalculator($tarif);

        $this->assertSame(17000, $kalkulator->totalDenganAsuransi('Bandung', 200000.0));
        $this->assertSame(26000, $kalkulator->totalDenganAsuransi('Surabaya', 100000.0));
    }
}

Setiap baris pada map berisi argumen masukan diikuti nilai balik: panggilan ongkir('Bandung') menghasilkan 15000, dan ongkir('Surabaya') menghasilkan 25000. Dengan satu stub kita bisa menguji beberapa kota sekaligus tanpa menulis class fake yang menyimpan tabel tarif sendiri.

Skenario berikutnya adalah nilai balik yang berubah setiap pemanggilan — khas proses retry. Method willReturnOnConsecutiveCalls() mengatur jawaban berurutan untuk pemanggilan pertama, kedua, dan seterusnya:

php
<?php

declare(strict_types=1);

interface PaymentGatewayInterface
{
    public function charge(string $nomorKartu, int $total): bool;
}
php
<?php

declare(strict_types=1);

final class PaymentRetry
{
    public function __construct(
        private readonly PaymentGatewayInterface $gateway,
    ) {
    }

    public function bayarDenganRetry(string $nomorKartu, int $total, int $maksPercobaan): bool
    {
        for ($percobaan = 1; $percobaan <= $maksPercobaan; $percobaan++) {
            if ($this->gateway->charge($nomorKartu, $total)) {
                return true;
            }
        }

        return false;
    }
}
php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class PaymentRetryTest extends TestCase
{
    public function testPercobaanKeduaBerhasilSetelahGagal(): void
    {
        $gateway = $this->createMock(PaymentGatewayInterface::class);

        $gateway->expects($this->exactly(2))
            ->method('charge')
            ->with('4111111111111111', 95000)
            ->willReturnOnConsecutiveCalls(false, true);

        $pembayaran = new PaymentRetry($gateway);

        $this->assertTrue(
            $pembayaran->bayarDenganRetry('4111111111111111', 95000, 3),
        );
    }
}

Kombinasi expects($this->exactly(2)) dan willReturnOnConsecutiveCalls(false, true) di atas menghasilkan simulasi yang sangat realistis: gateway menolak percobaan pertama, menerima percobaan kedua, dan test memastikan bayarDenganRetry() berhenti tepat setelah keberhasilan — bukan menghabiskan tiga percobaan. Variasi lain yang sejenis adalah willReturnCallback(), yang menerima closure sehingga nilai balik bisa dihitung dari argumen masukan.

Partial Mock untuk Method yang Sulit Dikendalikan #

Kadang ketidakdeterminisan justru berada di dalam class itu sendiri, misalnya pembacaan jam saat ini. Partial mock menyelesaikannya dengan hanya mengganti sebagian method, sementara sisanya tetap menjalankan kode asli:

php
<?php

declare(strict_types=1);

class GreetingService
{
    public function greet(string $nama): string
    {
        if ($this->currentHour() < 12) {
            return "Selamat pagi, {$nama}";
        }

        return "Selamat siang, {$nama}";
    }

    protected function currentHour(): int
    {
        return (int) date('G');
    }
}
php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class GreetingServiceTest extends TestCase
{
    public function testMenyapaPagiSebelumJamDuaBelas(): void
    {
        $service = $this->createPartialMock(GreetingService::class, ['currentHour']);
        $service->expects($this->once())
            ->method('currentHour')
            ->willReturn(9);

        $this->assertSame('Selamat pagi, Budi', $service->greet('Budi'));
    }

    public function testMenyapaSiangMulaiJamDuaBelas(): void
    {
        $service = $this->createPartialMock(GreetingService::class, ['currentHour']);
        $service->expects($this->once())
            ->method('currentHour')
            ->willReturn(13);

        $this->assertSame('Selamat siang, Siti', $service->greet('Siti'));
    }
}

Argumen kedua createPartialMock() adalah daftar nama method yang diganti; di sini hanya currentHour(), sehingga greet() tetap menjalankan logika aslinya termasuk percabangannya. Kedua test kini deterministik: jam 9 selalu menghasilkan sapaan pagi dan jam 13 selalu sapaan siang, terlepas dari waktu nyata saat suite dijalankan. Perhatikan juga bahwa currentHour() harus protected — bukan private atau final — agar PHPUnit bisa menimpanya di class turunan yang dibuatnya.

Menjalankan Seluruh Suite #

Setelah semua berkas tersusun, struktur direktori proyek contoh kita tampak seperti ini:

text
mocking/
├── bootstrap.php
├── src/
│   ├── GreetingService.php
│   ├── Invoice.php
│   ├── MailerInterface.php
│   ├── OngkirCalculator.php
│   ├── PaymentGatewayInterface.php
│   ├── PaymentRetry.php
│   └── ShippingTariffInterface.php
└── tests/
    ├── GreetingServiceTest.php
    ├── InvoiceTest.php
    ├── OngkirCalculatorTest.php
    └── PaymentRetryTest.php

Seluruh suite dijalankan dengan satu perintah yang menunjuk direktori tests:

bash
php /home/z/my-project/bin/phpunit.phar --bootstrap bootstrap.php tests

Berikut output nyata dari perintah tersebut:

text
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.23

........                                                            8 / 8 (100%)

Time: 00:00.004, Memory: 28.05 MB

OK (8 tests, 25 assertions)

Delapan titik pada progress bar mewakili delapan test yang semuanya lulus, dan angka 25 assertions menghitung seluruh assertSame/assertTrue ditambah setiap expectation yang terverifikasi. Perintah ini mengembalikan exit code 0, sinyal hijau yang nantinya dipakai pipeline CI untuk memutuskan apakah build boleh lanjut.

Keterbatasan Kelas dan Method Final #

Seluruh test double PHPUnit dibuat dengan meng-generate class turunan di memori, lalu menimpa method yang kita konfigurasi. Konsekuensinya: class atau method yang dideklarasikan final tidak bisa diduplikasi, karena final memang melarang pewarisan. Mari kita buktikan dengan class berikut:

php
<?php

declare(strict_types=1);

final class AuditLogger
{
    public function log(string $pesan): void
    {
        file_put_contents('/var/log/audit.log', $pesan . "\n", FILE_APPEND);
    }
}
php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class AuditLoggerTest extends TestCase
{
    public function testMencatatTransaksi(): void
    {
        $logger = $this->createMock(AuditLogger::class);

        $logger->expects($this->once())
            ->method('log')
            ->with('transaksi selesai');

        $logger->log('transaksi selesai');
    }
}
bash
php /home/z/my-project/bin/phpunit.phar --bootstrap bootstrap.php tests
text
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.23

E                                                                   1 / 1 (100%)

Time: 00:00.001, Memory: 28.05 MB

There was 1 error:

1) AuditLoggerTest::testMencatatTransaksi
PHPUnit\Framework\MockObject\Generator\ClassIsFinalException: Class "AuditLogger" is declared "final" and cannot be doubled

/tmp/a96/mock-final/tests/AuditLoggerTest.php:11

ERRORS!
Tests: 1, Assertions: 0, Errors: 1.

PHPUnit melempar ClassIsFinalException begitu createMock() dipanggil, dengan pesan yang sangat jelas: class final tidak bisa diduplikasi. Jalan keluarnya bukan menghapus final dari class pihak ketiga yang memang dikunci, melainkan menyelipkan interface milik kita sendiri di antara kode produksi dan class final tersebut — pola wrapper yang sudah kita praktikkan di Chapter test doubles .

Kapan Mocking Justru Merugikan #

Mocking adalah alat yang mudah disalahgunakan. Gejala klasiknya adalah test yang terikat implementasi: setiap panggilan internal dibungkus expects($this->once())->with(...), sehingga refactoring yang tidak mengubah perilaku — misalnya menggabungkan dua panggilan menjadi satu — memecahkan puluhan test sekaligus. Ketika mengubah isi email saja membuat test merah, padahal pengguna tidak pernah melihat perbedaannya, suite tersebut melindungi struktur kode, bukan perilakunya.

Beberapa panduan berikut menjaga mocking tetap sehat:

  • Mock hanya di batas arsitektur — jaringan, SMTP, payment gateway, jam. Logika murni seperti OngkirCalculator tidak butuh mock apa pun.
  • Utamakan stub daripada mock — atur nilai balik sebanyak yang perlu, pasang expectation sesedikit mungkin; verifikasi interaksi hanya ketika interaksi itu memang perilaku yang diuji.
  • Ekspetasi secukupnya — expects($this->once()) pada satu method kunci lebih berharga daripada sepuluh with() yang menyalin seluruh argumen.
  • Pertimbangkan fake tulisan tangan untuk dependensi yang punya state kompleks; fake berbasis array sering lebih jujur daripada rangkaian willReturnOnConsecutiveCalls() yang panjang (lihat kembali test doubles ).

Ringkasan #

createMock() dan createStub() menghasilkan test double otomatis: stub menyuplai nilai balik terkontrol lewat willReturn(), willReturnMap(), dan willReturnOnConsecutiveCalls(), sedangkan mock memverifikasi interaksi lewat expects() dan constraint with(). Partial mock menangani method yang memang sulit dikendalikan seperti pembacaan jam, dengan catatan class tidak boleh final — batasan mendasar karena PHPUnit bekerja lewat pewarisan. Kunci kesehatannya adalah menahan diri: expectation secukupnya, hanya di batas sistem. Namun unit test yang seluruh dependensinya dimock hanya membuktikan potongan-potongan bekerja sendiri-sendiri; Chapter berikutnya kita mulai menggabungkan potongan-potongan itu dengan dependensi sungguhan lewat integration testing.

Referensi #