The recommended way to write tests for Stripe's API integration often involves tests that hit a mock server or the live server but with test keys — as documented here by my favourite framework.
The downside to this is obviously having slow tests. There are limitations and caveats to using the official Stripe testing mechanism, some of which are:
- Testing for specific responses and errors is not possible currently
- Inability to retrieve polymorphic resources.
- Apart from functionality, in terms of tooling, you are required to install and use Go and Docker
Let's try a quicker way to execute these tests in PHP while avoiding most of these limitations.
Whether you are in a vanilla PHP project, or a framework like Laravel (Cashier or Spark), Symfony, etc — you can still follow along.
Mock Stripe's HTTP Client
Stripe uses an HTTP client, the Stripe\HttpClient\CurlClient
class, to make HTTP requests to the Stripe API and format the responses.
When we mock this client, we will have the ability to intercept the requests to the live API and return responses that we would expect, then Stripe will use the client's response to return the appropriate Stripe objects.
In our test suite, let's create our own client MockStripeClient
that should implement Stripe's client interface:
<?php
namespace Tests;
use Stripe\HttpClient\ClientInterface;
class MockStripeClient implements ClientInterface
{
/**
* @inheritDoc
*/
public function request($method, $absUrl, $headers, $params, $hasFile): array
{
return [{}, 200, []];
// Array of the API response body, response code and response headers
}
}
The interface will force us to add a request
method which is where all the magic and customization happens.
Since we are only going to alter the response body we will initialize it with a value while the response code and headers remain constants:
<?php
namespace Tests;
use Stripe\HttpClient\ClientInterface;
class MockStripeClient implements ClientInterface
{
const CODE = 200;
const HEADER = [];
public function __construct(
private string $rbody = '{}',
){}
/**
* @inheritDoc
*/
public function request($method, $absUrl, $headers, $params, $hasFile): array
{
return [$this->rbody, self::CODE, self::HEADER];
}
}
Typically, you would need to be aware which of all the Stripe resources your integration is interacting with, these include: Customers, PaymentIntents, Charges, Balances, Refunds etc in order to prevent your tests from interacting with the API.
Let's assume somewhere in our integration code we are ONLY creating customers, creating payment intents and retrieving payment intents to charge credit cards. Our request implementation will look like this:
public function request($method, $absUrl, $headers, $params, $hasFile): array
{
if (strtolower($method) === 'post' && $absUrl === 'https://api.stripe.com/v1/customers') {
$this->rbody = $this->createCustomer();
}
if (strtolower($method) === 'post' && $absUrl === 'https://api.stripe.com/v1/payment_intents') {
$this->rbody = $this->createPaymentIntent();
}
if (strtolower($method) === 'get' && str_contains($absUrl, 'https://api.stripe.com/v1/payment_intents/')) {
$this->rbody = $this->createPaymentIntent();
}
return [$this->rbody, self::CODE, self::HEADER];
}
private function createCustomer(): string
{
//
}
public function createPaymentIntent(): string
{
// Later we will see why this should be public
}
The first if
statement intercepts Stripe's request to create a customer, the second intercepts the request to create payment intents and the last to retrieve payment intents.
What we return in the create methods are JSON responses. For these examples we will copy the sample customer and payment intent JSON objects in the API documentation:
private function createCustomer(): string
{
return <<<JSON
{
"id": "cus_AJ6yEs79rUDTXH",
"object": "customer",
"address": null,
"balance": 0,
"created": 1489794306,
"currency": "usd",
"default_source": "ba_1KYy8t2eZvKYlo2CmoRl2hDg",
"delinquent": false,
"description": "Sample user",
"discount": null,
"email": "pattie.satterfield@example.com",
"invoice_prefix": "44E2E64",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": "pm_1KYy5m2eZvKYlo2CVUMn7KMW",
"footer": null
},
"livemode": false,
"metadata": {
"order_id": "1234"
},
"name": null,
"next_invoice_sequence": 57847,
"phone": null,
"preferred_locales": [],
"shipping": null,
"tax_exempt": "none"
}
JSON;
}
public function createPaymentIntent(): string
{
return <<<JSON
{
"id": "pi_1Dq2OK2eZvKYlo2Cus7PfPyD",
"object": "payment_intent",
"amount": 2000,
"amount_capturable": 0,
"amount_received": 0,
"application": null,
"application_fee_amount": null,
"automatic_payment_methods": null,
"canceled_at": null,
"cancellation_reason": null,
"capture_method": "automatic",
"charges": {
"object": "list",
"data": [],
"has_more": false,
"url": "/v1/charges?payment_intent=pi_1Dq2OK2eZvKYlo2Cus7PfPyD"
},
"client_secret": "pi_1Dq2OK2eZvKYlo2Cus7PfPyD_secret_F5OWRN1wvA0YpbHMUcTqu11l1",
"confirmation_method": "automatic",
"created": 1546884000,
"currency": "usd",
"customer": null,
"description": null,
"invoice": null,
"last_payment_error": null,
"livemode": false,
"metadata": {},
"next_action": null,
"on_behalf_of": null,
"payment_method": null,
"payment_method_options": {},
"payment_method_types": [
"card"
],
"processing": null,
"receipt_email": null,
"review": null,
"setup_future_usage": null,
"shipping": null,
"statement_descriptor": null,
"statement_descriptor_suffix": null,
"status": "requires_payment_method",
"transfer_data": null,
"transfer_group": null
}
JSON;
}
At this point we're ready to use our mock stripe client.
But in order to use it only when necessary, let's add one function mockStripe
in our base TestCase
class that can be called from all other test classes:
<?php
namespace Tests;
// use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
// If this was a Laravel example we would use the base above, instead we can use the one below
use PHPUnit\Framework\TestCase as BaseTestCase;
use Stripe\ApiRequestor;
use MockStripeClient;
abstract class TestCase extends BaseTestCase
{
public function mockStripe()
{
ApiRequestor::setHttpClient(new MockStripeClient);
}
}
When the mockStripe
method is called in our tests, we will command Stripe to use our mock client instead of the one it uses to make requests to the live API. Anywhere in our tests where we know Stripe will be used we can call:
<?php
namespace Tests\Feature;
use Tests\TestCase;
class CheckoutControllerTest extends TestCase
{
public function a_new_stripe_customer_will_be_created()
{
$this->mockStripe();
// continue testing code that creates a stripe customer
}
}
Custom Stripe Object Responses (optional)
To have total control and confidence on our tests we should be able to customize the JSON responses depending on what we are expecting and not have the same objects returned all the time.
Scenario: 1
Let's assume we want to customize the id
returned on both customer and payment intent objects.
- We start by requiring a
resourceId
property in theMockStripeClient
constructor:
public function __construct(
private string $rbody = '{}',
private string $resourceId,
){}
- Change the create methods to use the custom ID:
private function createCustomer(): string
{
return <<<JSON
{
"id": "cus_$this->resourceId",
"object": "customer",
}
JSON;
}
public function createPaymentIntent(): string
{
return <<<JSON
{
"id": "pi_$this->resourceId",
"object": "payment_intent",
"charges": {
"object": "list",
"data": [],
"has_more": false,
"url": "/v1/charges?payment_intent=pi_$this->resourceId"
},
}
JSON;
}
- Send the ID from the
TestCase
and the test class:
abstract class TestCase extends BaseTestCase
{
public function mockStripe(string $id)
{
ApiRequestor::setHttpClient(new MockStripeClient(
resourceId: $id,
));
}
}
class CheckoutControllerTest extends TestCase
{
/** @test */
public function a_new_stripe_customer_will_be_created()
{
$fakeCustomerId = 'G3E99LH8110';
$this->mockStripe($fakeCustomerId);
// continue testing code that creates a stripe customer
}
}
In the example above, we are setting a custom ID that will be required when creating customers and payment intents.
Scenario: 2
Let's imagine another situation where we are sending a custom array that will alter certain fields of the payment intent object.
- Collect optional payment intent data in the client:
public function __construct(
private string $rbody = '{}',
private string $resourceId,
private array|null $intentData = null,
){}
- Modify the
createPaymentIntent
method to update the fields you want:
use Stripe\PaymentIntent;
public function createPaymentIntent(): string
{
$status = $this->intentData['status'] ?? PaymentIntent::STATUS_SUCCEEDED;
$amount = $this->intentData['amount'] ?? 900;
return <<<JSON
{
"id": "pi_$this->resourceId",
"object": "payment_intent",
"amount": $amount,
"amount_capturable": 0,
"amount_received": 0,
"application": null,
"application_fee_amount": null,
"automatic_payment_methods": null,
"canceled_at": null,
"cancellation_reason": null,
"capture_method": "automatic",
"charges": {
"object": "list",
"data": [],
"has_more": false,
"url": "/v1/charges?payment_intent=pi_$this->resourceId"
},
"client_secret": "pi_1Dq2OK2eZvKYlo2Cus7PfPyD_secret_F5OWRN1wvA0YpbHMUcTqu11l1",
"confirmation_method": "automatic",
"created": 1546884000,
"currency": "usd",
"customer": null,
"description": null,
"invoice": null,
"last_payment_error": null,
"livemode": false,
"metadata": {},
"next_action": null,
"on_behalf_of": null,
"payment_method": null,
"payment_method_options": {},
"payment_method_types": [
"card"
],
"processing": null,
"receipt_email": null,
"review": null,
"setup_future_usage": null,
"shipping": null,
"statement_descriptor": null,
"statement_descriptor_suffix": null,
"status": "$status",
"transfer_data": null,
"transfer_group": null
}
JSON;
}
- Send the custom payment intent data in the
mockStripe
method:
abstract class TestCase extends BaseTestCase
{
public function mockStripe(string $id, array|null $intent = null)
{
ApiRequestor::setHttpClient(new MockStripeClient(
resourceId: $id,
intentData: $intent,
));
}
}
use Stripe\PaymentIntent;
class CheckoutControllerTest extends TestCase
{
/** @test */
public function a_user_will_be_redirected_to_checkout()
{
$fakePaymentIntentId = 'F2Y97LO2140';
$this->mockStripe($fakePaymentIntentId, [
'amount' => 10000,
'status' => PaymentIntent::STATUS_PROCESSING,
]);
// continue testing code that creates a payment intent
}
}
Scenario: 3
In this last example we may have a situation in our codebase where we want to use the Stripe object of the payment intent Stripe\PaymentIntent
.
Why do we need it? Sometimes we may want to query the status of a payment if it was successful in the background, and so it is necessary to have a sample of this object generated by fake data in our tests.
- Follow the steps in scenario 2 above
- In the test class, create the object using the mock client since we have the public method for creating payment intents:
use Stripe\PaymentIntent;
use Tests\MockStripeClient;
use Tests\TestCase;
class QueryPaymentStatusTest extends TestCase
{
/** @test */
public function a_stripe_payment_status_can_be_updated()
{
// test code before fetching the fake payment intent
// retrieve a fake payment intent
$mockApiResponse = (new MockStripeClient(resourceId: $fakeIntentId, intentData: [
'amount' => 12000,
'status' => PaymentIntent::STATUS_SUCCEEDED,
]))
->createPaymentIntent();
$intent = (new PaymentIntent($fakeIntentId))::constructFrom(
json_decode($mockApiResponse, true)
);
// code that tests the $intent object has a succeeded status
}
}
Note: We have tried to generate a fake API response using our mock client, then using the response to create a Stripe\PaymentIntent
object using the constructFrom
static method from Stripe.
Conclusion
This implemetation will make the execution of your Stripe tests complete a lot faster than the current traditional approach.
One may argue that this approach may introduce alot of if
statements in the client's request
method. But if you want to avoid that because of interacting with many Stripe objects, it is possible to create as many clients for every Stripe object and that will help in writing cleaner code.
ApiRequestor::setHttpClient(new MockStripeCustomerClient(...));
ApiRequestor::setHttpClient(new MockStripeRefundClient(...));
ApiRequestor::setHttpClient(new MockStripePaymentIntentClient(...));
// etc
This would also prevent one mock client from having lengthy code because of the long JSON string responses.
Join to participate in the discussion