Simplifying work with custom stubs in Laravel


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;
}
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.');
}
}
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]);
}
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,
]);
}
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();
}
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);
}
}
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,
]);
}
}
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')
);
});
}
}
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 ๐.
Subscribe to my newsletter
Read articles from Geni Jaho directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Geni Jaho
Geni Jaho
Full-stack web dev with a passion for software architecture and programming best coffees.