Simplifying work with custom stubs in Laravel
Geni Jaho

Geni Jaho @genijaho

About: Full-stack web developer with a passion for software architecture and cloud computing.

Joined:
Jun 19, 2021

Simplifying work with custom stubs in Laravel

Publish Date: May 17
1 0

Testing is almost always difficult when developing applications that interact with external services, APIs, or complex features. One way to make testing easier is to use stub classes. Here's how I usually work with them.

Quick intro on benefits

Stubs are fake implementations of interfaces or classes that simulate the behavior of real services. They allow you to:

  • Test code without calling external services
  • Work locally without API keys
  • Speed up tests by avoiding expensive API calls
  • Create predictable test scenarios

External Accounting Service Example

Let's look at a simple interface for an external accounting service. In reality, you don't even need an interface to do this, but it makes it easier to swap implementations, and to keep them in sync.

interface ExternalAccountingInterface
{
    public function createRecord(array $data): string;
}
Enter fullscreen mode Exit fullscreen mode

Here's a real implementation that would call an external API:

class ExternalAccounting implements ExternalAccountingInterface
{
    public function __construct(
        private readonly HttpClient $client,
        private readonly string $apiKey,
    ) {}

    public function createRecord(array $data): string
    {
        $response = $this->client->post("https://api.accounting-service.com/v1/records", [
            'headers' => [
                'Authorization' => "Bearer {$this->apiKey}",
                'Content-Type' => 'application/json',
            ],
            'json' => $data,
        ]);

        $responseData = json_decode($response->getBody(), true);

        return $responseData['record_id'];
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, here's a fake implementation for testing:

class FakeExternalAccounting implements ExternalAccountingInterface
{
    private array $createdRecords = [];
    private bool $hasEnoughCredits = true;

    public function createRecord(array $data): string
    {
        if (! $this->hasEnoughCredits) {
            throw new InsufficientCreditsException("Not enough credits to create a record");
        }

        $recordId = Str::uuid();

        $this->createdRecords[$recordId] = $data;

        return $recordId;
    }

    // Edge case simulation
    public function withNotEnoughCredits(): self
    {
        $this->hasEnoughCredits = false;
        return $this;
    }

    // Helper methods for assertions
    public function assertRecordsCreated(array $eventData): void
    {
        Assert::assertContains(
            $eventData,
            $this->createdRecords,
            'Failed asserting that the record was created with the correct data.'
        );
    }

    public function assertNothingCreated(): void
    {
        Assert::assertEmpty($this->createdRecords, 'Records were created unexpectedly.');
    }
}
Enter fullscreen mode Exit fullscreen mode

Before and After: Refactoring to Use Stubs

Before: Using Mockery

public function testCreateAccountingRecord(): void
{
    // Create a mock using Mockery
    $accountingMock = $this->mock(ExternalAccountingInterface::class);

    // Set expectations
    $accountingMock->shouldReceive('createRecord')
        ->once()
        ->with(Mockery::on(function ($data) {
            return isset($data['type']) && $data['type'] === 'invoice' &&
                   isset($data['amount']) && $data['amount'] === 99.99;
        }))
        ->andReturn('rec_123456');

    // Bind the mock
    $this->swap(ExternalAccountingInterface::class, $accountingMock);

    // Execute the test
    $response = $this->post('/api/invoices', [
        'product_id' => 'prod_123',
        'amount' => 99.99,
    ]);

    // Assert the response
    $response->assertStatus(200);
    $response->assertJson(['success' => true]);
}
Enter fullscreen mode Exit fullscreen mode

After: Using the Stub

public function testCreateAccountingRecord(): void
{
    // Create an instance of our custom stub
    $fakeAccounting = new FakeExternalAccounting;

    // Bind the stub
    $this->swap(ExternalAccountingInterface::class, $fakeAccounting);

    // Execute the test
    $response = $this->post('/api/invoices', [
        'product_id' => 'prod_123',
        'amount' => 99.99,
    ]);

    // Assert the response
    $response->assertStatus(200);
    $response->assertJson(['success' => true]);

    // Assert that records were created with the expected data
    $fakeAccounting->assertRecordsCreated([
        'type' => 'invoice',
        'amount' => 99.99,
    ]);
}
Enter fullscreen mode Exit fullscreen mode

Testing Edge Cases

Custom stubs make it easy to test edge cases and error scenarios:

public function testInvoiceFailsWhenNotEnoughCredits(): void
{
    // Create an instance of our custom stub
    $fakeAccounting = new FakeExternalAccounting;

    // Configure the stub to simulate not enough credits
    $fakeAccounting->withNotEnoughCredits();

    // Bind the stub
    $this->swap(ExternalAccountingInterface::class, $fakeAccounting);

    // Execute the test expecting a failure
    $response = $this->post('/api/invoices', [
        'product_id' => 'prod_123',
        'amount' => 99.99,
    ]);

    // Assert the response handles the failure correctly
    $response->assertStatus(422);
    $response->assertJson(['error' => 'Insufficient credits']);

    // Assert that no records were created
    $fakeAccounting->assertNothingCreated();
}
Enter fullscreen mode Exit fullscreen mode

Swapping Stubs in Your Base Test Case

To avoid having to swap implementations in every test, you can set up your stubs in your base test case:

class TestCase extends BaseTestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        // Create and register the stub for all tests
        $this->swap(ExternalAccountingInterface::class, new FakeExternalAccounting);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now in your tests, you can directly use the stub without having to register it, and you don't have to worry about accidentally using the real implementation, and forgetting to swap it.

class InvoiceTest extends TestCase
{
    public function testCreateInvoice(): void
    {
        // The accounting service is already swapped in the base test case
        // Just get it from the container
        $fakeAccounting = app(ExternalAccountingInterface::class);

        // Execute the test
        $response = $this->post('/api/invoices', [
            'product_id' => 'prod_123',
            'amount' => 99.99,
        ]);

        // Assert the response
        $response->assertStatus(200);

        // Use the stub's assertion methods
        $fakeAccounting->assertRecordsCreated([
            'type' => 'invoice',
            'amount' => 99.99,
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Using Stubs During Local Development

Custom stubs aren't just useful for testing; they can also improve your local DX. Here's how to use them during development to avoid hitting API rate limits or needing API keys:

// In a service provider
public function register(): void
{
    // Only use the mock in local environment
    if ($this->app->environment('local')) {
        $this->app->bind(ExternalAccountingInterface::class, function () {
            return new FakeExternalAccounting;
        });
    } else {
        // Use the real implementation in other environments
        $this->app->bind(ExternalAccountingInterface::class, function (Application $app) {
            return new ExternalAccounting(
                $app->make(HttpClient::class),
                config('services.accounting.api_key')
            );
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

With this setup, your local development environment will use the fake implementation, allowing you to work without an API key and without worrying about hitting rate limits. When deployed to staging or production, the application will use the real implementation.

The best part: you can have more than one fake implementation, so you can have a different one for local development and one for running tests.

So try them out!

I'm not going to list any more benefits of using stubs, but let me say, you'll definitely have fun with them 😁.

Comments 0 total

    Add comment