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:

JenisPertanyaan yang dijawabPerilakunya
DummyTidak menjawab apa punHanya 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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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:

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

  1. Property $panggilan dideklarasikan public berisi array kosong; setiap elemennya kelak memuat asosiasi nomor dan isi dari satu panggilan.
  2. Method kirim() menambahkan satu entri ke $panggilan sebelum mengembalikan true — jawaban kaleng sederhana, karena fokus spy ada pada catatan, bukan jawaban.
  3. Closure $lapor berperan sebagai sistem yang diuji versi mini: menerima spy, lalu memanggil kirim() dua kali dengan nomor dan nomor pesanan berbeda.
  4. 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
<?php

declare(strict_types=1);

interface LoggerInterface
{
    public function info(string $pesan): void;
}
php
<?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
<?php

declare(strict_types=1);

final readonly class Pesanan
{
    public function __construct(
        public string $id,
        public int $total,
    ) {
    }
}
php
<?php

declare(strict_types=1);

interface PesananRepositoryInterface
{
    public function simpan(Pesanan $pesanan): void;

    public function cari(string $id): ?Pesanan;
}
php
<?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
<?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
<?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
<?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
<?php

declare(strict_types=1);

interface ClockInterface
{
    public function saatIni(): DateTimeImmutable;
}
php
<?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
<?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
<?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:

text
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.php

Perintah berikut menjalankan semua test sekaligus:

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

Berikut output nyata dari perintah tersebut:

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

AspekDouble tulisan tanganDouble otomatis PHPUnit
Investasi awalSatu class kecil per dependensiNyaris nol, langsung konfigurasi
Isi testnew InMemoryPesananRepository(), selesaiRangkaian expects()->method()->with() per test
Pemakaian ulangSatu fake dipakai puluhan testKonfigurasi jarang bisa dipindah antar test
Cocok untukDependensi berstate: repositori, cache, filesystemInteraksi sederhana satu-dua method
Risiko khasFake makin pintar dan butuh test sendiriTest 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 #