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.
| Aspek | Stub | Mock |
|---|---|---|
| Dibuat dengan | createStub() | createMock() |
| Pertanyaan yang dijawab | “Nilai baliknya apa?” | “Apakah dipanggil dengan benar?” |
| API utama | method(), willReturn...() | expects(), with() |
| Cocok untuk | menyiapkan input terkontrol | memverifikasi 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
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
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:
- Constructor menerima
MailerInterfacelewat constructor promotion, sehingga dependensi selalu disuntikkan dari luar — inilah syarat utama agar mock bisa dipasang. - Guard clause
$total <= 0.0mengembalikanfalsetanpa mengirim email apa pun untuk invoice kosong. - Panggilan
send()mendelegasikan pengiriman ke mailer, lalu nilai baliknya menjadi nilai baliksettle()— keberhasilanInvoicesepenuhnya ditentukan keberhasilan mailer.
Karena contoh ini tidak memakai Composer autoload, kita siapkan bootstrap.php yang memuat semua berkas src/ sebelum test berjalan:
<?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
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:
createMock(MailerInterface::class)menghasilkan objek anonim yang mengimplementasi interface tersebut — PHPUnit menulis class turunannya di memori secara otomatis.expects($this->once())menyatakan kontrak interaksi:send()harus dipanggil tepat satu kali; bila tidak, test gagal.method('send')menentukan method mana yang diberi kontrak tersebut.with(...)membatasi argumen yang sah: penerima harus[email protected], subjek harus persisInvoice Pesanan Anda, dan isi email harus memuat teksRp150.000.willReturn(true)mengatur stub sekaligus: ketika dipanggil dengan benar, kembalikantrue.
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():
| Aturan | Makna |
|---|---|
$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
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:
php /home/z/my-project/bin/phpunit.phar --bootstrap bootstrap.php tests-failBerikut output nyata dari perintah tersebut:
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.
| Constraint | Cocok 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
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
declare(strict_types=1);
interface ShippingTariffInterface
{
public function ongkir(string $kota): int;
}<?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
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
declare(strict_types=1);
interface PaymentGatewayInterface
{
public function charge(string $nomorKartu, int $total): bool;
}<?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
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
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
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:
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.phpSeluruh suite dijalankan dengan satu perintah yang menunjuk direktori tests:
php /home/z/my-project/bin/phpunit.phar --bootstrap bootstrap.php testsBerikut output nyata dari perintah tersebut:
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
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
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');
}
}php /home/z/my-project/bin/phpunit.phar --bootstrap bootstrap.php testsPHPUnit 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
OngkirCalculatortidak 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 sepuluhwith()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 #
- Dokumentasi resmi PHPUnit tentang test doubles: https://docs.phpunit.de/en/12.5/test-doubles.html
- Mengorganisasi test ke dalam suite dan direktori: https://docs.phpunit.de/en/12.5/organizing-tests.html
- Kata kunci
finaldan pewarisan class di PHP: https://www.php.net/manual/en/language.oop5.final.php