Testing Payment APIs
Testing Payment APIs
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.
Lets say you 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 little 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 intersting talk about unit testing api's 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 Braintee 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 create 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 a 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.
Happy testing!
