Testing Payment APIs
How to make testing Payment APIs fast, easy and clean
In testing, both mocks and fakes are used to replace real implementations of dependencies, but they serve different purposes and are used in different contexts.
Mock: A mock is an object that is used to verify that certain interactions occur. It is typically used to check that a method is called with specific parameters. Mocks are often used in unit tests to ensure that the code under test interacts correctly with its dependencies.
Fake: A fake is a simpler implementation of a dependency that is used to make the test run faster or more reliably. Fakes are often used in integration tests to replace complex or slow dependencies with simpler versions that behave in a predictable way.
Let’s say you have a PaymentController that looks something like this:
<?php
namespace App\Http\Controllers;
use App\Http\Requests\PaymentRequest;
use Domain\Payments\Braintree\BraintreeService;
use Illuminate\Http\Resources\Json\JsonResource;
class PaymentController extends Controller
{
public function __construct(
private BraintreeService $service,
) {}
public function __invoke(PaymentRequest $request): JsonResource
{
$resource = $this->service->createPayment($request->validated());
return JsonResource::make($resource);
}
}
And you might have a simple Braintree Payment Service that looks like this.
<?php
declare(strict_types=1);
namespace Domain\Payments\Braintree;
class HttpBraintreeGateway implements BraintreeService
{
public function __construct(
protected Gateway $client
) {}
public static function make(): static
{
return app(static::class);
}
/**
* Ideally, you'd have a type for payment here,
* and this would return the Braintree result.
*/
public function createPayment(array $payment)
{
return $this->client->transaction()->sale($payment);
}
}
Invoking the Real Braintree HTTP API
Normally you’d write a test like this, and it provides a level of confidence that the Braintree HTTP API is working as expected.
The downside is that it would be quite slow as it uses a real network request.
#[Test]
public function can_create_payment(): void
{
$response = $this->json('POST', '/api/payments', [
'order_id' => 12345,
'payment_method_token' => 'tok_visa',
]);
$response->assertStatus(200);
}
Creating a Mock for the Braintree HTTP API
The next step is to create a mock for the Braintree HTTP API.
This test has some downsides. Mocked tests can be hard to refactor, and they can become a bit tedious when a service depends on multiple things.
They can be fragile, as they are tightly coupled to the implementation of the class they are testing.
Adam Wathan has a really interesting talk about unit testing APIs like this, and is highly recommended.
#[Test]
public function can_create_a_payment_via_mock(): void
{
// You'd probably have a binding in your AppServiceProvider like this:
// App\Providers\AppServiceProvider::register()
$this->app->instance(BraintreeService::class, new HttpBraintreeGateway);
$this->mock(BraintreeService::class)
->expects('createPayment')
->with([
'order_id' => 12345,
'payment_method_token' => 'tok_visa',
]);
$response = $this->json('POST', '/api/payments', [
'order_id' => 12345,
'payment_method_token' => 'tok_visa',
]);
$response->assertStatus(200);
}
Using a Fake Object for the Braintree HTTP API
The next stage of evolution is to introduce a set of Fake objects that can be switched out during test runtime, and prevents the network request from being sent.
While this fake takes a bit more work to set up, the effort is probably worth it in the long run.
We can create a Braintree Interface.
<?php
declare(strict_types=1);
namespace Domain\Payments\Braintree;
interface BraintreeService
{
public static function fake(): BraintreeService;
public function createPayment(array $payment): void;
}
We can add a fake() method to our existing Braintree Service.
<?php
declare(strict_types=1);
namespace Domain\Payments\Braintree;
class HttpBraintreeGateway implements BraintreeService
{
public static function fake(): BraintreeService
{
$inMemoryService = new InMemoryBraintreeGateway;
app()->instance(BraintreeService::class, $inMemoryService);
return $inMemoryService;
}
}
And we can create an InMemory Service that keeps track of the API requests that would be sent to Braintree, making testing easier.
<?php
declare(strict_types=1);
namespace Domain\Payments\Braintree;
use Illuminate\Testing\Assert;
class InMemoryBraintreeGateway implements BraintreeService
{
public array $payments = [];
public static function make(): static
{
return app(static::class);
}
public static function fake(): BraintreeService
{
$self = new static;
app()->instance(BraintreeService::class, $self);
return $self;
}
/**
* Ideally, you'd have a type for payment here
*/
public function createPayment(array $payment): void
{
$this->payments[] = $payment;
}
public function assertPaymentReceived(array $payment)
{
$hasReceivedPayment = in_array($payment, $this->payments, true);
Assert::assertTrue($hasReceivedPayment, 'The expected payment address was not received.');
}
}
Having an assertPaymentReceived method on the fake object allows us to verify that the payment was received.
#[Test]
public function it_creates_a_payment_using_a_fake_object(): void
{
$inMemoryService = new InMemoryBraintreeGateway;
$this->app->instance(BraintreeService::class, $inMemoryService);
$response = $this->json('POST', '/api/payments', [
'order_id' => 12345,
'payment_method_token' => 'tok_visa',
]);
$response->assertStatus(200);
$inMemoryService->assertPaymentReceived([
'order_id' => 12345,
'payment_method_token' => 'tok_visa',
]);
}
Using a Fake Facade for a Little More Laravel Magic
#[Test]
public function it_creates_a_payment_via_fake_object_with_more_elegance(): void
{
$inMemoryService = HttpBraintreeGateway::fake();
$response = $this->json('POST', '/api/payments', [
'order_id' => 12345,
'payment_method_token' => 'tok_visa',
]);
$response->assertStatus(200);
$inMemoryService->assertPaymentReceived([
'order_id' => 12345,
'payment_method_token' => 'tok_visa',
]);
}
We can add a couple more assertions to ensure the bindings work as expected.
#[Test]
public function ensure_braintree_service_has_correct_default_implementation(): void
{
$this->assertInstanceOf(HttpBraintreeGateway::class, $this->app->make(HttpBraintreeGateway::class));
}
#[Test]
public function will_swap_the_default_implementation(): void
{
$this->assertInstanceOf(InMemoryBraintreeGateway::class, HttpBraintreeGateway::fake());
}
You can find the full code for this example in the tests directory.
Laravel’s Built-in Fake Classes
The fake pattern isn’t unique to our custom implementation — Laravel ships with a rich set of built-in fakes that follow exactly this convention. Calling ::fake() on a facade swaps the underlying implementation for an in-memory one and returns it so you can assert against it:
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
Mail::fake(); // Prevent real emails from being sent
Queue::fake(); // Prevent jobs from being dispatched to the queue
Event::fake(); // Prevent events from firing
Notification::fake(); // Prevent notifications from being sent
Http::fake(); // Intercept outbound HTTP calls
Storage::fake('s3'); // Use an in-memory disk instead of S3
Each fake records all interactions it receives, then exposes assertion helpers so you can verify your code behaved correctly:
#[Test]
public function it_sends_a_payment_receipt_email(): void
{
Mail::fake();
$this->json('POST', '/api/payments', [
'order_id' => 12345,
'payment_method_token' => 'tok_visa',
])->assertStatus(200);
Mail::assertSent(PaymentReceiptMail::class, fn ($mail) =>
$mail->hasTo('customer@example.com')
);
Mail::assertSentCount(1);
}
You can also assert the inverse — that nothing was sent — to guard against accidental side effects:
Mail::assertNothingSent();
Queue::assertNothingPushed();
Event::assertNothingDispatched();
When to Use Each Approach
| Approach | Best For |
|---|---|
| Real HTTP | End-to-end confidence against a sandbox environment |
| Mock | Verifying a specific method is called with exact arguments |
Custom Fake (InMemoryBraintreeGateway) | Complex integrations with many methods; reusable across many tests |
Http::fake() | Integrations using Laravel’s HTTP client; simulating failure sequences |
| Laravel built-in fakes | Mail, queues, events, notifications, and storage |
The custom InMemoryBraintreeGateway approach shines when the third-party service has many methods and you want a stable, reusable test double that can grow alongside your integration. It also gives you full control over what assertion helpers are exposed. For simpler integrations built on Laravel’s Http client, Http::fake() is faster to set up and requires no additional classes.
The key insight is that Laravel’s own fakes follow the exact same design: a static fake() method replaces the real binding in the service container, the fake records interactions, and assertion methods let you verify those interactions after the fact. Building your own fakes in this style keeps everything consistent and readable for anyone already familiar with writing Laravel tests.
Happy testing!