Unit test yang baik menguji satu potongan kode dalam isolasi, seperti yang kita praktikkan pada Chapter unit testing . Persoalannya, kode sungguhan jarang berdiri sendiri: layanan pemberitahuan butuh gateway SMS, penyimpanan pesanan butuh database, dan perhitungan token butuh jam saat ini. Menyentuh dependensi nyata tersebut di dalam unit test membuat test menjadi lambat, butuh jaringan, dan hasilnya berubah-ubah bergantung kondisi server. Test double menyelesaikan masalah ini dengan mengganti dependensi nyata menggunakan objek tiruan yang perilakunya kita kendalikan penuh.
Lima Jenis Test Double #
Istilah test double dipopulerkan oleh Gerard Meszaros dalam buku xUnit Test Patterns, terinspirasi stunt double di dunia film. Istilah ini adalah payung untuk lima jenis pengganti yang jawabannya berbeda-beda. Tabel berikut merangkum taksonomi lengkapnya:
| Jenis | Pertanyaan yang dijawab | Perilakunya |
|---|---|---|
| Dummy | Tidak menjawab apa pun | Hanya mengisi parameter yang disyaratkan, tidak pernah dipakai |
| Stub | “Apa jawaban dependensi ini?” | Mengembalikan nilai kaleng yang sudah kita atur |
| Spy | “Bagaimana dependensi ini dipanggil?” | Merekam setiap panggilan agar bisa diperiksa setelah test |
| Fake | “Bagaimana versi ringkas yang benar-benar bekerja?” | Implementasi sungguhan yang disederhanakan (misalnya array menggantikan database) |
| Mock | “Interaksi apa yang harus terjadi?” | Objek berkontrak yang gagal bila kontrak interaksinya dilanggar |
Empat jenis pertama bisa kita tulis dengan tangan sebagai class biasa, dan itulah fokus Chapter ini. Jenis kelima, mock, biasanya dihasilkan otomatis oleh mocking framework bawaan PHPUnit — pembahasannya kita simpan untuk Chapter mocking .
Interface sebagai Titik Sambung #
Test double hanya bisa dipasang bila dependensi bisa diganti tanpa mengubah sistem yang diuji. Syarat itu terpenuhi ketika sistem bergantung pada interface, bukan pada class konkret — titik sambung semacam ini sering disebut seam. Mari kita mulai dari gateway SMS sebagai dependensi eksternal kita:
<?php
declare(strict_types=1);
interface SmsGatewayInterface
{
public function kirim(string $nomor, string $isi): bool;
}Di produksi, implementasi nyatanya membungkus SDK pihak ketiga. SDK semacam itu hampir selalu final dan sepenuhnya di luar kendali kita, seperti simulasi berikut:
<?php
declare(strict_types=1);
// Simulasi SDK pihak ketiga: class final, kode di luar kendali kita
final class VendorSmsApi
{
public function dispatch(string $to, string $body): bool
{
// di dunia nyata: HTTP request ke server vendor
return str_starts_with($to, '08');
}
}Karena final melarang pewarisan, class vendor tidak bisa diduplikasi oleh alat test apa pun. Jalan keluarnya adalah adapter milik kita sendiri yang menerjemahkan kontrak SmsGatewayInterface ke panggilan SDK:
<?php
declare(strict_types=1);
final class VendorSmsAdapter implements SmsGatewayInterface
{
public function __construct(
private readonly VendorSmsApi $api,
) {
}
public function kirim(string $nomor, string $isi): bool
{
return $this->api->dispatch($nomor, $isi);
}
}Dengan pola ini, kode produksi memakai VendorSmsAdapter yang tersambung ke server SMS sungguhan, sedangkan unit test tinggal mengganti implementasi interface dengan double mana pun. Test adapter sendiri tetap memakai vendor aslinya karena tugas adapter memang meneruskan panggilan:
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class VendorSmsAdapterTest extends TestCase
{
public function testAdapterMeneruskanPesanKeApiVendor(): void
{
$adapter = new VendorSmsAdapter(new VendorSmsApi());
$this->assertTrue($adapter->kirim('0812-3456-7890', 'Pesanan dikirim'));
$this->assertFalse($adapter->kirim('+62812-3456-7890', 'Pesanan dikirim'));
}
}Sekarang kita punya sistem yang diuji, yaitu layanan yang memberi tahu pelanggan bahwa pesanannya telah dikirim:
<?php
declare(strict_types=1);
final class PemberitahuanService
{
public function __construct(
private readonly SmsGatewayInterface $gateway,
) {
}
public function beriTahuPesananDikirim(string $nomorTelepon, string $nomorPesanan): bool
{
return $this->gateway->kirim(
$nomorTelepon,
"Pesanan {$nomorPesanan} telah dikirim",
);
}
}Perhatikan bahwa PemberitahuanService hanya mengenal SmsGatewayInterface. Class ini tidak tahu dan tidak peduli apakah di baliknya ada server SMS, stub, atau spy — dan ketidaktahuan itulah yang membuat test double bisa dipasang tanpa sentuhan apa pun pada kode produksi.
Stub dengan Jawaban Terkontrol #
Stub menggantikan dependensi dengan jawaban tetap yang kita pilih sendiri. Untuk gateway SMS, pertanyaan yang penting bagi PemberitahuanService hanyalah “apakah pengiriman berhasil atau gagal?”, sehingga stub-nya cukup menyimpan satu nilai bool:
<?php
declare(strict_types=1);
final class StubSmsGateway implements SmsGatewayInterface
{
public function __construct(
private readonly bool $berhasil,
) {
}
public function kirim(string $nomor, string $isi): bool
{
return $this->berhasil;
}
}Constructor menerima jawaban yang diinginkan lewat constructor promotion, lalu kirim() mengembalikan jawaban itu tanpa memedulikan argumen. Dua skenario uji — gateway menerima dan gateway menolak — kini bisa disimulasikan tanpa jaringan sama sekali:
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class PemberitahuanServiceTest extends TestCase
{
public function testPengirimanBerhasilBilaGatewayMenerima(): void
{
$service = new PemberitahuanService(new StubSmsGateway(true));
$this->assertTrue(
$service->beriTahuPesananDikirim('0812-3456-7890', 'PSN-001'),
);
}
public function testKegagalanGatewayDiteruskanKePemanggil(): void
{
$service = new PemberitahuanService(new StubSmsGateway(false));
$this->assertFalse(
$service->beriTahuPesananDikirim('0812-3456-7890', 'PSN-002'),
);
}
}Test pertama menyuntikkan new StubSmsGateway(true), sehingga beriTahuPesananDikirim() diprediksi mengembalikan true karena nilai balik gateway diteruskan apa adanya. Test kedua memakai stub yang menjawab false dan memastikan kegagalan gateway juga sampai ke pemanggil. Kedua test berjalan dalam hitungan mikrodetik dan hasilnya selalu sama berapa pun kali dijalankan — dua sifat yang mustahil dicapai gateway sungguhan.
Spy yang Merekam Interaksi #
Spy bekerja ke arah sebaliknya: alih-alih mengatur jawaban, spy mencatat semua panggilan yang masuk lalu membiarkan test memeriksa catatannya. Karena perekaman inilah spy bisa menjawab “ke nomor mana pesan sesungguhnya dikirim?”. Berikut spy untuk gateway SMS kita — interface yang sama disertakan agar cuplikan bisa dijalankan sendiri:
<?php
declare(strict_types=1);
interface SmsGatewayInterface
{
public function kirim(string $nomor, string $isi): bool;
}
final class SpySmsGateway implements SmsGatewayInterface
{
/** @var list<array{nomor: string, isi: string}> */
public array $panggilan = [];
public function kirim(string $nomor, string $isi): bool
{
$this->panggilan[] = ['nomor' => $nomor, 'isi' => $isi];
return true;
}
public function jumlahPanggilan(): int
{
return count($this->panggilan);
}
}
$lapor = static function (SpySmsGateway $spy, string $nomor, string $nomorPesanan): bool {
return $spy->kirim($nomor, "Pesanan {$nomorPesanan} telah dikirim");
};
$spy = new SpySmsGateway();
$lapor($spy, '0812-3456-7890', 'PSN-001');
$lapor($spy, '0813-9999-0001', 'PSN-002');
echo 'Jumlah panggilan: ', $spy->jumlahPanggilan(), "\n";
foreach ($spy->panggilan as $i => $panggilan) {
printf("#%d -> %s (%s)\n", $i + 1, $panggilan['nomor'], $panggilan['isi']);
}Output yang Dihasilkan:
Jumlah panggilan: 2
#1 -> 0812-3456-7890 (Pesanan PSN-001 telah dikirim)
#2 -> 0813-9999-0001 (Pesanan PSN-002 telah dikirim)Mari kita bedah mekanisme perekamannya:
- Property
$panggilandideklarasikanpublicberisi array kosong; setiap elemennya kelak memuat asosiasinomordanisidari satu panggilan. - Method
kirim()menambahkan satu entri ke$panggilansebelum mengembalikantrue— jawaban kaleng sederhana, karena fokus spy ada pada catatan, bukan jawaban. - Closure
$laporberperan sebagai sistem yang diuji versi mini: menerima spy, lalu memanggilkirim()dua kali dengan nomor dan nomor pesanan berbeda. - Perulangan di akhir membaca isi catatan
$spy->panggilan— persis seperti yang akan dilakukan assertion PHPUnit pada test sungguhan.
Dalam test, catatan spy diverifikasi dengan assertion biasa: $this->assertSame(1, $gateway->jumlahPanggilan()) memastikan jumlah panggilan, dan $gateway->panggilan[0]['nomor'] memeriksa argumen panggilan pertama. Kita akan melihat pola ini bekerja pada test PemesananService di bagian fake.
Dummy untuk Memenuhi Kontrak #
Dummy adalah anggota paling sederhana: objek yang dipakai hanya karena signature mensyaratkan kehadirannya, sementara jalur test tidak pernah menyentuh perilakunya. Bayangkan PemesananService di bagian berikut mensyaratkan logger untuk kebutuhan audit, padahal test kita tidak memedulikan pencatatan log sama sekali:
<?php
declare(strict_types=1);
interface LoggerInterface
{
public function info(string $pesan): void;
}<?php
declare(strict_types=1);
final class DummyLogger implements LoggerInterface
{
public function info(string $pesan): void
{
// sengaja kosong: dummy hanya perlu memenuhi kontrak constructor
}
}Method info() sengaja dibiarkan kosong karena nilainya ada pada kehadiran objek, bukan pada perilakunya. Jangan tertukar dengan stub: stub dipanggil dan jawabannya menentukan hasil test, sedangkan dummy sekadar mengisi tempat agar kode bisa berjalan. Kalau suatu saat test mulai memeriksa isi log, dummy harus dinaikkan statusnya menjadi spy perekam.
Fake Repositori di Dalam Memori #
Fake adalah test double paling pekerja keras: implementasi sungguhan yang berfungsi, hanya dengan mesin yang jauh lebih ringkas. Contoh klasiknya adalah repositori pesanan yang menyimpan data di array, menggantikan tabel database:
<?php
declare(strict_types=1);
final readonly class Pesanan
{
public function __construct(
public string $id,
public int $total,
) {
}
}<?php
declare(strict_types=1);
interface PesananRepositoryInterface
{
public function simpan(Pesanan $pesanan): void;
public function cari(string $id): ?Pesanan;
}<?php
declare(strict_types=1);
final class InMemoryPesananRepository implements PesananRepositoryInterface
{
/** @var array<string, Pesanan> */
private array $penyimpanan = [];
public function simpan(Pesanan $pesanan): void
{
$this->penyimpanan[$pesanan->id] = $pesanan;
}
public function cari(string $id): ?Pesanan
{
return $this->penyimpanan[$id] ?? null;
}
}Property $penyimpanan memetakan id pesanan ke objek Pesanan; simpan() menimpa entri lama dengan kunci sama, dan cari() mengembalikan null untuk kunci tak dikenal lewat operator ??. Kontrak inti sebuah repositori — data yang disimpan bisa ditemukan kembali — tetap terpenuhi tanpa satu baris SQL pun. Sekarang kita rangkai semua double dalam satu layanan pemesanan:
<?php
declare(strict_types=1);
final class PemesananService
{
public function __construct(
private readonly PesananRepositoryInterface $repository,
private readonly SmsGatewayInterface $gateway,
private readonly LoggerInterface $logger,
) {
}
public function buatPesanan(string $id, int $total, string $nomorTelepon): bool
{
$pesanan = new Pesanan($id, $total);
$this->repository->simpan($pesanan);
$terkirim = $this->gateway->kirim(
$nomorTelepon,
"Pesanan {$id} kami terima. Total: Rp" . number_format($total, 0, ',', '.'),
);
$this->logger->info("Pesanan {$id} diproses");
return $terkirim;
}
}Satu test kini bisa menguji alur lengkapnya memakai tiga double sekaligus — fake sebagai penyimpanan, spy sebagai gateway, dan dummy sebagai logger:
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class PemesananServiceTest extends TestCase
{
public function testBuatPesananMenyimpanDanMengirimKonfirmasi(): void
{
$repository = new InMemoryPesananRepository();
$gateway = new SpySmsGateway();
$service = new PemesananService($repository, $gateway, new DummyLogger());
$this->assertTrue(
$service->buatPesanan('PSN-042', 275000, '0812-3456-7890'),
);
$tersimpan = $repository->cari('PSN-042');
$this->assertNotNull($tersimpan);
$this->assertSame(275000, $tersimpan->total);
$this->assertSame(1, $gateway->jumlahPanggilan());
$this->assertSame('0812-3456-7890', $gateway->panggilan[0]['nomor']);
$this->assertStringContainsString('PSN-042', $gateway->panggilan[0]['isi']);
$this->assertStringContainsString('Rp275.000', $gateway->panggilan[0]['isi']);
}
}Bagian verifikasi menilai dua efek buatPesanan() secara terpisah: status di penyimpanan diperiksa lewat fake (cari() menemukan pesanan dengan total yang tepat), sedangkan efek samping ke pelanggan diperiksa lewat catatan spy (satu panggilan, ke nomor yang benar, dengan isi yang memuat nomor pesanan dan format rupiah). Fake juga layak diuji sendiri karena logikanya nyata:
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class InMemoryPesananRepositoryTest extends TestCase
{
public function testPesananTersimpanDanBisaDicari(): void
{
$repository = new InMemoryPesananRepository();
$pesanan = new Pesanan('PSN-042', 275000);
$repository->simpan($pesanan);
$this->assertSame($pesanan, $repository->cari('PSN-042'));
}
public function testMencariPesananTakDikenalMengembalikanNull(): void
{
$repository = new InMemoryPesananRepository();
$this->assertNull($repository->cari('PSN-999'));
}
}Waktu sebagai Dependensi #
Dependensi tersulit sering bukan jaringan, melainkan jam dinding. Fungsi yang memanggil date() atau new DateTimeImmutable() tanpa argumen menghasilkan keluaran berbeda setiap detiknya, sehingga test yang membandingkan hasil dengan nilai harapan akan lolos pada jam tertentu dan gagal pada jam lain. Solusinya sama seperti gateway SMS: angkat waktu menjadi interface, lalu suntikkan implementasinya.
<?php
declare(strict_types=1);
interface ClockInterface
{
public function saatIni(): DateTimeImmutable;
}<?php
declare(strict_types=1);
final class FixedClock implements ClockInterface
{
public function __construct(
private readonly DateTimeImmutable $waktu,
) {
}
public function saatIni(): DateTimeImmutable
{
return $this->waktu;
}
}FixedClock adalah stub waktu: satu nilai beku yang ditentukan test, dalam kasus ini pukul 08:00:00 tanggal 15 Januari 2026 zona Asia/Jakarta. Layanan pembuat token memakai jam tersebut untuk menghitung masa berlaku:
<?php
declare(strict_types=1);
final class TokenService
{
private const MASA_BERLAKU = '+24 hours';
public function __construct(
private readonly ClockInterface $clock,
) {
}
public function buatToken(string $pengguna): string
{
$kadaluarsa = $this->clock->saatIni()
->modify(self::MASA_BERLAKU)
->format('Y-m-d H:i:s');
return "{$pengguna}|{$kadaluarsa}";
}
}<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class TokenServiceTest extends TestCase
{
public function testTokenMemuatWaktuKadaluarsa24Jam(): void
{
$jamTetap = new FixedClock(
new DateTimeImmutable('2026-01-15 08:00:00', new DateTimeZone('Asia/Jakarta')),
);
$service = new TokenService($jamTetap);
$this->assertSame(
'budi|2026-01-16 08:00:00',
$service->buatToken('budi'),
);
}
}Karena FixedClock selalu melaporkan waktu yang sama, assertion assertSame bisa membandingkan token secara harfiah: budi|2026-01-16 08:00:00 adalah 24 jam setelah titik beku yang kita pilih. Test ini akan menghasilkan hasil identik entah dijalankan pagi, malam, atau lima tahun lagi.
Menjalankan Seluruh Suite #
Seluruh berkas tersusun seperti proyek mini berikut, dengan bootstrap.php yang memuat semua class sebelum suite berjalan:
test-doubles/
├── bootstrap.php
├── src/
│ ├── ClockInterface.php
│ ├── DummyLogger.php
│ ├── FixedClock.php
│ ├── InMemoryPesananRepository.php
│ ├── LoggerInterface.php
│ ├── PemberitahuanService.php
│ ├── PemesananService.php
│ ├── Pesanan.php
│ ├── PesananRepositoryInterface.php
│ ├── SmsGatewayInterface.php
│ ├── SpySmsGateway.php
│ ├── StubSmsGateway.php
│ ├── TokenService.php
│ ├── VendorSmsAdapter.php
│ └── VendorSmsApi.php
└── tests/
├── InMemoryPesananRepositoryTest.php
├── PemberitahuanServiceTest.php
├── PemesananServiceTest.php
├── TokenServiceTest.php
└── VendorSmsAdapterTest.phpPerintah berikut menjalankan semua test sekaligus:
php /home/z/my-project/bin/phpunit.phar --bootstrap tests/bootstrap.php testsBerikut output nyata dari perintah tersebut:
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.23
....... 7 / 7 (100%)
Time: 00:00.002, Memory: 28.05 MB
OK (7 tests, 14 assertions)Tujuh test selesai dalam dua milidetik tanpa satu paket jaringan pun keluar — bandingkan dengan keharusan menghubungi server SMS dan database sungguhan tujuh kali. Exit code 0 menjadi sinyal hijau yang kelak dipakai pipeline CI untuk memutuskan build. Total empat belas assertion menghitung seluruh assertSame dan assertTrue, termasuk pemeriksaan catatan spy yang paling banyak menyumbang.
Kapan Fake Tulisan Tangan Lebih Baik #
Semua yang kita tulis di Chapter ini juga bisa dilakukan mocking framework otomatis. Jadi kapan menulis double secara manual layak dipilih? Perbandingan berikut membantu memutuskan:
| Aspek | Double tulisan tangan | Double otomatis PHPUnit |
|---|---|---|
| Investasi awal | Satu class kecil per dependensi | Nyaris nol, langsung konfigurasi |
| Isi test | new InMemoryPesananRepository(), selesai | Rangkaian expects()->method()->with() per test |
| Pemakaian ulang | Satu fake dipakai puluhan test | Konfigurasi jarang bisa dipindah antar test |
| Cocok untuk | Dependensi berstate: repositori, cache, filesystem | Interaksi sederhana satu-dua method |
| Risiko khas | Fake makin pintar dan butuh test sendiri | Test menempel pada detail implementasi |
Panduan praktisnya: pilih fake tulisan tangan ketika dependensi punya state yang kompleks atau dirinya dipakai di banyak test, karena logika tiruan cukup ditulis sekali dan diverifikasi sekali. Pilih double otomatis untuk verifikasi interaksi sesaat yang bentuknya sederhana. Keduanya bukan pesaing, melainkan pilihan yang dilakukan per dependensi.
Ringkasan #
Test double menggantikan dependensi nyata agar unit test cepat dan deterministik: dummy mengisi kontrak, stub menyuplai jawaban terkontrol, spy merekam interaksi, fake menyediakan implementasi ringkas yang benar-benar bekerja, dan mock menuntut kontrak interaksi. Kunci pemasangannya adalah interface sebagai seam — termasuk pola adapter untuk membungkus SDK final pihak ketiga — serta pola ClockInterface untuk menaklukkan waktu. Menulis double dengan tangan membutuhkan investasi class kecil, tetapi membayar dengan test yang ringkas dan tahan lama. Untuk dependensi yang hanya butuh verifikasi interaksi sekali pakai, menulis class manual terasa berlebihan — dan di situlah mocking framework bawaan PHPUnit masuk, topik Chapter berikutnya.
Referensi #
- Test doubles dalam dokumentasi resmi PHPUnit: https://docs.phpunit.de/en/12.5/test-doubles.html
- Interface sebagai kontrak dalam object model PHP: https://www.php.net/manual/en/language.oop5.interfaces.php
DateTimeImmutabledan penanganan waktu yang aman: https://www.php.net/manual/en/class.datetimeimmutable.php