0

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);