Container swapping in tests — binding mocks
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.
MailFakestores sent mails in memory instead of sending.QueueFakestores 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
// 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();
}
}