0

Container swapping in tests — binding mocks

Advanced5 min read·fw-11-004

Concept

Container swapping in tests replaces real implementations with test doubles (fakes, mocks, spies) for the duration of a test. This is the mechanism behind Mail::fake(), Queue::fake(), and Event::fake() in Laravel.

$app->instance($abstract, $instance): Binds a specific instance to an abstract in the container. Any subsequent $container->make($abstract) returns this instance instead of the registered binding. This is the core swap mechanism.

$app->bind($abstract, $concrete): Re-binds an interface to a different implementation for the test. Returns a new instance each time. Use when you need a fresh fake per resolution.

$app->singleton($abstract, $concrete): Re-bind as a singleton. The same fake instance is returned every time. Use for stateful fakes (like Mail::fake() — it accumulates sent mails for assertion).

Test doubles:

  • Fake: A simplified, controlled implementation. MailFake stores sent mails in memory instead of sending. QueueFake stores dispatched jobs.
  • Mock (Mockery): A PHPUnit/Mockery mock. Configure expected method calls with arguments. Mockery::mock(SomeService::class)->shouldReceive('send')->once().
  • Stub: A mock that always returns a fixed value without checking call expectations.

afterApplicationCreated(callable): Run a callback after the application is booted. Register fakes here to ensure they're in place before any test code runs.

Restoring original bindings: After the test, the transaction rollback handles DB state. For container swaps, either use a fresh application per test class or restore bindings in tearDown().

Code Example

php
<?php
// MailFake — a fake mail implementation
class MailFake
{
    private array $sent    = [];
    private array $queued  = [];

    public function send($mailable): void
    {
        $this->sent[] = $mailable;
    }

    public function queue($mailable): void
    {
        $this->queued[] = $mailable;
    }

    public function assertSent(string $class, ?\Closure $callback = null): void
    {
        $matching = array_filter($this->sent, fn($m) => $m instanceof $class);
        if (empty($matching)) {
            throw new \PHPUnit\Framework\AssertionFailedError("Mail [{$class}] was not sent.");
        }
        if ($callback !== null) {
            $matched = array_filter($matching, $callback);
            if (empty($matched)) {
                throw new \PHPUnit\Framework\AssertionFailedError(
                    "Mail [{$class}] was sent but did not match the given callback."
                );
            }
        }
    }

    public function assertNothingSent(): void
    {
        if (!empty($this->sent)) {
            throw new \PHPUnit\Framework\AssertionFailedError('Mail was sent unexpectedly.');
        }
    }
}

// TestCase swap methods
abstract class TestCase extends \PHPUnit\Framework\TestCase
{
    private array $bindings = [];

    protected function fake(string $abstract): mixed
    {
        // Resolve the registered fake for this abstract
        $fake = FakeRegistry::get($abstract);
        static::$app->instance($abstract, $fake);
        return $fake;
    }

    protected function mockBind(string $abstract, callable $factory): object
    {
        static::$app->bind($abstract, $factory);
        return static::$app->make($abstract);
    }

    protected function partialMock(string $abstract, callable $callback): object
    {
        $mock = \Mockery::mock($abstract)->makePartial();
        $callback($mock);
        static::$app->instance($abstract, $mock);
        return $mock;
    }

    protected function tearDown(): void
    {
        \Mockery::close(); // verify Mockery expectations and clean up
        parent::tearDown();
    }
}

// Test using container swap
class OrderControllerTest extends TestCase
{
    use RefreshDatabase;

    public function test_creating_order_queues_confirmation(): void
    {
        $queueFake = new QueueFake();
        static::$app->instance(\Framework\Queue\QueueManager::class, $queueFake);

        $user = User::factory()->create();
        $this->actingAs($user)->postJson('/api/orders', ['product_id' => 1]);

        $queueFake->assertPushed(\App\Jobs\SendOrderConfirmation::class);
    }

    public function test_uses_mock_payment_service(): void
    {
        $mock = \Mockery::mock(\App\Services\PaymentService::class);
        $mock->shouldReceive('charge')->once()->andReturn(['status' => 'success', 'id' => 'pay_123']);
        static::$app->instance(\App\Services\PaymentService::class, $mock);

        $user = User::factory()->create();
        $this->actingAs($user)->postJson('/api/orders', ['product_id' => 1])->assertCreated();
    }
}