Simplifying work with custom stubs in Laravel

Search for a command to run...

No comments yet. Be the first to comment.
We're upgrading from Laravel 9 to 10, and PHPUnit 9 to 10, and, if all goes well, we'll top it off by migrating to Pest, the latest version. To keep the positive vibes on I'll skip an upgrade from PHP 8.1 to 8.2, just because I don't want to restart ...

GitHub's 2000 minutes per month of free usage of GitHub Actions is usually enough to handle light workflows. However, as more tech is run automatically, we are forced to upgrade to a paid plan or optimize our workflows. Currently, I am running Rector...

A nice refactoring I've learned recently is to utilize types in conditionals. For years I’ve been doing if (!empty($posts)), but Rector, a tool to automatically refactor code, changed that to if ($posts === []). The main advantage of using $posts ===...

Laravel 10 is making the headlines these days. However, many apps are stuck in older versions, or at least still use the syntax and methods of older releases. One of the most significant changes introduced in Laravel 9 is the syntax for accessors and...

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.
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
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;
}
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'];
}
}
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.');
}
}
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]);
}
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,
]);
}
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();
}
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);
}
}
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,
]);
}
}
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')
);
});
}
}
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.
I'm not going to list any more benefits of using stubs, but let me say, you'll definitely have fun with them 😁.