Spy — a test double that records calls so you can assert after the fact
Beginner5 min read·eng-17-007
interview
Concept
Spy — a test double that RECORDS calls made to it so you can assert them AFTER the code runs. Unlike a mock, the spy doesn't set expectations in advance — you run the code first, then check what happened.
Mock vs Spy approach:
- Mock: Expectations set BEFORE running code. "I expect method X to be called once." If not met → fail immediately.
- Spy: Run the code FIRST, then assert AFTER. "Let me see what was called." More flexible, but assertions happen after.
When spies are useful:
- When you want to see what happened without pre-committing to expectations.
- When testing that a method was called at least once, but you don't know the exact call count in advance.
- When you want to inspect arguments after the fact.
In PHPUnit: PHPUnit doesn't have a distinct "spy" class — you can use a mock as a spy by omitting expects() and instead checking the call count after using getInvocationCount(). More commonly, you write a manual spy class.
In Laravel: Laravel's Event::fake(), Queue::fake(), Mail::fake() are spy-like — they record what was dispatched/queued/sent, and you assert AFTER.
Manual spy: A class that records calls to its methods, which you can inspect in assertions.
Code Example
php
<?php
// MANUAL SPY — records calls for later assertion
class SpyMailer implements MailerInterface
{
public array $sentMessages = [];
public function send(string $to, string $subject, string $body): void
{
$this->sentMessages[] = compact('to', 'subject', 'body');
// Actually sends nothing — records the call
}
public function assertSentTo(string $email): void
{
$sent = array_filter($this->sentMessages, fn($m) => $m['to'] === $email);
\PHPUnit\Framework\Assert::assertNotEmpty($sent, "No email sent to {$email}");
}
public function assertSentCount(int $count): void
{
\PHPUnit\Framework\Assert::assertCount($count, $this->sentMessages);
}
}
// TEST using the spy
class OrderServiceTest extends \PHPUnit\Framework\TestCase
{
public function test_sends_confirmation_email(): void
{
$spyMailer = new SpyMailer();
$service = new OrderService(new InMemoryOrderRepository(), $spyMailer);
// RUN the code first
$service->place(['user_email' => 'alice@example.com', 'total' => 100]);
// ASSERT AFTER — what did the spy record?
$spyMailer->assertSentTo('alice@example.com');
$spyMailer->assertSentCount(1);
// Inspect recorded calls
$this->assertEquals('alice@example.com', $spyMailer->sentMessages[0]['to']);
$this->assertStringContains('Order', $spyMailer->sentMessages[0]['subject']);
}
public function test_sends_emails_to_all_users_on_broadcast(): void
{
$spyMailer = new SpyMailer();
$service = new OrderService(new InMemoryOrderRepository(), $spyMailer);
$service->notifyAll(['alice@x.com', 'bob@x.com', 'charlie@x.com'], 'System maintenance');
// Assert AFTER running
$spyMailer->assertSentCount(3);
$spyMailer->assertSentTo('alice@x.com');
$spyMailer->assertSentTo('charlie@x.com');
}
}
// LARAVEL FAKES — built-in spy pattern
\Mail::fake();
\Queue::fake();
\Event::fake();
// Run code that sends mail, queues jobs, dispatches events
$service->placeOrder($user, $data);
// Assert AFTER — spy recorded what happened
\Mail::assertSent(\App\Mail\OrderConfirmation::class, 1); // sent exactly once
\Queue::assertPushed(\App\Jobs\ProcessPayment::class, function ($job) use ($order) {
return $job->orderId === $order->id; // assert specific job was queued with right data
});
\Event::assertDispatched(\App\Events\OrderPlaced::class);
// Number-based assertions (spy-like)
\Mail::assertSentCount(1);
\Queue::assertPushedOn('payments', \App\Jobs\ProcessPayment::class);