Mocking #
Mocking adalah teknik dalam testing untuk menggantikan dependensi nyata dengan objek palsu yang perilakunya bisa dikontrol — sehingga unit yang diuji bisa dites secara terisolasi tanpa harus melibatkan database, jaringan, atau layanan eksternal. Artikel sebelumnya (Unit Test) sudah memperkenalkan stub dan mock dasar PHPUnit. Artikel ini membahas mocking secara lebih mendalam: taksonomi lengkap test double, perbedaan yang halus antara stub, mock, spy, dan fake, teknik mock yang lebih kompleks, Mockery sebagai alternatif yang lebih ekspresif, dan yang paling penting — kapan mocking justru menjadi tanda bahwa desain kode perlu dipertimbangkan ulang.
Taksonomi Test Double #
“Mock” sering digunakan sebagai istilah umum, padahal ada lima jenis test double yang berbeda dengan peran masing-masing:
flowchart TD
TD[Test Double] --> Dummy
TD --> Stub
TD --> Spy
TD --> Mock
TD --> Fake
Dummy --> D1["Mengisi parameter\nwajib yang tidak\ndipakai dalam test"]
Stub --> S1["Mengembalikan nilai\npre-programmed\ntanpa verifikasi"]
Spy --> SP1["Mencatat bagaimana\ndia dipanggil,\nbisa di-query setelah"]
Mock --> M1["Verifikasi ekspektasi\nsecara otomatis\nsaat test selesai"]
Fake --> F1["Implementasi\nsederhana tapi\nbeneran bekerja"]
style Stub fill:#dcfce7
style Mock fill:#dbeafe
style Fake fill:#fef9c3<?php
use PHPUnit\Framework\TestCase;
// 1. DUMMY — mengisi parameter tapi tidak dipanggil atau digunakan
class DummyLogger implements LoggerInterface
{
public function log(string $msg): void {} // tidak melakukan apapun
public function info(string $msg): void {}
public function error(string $msg): void {}
}
// Digunakan saat kelas yang ditest butuh logger tapi test tidak peduli logging
$service = new OrderService(new DummyLogger(), $repo);
// 2. STUB — mengembalikan nilai yang diprogram
$stub = $this->createStub(UserRepository::class);
$stub->method('findById')->willReturn(new User(id: 1, nama: 'Budi'));
// Tidak memverifikasi berapa kali dipanggil atau dengan argumen apa
// 3. MOCK — verifikasi ekspektasi interaksi
$mock = $this->createMock(MailerInterface::class);
$mock->expects($this->once())->method('kirim'); // HARUS dipanggil tepat sekali
// Verifikasi terjadi otomatis saat test selesai
// 4. SPY — rekam panggilan, verifikasi setelah fakta
// PHPUnit tidak punya spy bawaan, tapi bisa disimulasikan
$spy = $this->createMock(EventDispatcher::class);
$spy->method('dispatch')
->willReturnCallback(function($event) use (&$dispatchedEvents) {
$dispatchedEvents[] = $event;
return true;
});
// Eksekusi
$this->service->jalankan();
// Verifikasi setelah fakta (gaya spy)
$this->assertCount(2, $dispatchedEvents);
$this->assertInstanceOf(OrderCreatedEvent::class, $dispatchedEvents[0]);
// 5. FAKE — implementasi sederhana yang benar-benar bekerja
class InMemoryUserRepository implements UserRepositoryInterface
{
private array $users = [];
public function save(User $user): void
{
$this->users[$user->getId()] = $user;
}
public function findById(int $id): ?User
{
return $this->users[$id] ?? null;
}
public function findAll(): array
{
return array_values($this->users);
}
}
// Fake jauh lebih realistis dari stub — bisa digunakan di banyak test
$fakeRepo = new InMemoryUserRepository();
$fakeRepo->save(new User(id: 1, nama: 'Budi'));
$service = new OrderService($fakeRepo);
Mock Lanjutan PHPUnit #
Mencocokkan Argumen dengan Constraint #
PHPUnit menyediakan berbagai constraint untuk memverifikasi argumen yang dikirim ke mock:
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Constraint\{
IsEqual, IsInstanceOf, StringContains,
IsType, LogicalAnd, Anything
};
class EmailServiceTest extends TestCase
{
public function mengirimEmailKonfirmasiOrder(): void
{
$mailerMock = $this->createMock(MailerInterface::class);
$mailerMock->expects($this->once())
->method('kirim')
->with(
// Argumen 1: email — harus berformat email
$this->matchesRegularExpression('/^[\w.]+@[\w.]+\.[a-z]{2,}$/'),
// Argumen 2: subjek — harus mengandung nomor order
$this->logicalAnd(
$this->stringContains('Order'),
$this->stringContains('#42')
),
// Argumen 3: isi email — harus berisi informasi tertentu
$this->callback(function(string $isi): bool {
return str_contains($isi, 'Rp 150.000')
&& str_contains($isi, 'Budi Santoso');
}),
)
->willReturn(true);
$service = new EmailService($mailerMock);
$service->kirimKonfirmasiOrder(new Order(
id: 42,
email: '[email protected]',
total: 150_000,
namaPelanggan: 'Budi Santoso',
));
}
}
Mock dengan Callback #
Ketika perilaku mock perlu lebih kompleks dari sekadar return value statis, gunakan willReturnCallback:
<?php
$cacheMock = $this->createMock(CacheInterface::class);
// Simulasi cache yang benar-benar menyimpan dan mengambil nilai
$cacheStore = [];
$cacheMock->method('get')
->willReturnCallback(function(string $key) use (&$cacheStore) {
return $cacheStore[$key] ?? null;
});
$cacheMock->method('set')
->willReturnCallback(function(string $key, mixed $nilai, int $ttl) use (&$cacheStore) {
$cacheStore[$key] = $nilai;
return true;
});
$cacheMock->method('delete')
->willReturnCallback(function(string $key) use (&$cacheStore) {
unset($cacheStore[$key]);
return true;
});
// Sekarang mock berperilaku seperti cache yang sesungguhnya
// Lebih berguna untuk test yang kompleks
Mengurutkan Ekspektasi #
Jika urutan pemanggilan penting, gunakan InvokedAtIndex atau InvokedInSequence:
<?php
$dbMock = $this->createMock(DatabaseInterface::class);
// Verifikasi urutan: begin → query → commit
$dbMock->expects($this->at(0))->method('begin');
$dbMock->expects($this->at(1))->method('query')
->with($this->stringContains('INSERT'));
$dbMock->expects($this->at(2))->method('commit');
// Atau dengan getMockBuilder untuk konfigurasi lebih lanjut
$loggerMock = $this->getMockBuilder(LoggerInterface::class)
->disableOriginalConstructor() // jangan panggil __construct()
->onlyMethods(['log', 'error']) // hanya mock method ini
->getMock();
$loggerMock->expects($this->exactly(2))
->method('log')
->withConsecutive(
['info', 'Proses dimulai'], // pemanggilan pertama
['info', 'Proses selesai'], // pemanggilan kedua
);
Partial Mock #
Partial mock memungkinkan meng-override hanya method tertentu dari kelas nyata — method lain tetap menggunakan implementasi asli:
<?php
class ReportService
{
public function generate(array $data): string
{
$proses = $this->prosesData($data); // method yang ingin di-mock
$format = $this->formatOutput($proses); // method yang tetap nyata
return $format;
}
protected function prosesData(array $data): array
{
// Operasi berat — akses database, kalkulasi kompleks
sleep(5); // simulasi lambat
return $data;
}
protected function formatOutput(array $data): string
{
return json_encode($data); // ringan, tetap gunakan aslinya
}
}
class ReportServiceTest extends TestCase
{
public function menghasilkanReportDenganFormatBenar(): void
{
// Partial mock — mock hanya prosesData, formatOutput tetap nyata
$service = $this->getMockBuilder(ReportService::class)
->onlyMethods(['prosesData']) // hanya method ini yang di-mock
->getMock();
$service->method('prosesData')
->willReturn(['id' => 1, 'nama' => 'Test']); // cepat, tidak sleep
$hasil = $service->generate(['id' => 1]);
// formatOutput() asli yang dipanggil — bisa verifikasi output nyata
$this->assertSame('{"id":1,"nama":"Test"}', $hasil);
}
}
Mockery — Alternatif yang Lebih Ekspresif #
Mockery adalah library mocking alternatif yang sering dianggap lebih mudah dibaca dan lebih fleksibel dari sistem mock bawaan PHPUnit:
composer require --dev mockery/mockery
Sintaks Mockery #
<?php
use Mockery;
use Mockery\MockInterface;
use PHPUnit\Framework\TestCase;
class OrderServiceMockeryTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close(); // WAJIB — membersihkan ekspektasi Mockery
parent::tearDown();
}
public function menghitungTotalOrder(): void
{
// Buat mock dengan Mockery
$repo = Mockery::mock(UserRepository::class);
// Sintaks Mockery: ->shouldReceive()->with()->andReturn()
$repo->shouldReceive('findById')
->once()
->with(42)
->andReturn(new User(id: 42, diskon: 0.10));
$service = new OrderService($repo);
$total = $service->hitungTotalUntukUser(userId: 42, harga: 100_000);
$this->assertSame(90_000.0, $total);
}
public function verifikasiEmailDikirim(): void
{
$mailer = Mockery::mock(MailerInterface::class);
$mailer->shouldReceive('kirim')
->once()
->with(
Mockery::type('string'), // argumen 1: string apapun
Mockery::pattern('/^Order #\d+/'), // argumen 2: pattern regex
Mockery::any() // argumen 3: apapun
)
->andReturn(true);
$service = new NotifService($mailer);
$service->notifikasiOrder(new Order(id: 99, email: '[email protected]'));
}
public function mockTidakDiharapkan(): void
{
$cache = Mockery::mock(CacheInterface::class);
// Pastikan method ini TIDAK dipanggil sama sekali
$cache->shouldNotReceive('delete');
// Bisa dipanggil berapa saja (termasuk tidak dipanggil)
$cache->shouldReceive('get')->zeroOrMoreTimes()->andReturn(null);
// Harus dipanggil minimal sekali
$cache->shouldReceive('set')->atLeast()->once();
$service = new DataService($cache);
$service->muatData('key', fn() => 'nilai');
}
}
Mockery untuk Interface dan Kelas Final #
PHPUnit tidak bisa mock kelas final secara langsung. Mockery punya solusi via alias:
<?php
// Kelas final tidak bisa di-extend/mock biasa
final class SmsGateway
{
public function kirim(string $nomorHp, string $pesan): bool
{
// panggil API eksternal
}
}
// Mockery alias — buat mock seolah kelas tersebut bisa di-mock
$smsMock = Mockery::mock('overload:' . SmsGateway::class);
$smsMock->shouldReceive('kirim')->once()->andReturn(true);
// Atau gunakan pendekatan yang lebih bersih:
// Buat interface dan wrapper, lalu mock interface-nya
interface SmsGatewayInterface
{
public function kirim(string $nomorHp, string $pesan): bool;
}
class SmsGatewayWrapper implements SmsGatewayInterface
{
public function __construct(private SmsGateway $gateway) {}
public function kirim(string $nomorHp, string $pesan): bool
{
return $this->gateway->kirim($nomorHp, $pesan);
}
}
// Sekarang bisa mock interface-nya dengan PHPUnit biasa
$smsMock = $this->createMock(SmsGatewayInterface::class);
$smsMock->expects($this->once())->method('kirim')->willReturn(true);
Mocking Static Method #
Static method sulit di-mock karena tidak bisa diganti lewat dependency injection. Ada beberapa strategi:
<?php
// Pendekatan 1: Bungkus dalam kelas dengan interface (terbaik)
class TimeService
{
public function sekarang(): \DateTimeImmutable
{
return new \DateTimeImmutable();
}
}
// Sekarang TimeService bisa di-inject dan di-mock
class OrderService
{
public function __construct(private TimeService $time) {}
public function buatOrder(array $data): Order
{
return new Order(
...$data,
createdAt: $this->time->sekarang(), // tidak pakai static time()
);
}
}
class OrderServiceTest extends TestCase
{
public function orderMemilikiTanggalYangBenar(): void
{
$waktuTetap = new \DateTimeImmutable('2024-03-15 14:00:00');
$timeStub = $this->createStub(TimeService::class);
$timeStub->method('sekarang')->willReturn($waktuTetap);
$service = new OrderService($timeStub);
$order = $service->buatOrder(['user_id' => 1]);
$this->assertEquals($waktuTetap, $order->getCreatedAt());
}
}
// Pendekatan 2: Gunakan Clock abstraction (PSR-20 ClockInterface)
use Psr\Clock\ClockInterface;
class FakeClock implements ClockInterface
{
public function __construct(private \DateTimeImmutable $waktu) {}
public function now(): \DateTimeImmutable
{
return $this->waktu;
}
public function majukan(string $interval): static
{
return new static($this->waktu->modify($interval));
}
}
$fakeClock = new FakeClock(new \DateTimeImmutable('2024-03-15'));
$service = new OrderService($fakeClock);
// Bisa "majukan" waktu dalam test!
$fakeClock = $fakeClock->majukan('+1 day');
Fake vs Mock — Memilih yang Tepat #
Untuk dependensi yang sering digunakan di banyak test, fake lebih baik dari mock:
<?php
// Fake repository — implementasi yang benar-benar bekerja tapi di memori
class FakeUserRepository implements UserRepositoryInterface
{
private array $users = [];
private int $nextId = 1;
public function save(User $user): User
{
if ($user->getId() === null) {
$user = $user->withId($this->nextId++);
}
$this->users[$user->getId()] = $user;
return $user;
}
public function findById(int $id): ?User
{
return $this->users[$id] ?? null;
}
public function findByEmail(string $email): ?User
{
foreach ($this->users as $user) {
if ($user->getEmail() === $email) {
return $user;
}
}
return null;
}
public function findAll(): array
{
return array_values($this->users);
}
public function delete(int $id): void
{
unset($this->users[$id]);
}
// Metode helper untuk test — tidak ada di interface nyata
public function reset(): void
{
$this->users = [];
$this->nextId = 1;
}
public function count(): int
{
return count($this->users);
}
}
// Gunakan di banyak test
class UserServiceTest extends TestCase
{
private FakeUserRepository $repo;
private UserService $service;
protected function setUp(): void
{
$this->repo = new FakeUserRepository();
$this->service = new UserService($this->repo);
}
public function menyimpanUserBaru(): void
{
$user = $this->service->daftar('Budi', '[email protected]');
$this->assertNotNull($user->getId());
$this->assertSame(1, $this->repo->count());
}
public function menemukankUserBerdasarkanEmail(): void
{
$this->repo->save(new User(nama: 'Budi', email: '[email protected]'));
$ditemukan = $this->service->cariByEmail('[email protected]');
$this->assertNotNull($ditemukan);
$this->assertSame('Budi', $ditemukan->getNama());
}
}
Anti-Pattern Mocking yang Harus Dihindari #
<?php
// ✗ Anti-pattern 1: Mock semua dependensi (over-mocking)
// Jika test ini gagal, susah tahu apakah SUT atau mock yang salah
public function testProsesOrder(): void
{
$repoMock = $this->createMock(OrderRepository::class);
$mailerMock = $this->createMock(MailerInterface::class);
$loggerMock = $this->createMock(LoggerInterface::class);
$cacheMock = $this->createMock(CacheInterface::class);
$eventMock = $this->createMock(EventDispatcher::class);
$validMock = $this->createMock(ValidatorInterface::class);
// 6 mock? Mungkin OrderService melakukan terlalu banyak hal
// Tanda: kelas yang diuji punya terlalu banyak dependensi
}
// ✓ Pertimbangkan refactor — pisahkan tanggung jawab
// OrderService hanya butuh 1-2 dependensi yang benar-benar perlu
// ✗ Anti-pattern 2: Mock kelas konkret yang tidak punya interface
$userMock = $this->createMock(User::class); // Entity/Value Object jangan di-mock
// Buat User nyata dengan nilai yang dibutuhkan test
$user = new User(id: 1, nama: 'Test', email: '[email protected]');
// ✗ Anti-pattern 3: Mock mengembalikan mock
$repoMock = $this->createMock(UserRepository::class);
$userMock = $this->createMock(User::class); // STOP! ini tanda masalah
$userMock->method('getNama')->willReturn('Budi'); // Buat User nyata saja
$repoMock->method('findById')->willReturn($userMock);
// ✓ Return objek nyata dari stub/mock
$repoMock->method('findById')->willReturn(
new User(id: 1, nama: 'Budi', email: '[email protected]')
);
// ✗ Anti-pattern 4: Test yang hanya memverifikasi implementasi internal
public function testImplementasiRedis(): void
{
$redisMock = $this->createMock(\Redis::class);
// Verifikasi bahwa SET dipanggil dengan TTL tertentu
$redisMock->expects($this->once())
->method('setex')
->with('user:1', 3600, Mockery::any());
// Test ini akan gagal jika kita ganti Redis dengan Memcached
// meski perilaku cache tetap sama!
}
// ✓ Test perilaku yang bisa diamati dari luar
public function testUserDisimpanDiCache(): void
{
$fakeCache = new InMemoryCache(); // fake yang fleksibel
$service = new UserService($fakeCache);
$user1 = $service->getUser(1); // panggilan pertama
$user2 = $service->getUser(1); // seharusnya dari cache
$this->assertEquals($user1, $user2);
$this->assertTrue($fakeCache->has('user:1'));
}
// ✗ Anti-pattern 5: Lupa tearDown Mockery
public function testMenggunakanMockery(): void
{
$mock = Mockery::mock(SomeClass::class);
// ... test
// Jika tearDown tidak memanggil Mockery::close(), ekspektasi tidak diverifikasi!
}
// ✓ Selalu panggil Mockery::close() di tearDown
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
Kapan Tidak Mocking #
Mocking yang berlebihan adalah code smell — tanda bahwa desain perlu dipertimbangkan ulang:
Jangan mock:
✗ Value Object dan Entity (User, Order, Money) — buat instansi nyata
✗ Kelas yang sangat sederhana tanpa side effect (kalkulasi, formatting)
✗ Dependensi yang cepat dan deterministik (in-memory collection, tanggal tetap)
✗ Semua dependensi dalam satu test (tanda God Object atau terlalu banyak coupling)
Mock ketika:
✓ Dependensi punya side effect (kirim email, tulis ke database, panggil API)
✓ Dependensi lambat (network call, query berat)
✓ Dependensi non-deterministik (random, waktu sekarang, UUID)
✓ Ingin verifikasi interaksi — bukan hanya hasil akhir
Jika kamu butuh banyak mock untuk satu test:
→ Pertimbangkan memecah kelas yang diuji menjadi beberapa kelas lebih kecil
→ Gunakan fake (implementasi in-memory) daripada mock untuk dependensi kompleks
→ Tulis integration test daripada memaksakan unit test dengan banyak mock
Ringkasan #
- Lima jenis test double: Dummy (isi parameter yang tidak dipakai), Stub (return nilai pre-programmed), Spy (rekam panggilan untuk verifikasi belakangan), Mock (verifikasi ekspektasi otomatis), Fake (implementasi sederhana yang benar-benar bekerja).
- Stub untuk data, Mock untuk interaksi — stub mengembalikan nilai yang diperlukan SUT agar bisa berjalan; mock memverifikasi bahwa SUT berinteraksi dengan dependensi secara benar.
- Fake lebih baik dari Mock untuk dependensi yang digunakan banyak test —
InMemoryUserRepositoryjauh lebih mudah dipelihara dan lebih realistis dari puluhan mock yang dikonfigurasi ulang.- Mockery memberikan sintaks yang lebih ekspresif:
shouldReceive()->once()->with()->andReturn(). Wajib memanggilMockery::close()ditearDown().- Jangan mock kelas final atau static method secara langsung — bungkus dalam interface/wrapper untuk membuat testable, atau gunakan Mockery alias (sebagai solusi terakhir).
- Partial mock berguna untuk override satu method dari kelas nyata saat implementasi asli diperlukan untuk method lainnya.
- Tanda over-mocking: test butuh 5+ mock; mock mengembalikan mock; test gagal saat implementasi direfactor meski perilaku tetap sama. Ini semua tanda bahwa perlu redesain, bukan lebih banyak mock.
Mockery::close()di tearDown() tidak opsional — tanpanya, ekspektasi Mockery tidak diverifikasi dan test yang gagal akan terlewat.