Mocking Stripe

I recently was searching for a way to mock Stripe API calls with Laravel Cashier in my test suite.

I came across this issue, which had a great idea.

In this scenario, we replace the HTTP Client used by the Stripe PHP SDK so we don't make any HTTP requests. Then we do some work to return some pre-created responses (fixtures, I suppose we'll call those).

To create the fixtures, I went to the Stripe API docs and copied/pasted the JSON responses expected for any particular calls.

In my case, there were three calls to the Stripe API:

  1. Creating a customer
  2. Retrieving a customer
  3. Creating a Stripe Checkout session

The Test

Here's what one of the tests looked like. Obviously this could be abstracted some more, but this served my purposes just fine.

In my case, I'm testing creating a Stripe Session, which is a required step before redirecting a user to a hosted Stripe Checkout page.

Here is file tests/Feature/CreateStripeSessionTest.php:

<?php

namespace Tests\Feature;

use App\Models\User;

use Stripe\ApiRequestor;
use Stripe\HttpClient\ClientInterface;

use Laravel\Jetstream\Jetstream;

use Tests\TestCase;
use Illuminate\Support\Str;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class CreateStripeSessionTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function creates_stripe_session_and_redirects()
    {
        // Create the mock HTTP client used by Stripe
        $mockClient = new MockClient;
        ApiRequestor::setHttpClient($mockClient);

        $user = User::factory()->create([
            'stripe_id' => 'cus_'.Str::random(),
        ]);

        $response = $this->actingAs($user)->post(route('create-session'), [
            'intent' => 'some-meta-data-required',
        ]);

        $response->assertRedirect($mockClient->url)
    }
}

// Mock the Stripe API HTTP Client

# Optionally extend Stripe\HttpClient\CurlClient
class MockClient implements ClientInterface
{
    public $rbody = '{}';
    public $rcode = 200;
    public $rheaders = [];
    public $url;

    public function __construct() {
        $this->url = "https://checkout.stripe.com/pay/cs_test_".Str::random(32);
    }

    public function request($method, $absUrl, $headers, $params, $hasFile)
    {
        // Handle Laravel Cashier creating/getting a customer
        if ($method == "get" && strpos($absUrl, "https://api.stripe.com/v1/customers/") === 0) {
            $this->rBody = $this->getCustomer(str_replace("https://api.stripe.com/v1/customers/", "", $absUrl));
            return [$this->rBody, $this->rcode, $this->rheaders];
        }

        if ($method == "post" && $absUrl == "https://api.stripe.com/v1/customers") {
            $this->rBody = $this->getCustomer("cus_".Str::random(14));
            return [$this->rBody, $this->rcode, $this->rheaders];
        }

        // Handle creating a Stripe Checkout session
        if ($method == "post" && $absUrl == "https://api.stripe.com/v1/checkout/sessions") {
            $this->rBody = $this->getSession($this->url);
            return [$this->rBody, $this->rcode, $this->rheaders];
        }        

        return [$this->rbody, $this->rcode, $this->rheaders];
    }

    protected function getCustomer($id) {
        return <<<JSON
{
  "id": "$id",
  "object": "customer",
  "address": null,
  "balance": 0,
  "created": 1626897363,
  "currency": "usd",
  "default_source": null,
  "delinquent": false,
  "description": null,
  "discount": null,
  "email": null,
  "invoice_prefix": "61F72E0",
  "invoice_settings": {
    "custom_fields": null,
    "default_payment_method": null,
    "footer": null
  },
  "livemode": false,
  "metadata": {},
  "name": null,
  "next_invoice_sequence": 1,
  "phone": null,
  "preferred_locales": [],
  "shipping": null,
  "tax_exempt": "none"
}
JSON;

    }

    protected function getSession($url)
    {
        return <<<JSON
{
  "id": "cs_test_V9Gq09dEmaJ2p3tydHonjbPSr3eq3mfOn52UBVbppDLVEFQfOji1uZok",
  "object": "checkout.session",
  "allow_promotion_codes": null,
  "amount_subtotal": null,
  "amount_total": null,
  "automatic_tax": {
    "enabled": false,
    "status": null
  },
  "billing_address_collection": null,
  "cancel_url": "https://example.com/cancel",
  "client_reference_id": null,
  "currency": null,
  "customer": null,
  "customer_details": null,
  "customer_email": null,
  "livemode": false,
  "locale": null,
  "metadata": {},
  "mode": "subscription",
  "payment_intent": "pi_1DoyrW2eZvKYlo2CHqEodB86",
  "payment_method_options": {},
  "payment_method_types": [
    "card"
  ],
  "payment_status": "unpaid",
  "setup_intent": null,
  "shipping": null,
  "shipping_address_collection": null,
  "submit_type": null,
  "subscription": null,
  "success_url": "https://example.com/success",
  "total_details": null,
  "url": "$url"
}
JSON;

    }
}

Some Details

There's a few things to note.

First, we tell Stripe to use our mock HTTP client (luckily it's set globally):

$mockClient = new MockClient;
ApiRequestor::setHttpClient($mockClient);

The MockClient test implements the request() method as required by the interface. Here we just "sniff" out the various requests sent to the API and send some fake responses.

To figure out the calls made for the test, I just var_dump()'ed the method parameters:

public function request($method, $absUrl, $headers, $params, $hasFile)
{
    // Figure out what API calls Laravel Cashier is making
    // for a given test
    dd($method, $absUrl, $headers, $params, $hasFile);
}

Then I made mock/fake/fixture/whatever responses for those calls based on the API reference (literally just copying/pasting the JSON).

Under the hood, Stripe takes the object parameter in the JSON response from their API and maps it to a PHP class. So returned JSON "object": "checkout.session" becomes an instance of Stripe\Checkout\Session.

The redirect my specific test includes is the URL returned as a parameter when creating a Checkout Session. This sends the user off to a hosted Stripe Checkout page.

The Offical Way

There's a more Technically Correctâ„¢ way to do this, although I discovered this after I already setup the above and didn't change it.

Stripe has a Golang based project named Stripe Mock that runs a test version of the Stripe API. This project aims to return "approximately correct API response for any endpoint".

To use this, you can download a binary (or run it in Docker), and then have the Stripe client object use that for it's API endpoint.

Here's an article on using Stripe Mock, and even how to set it up in GitHub Actions.

Locally I did something like this to play with it:

# In one terminal window
docker run --rm -it -p 12111-12112:12111-12112 \
    stripemock/stripe-mock:latest

# In another terminal window
curl -i -X POST http://localhost:12111/v1/customers \
    -H "Authorization: Bearer sk_test_123" \
    -d 'name=Chris Fidao' -d 'email=foo@example.com'

HTTP/1.1 200 OK
Request-Id: req_123
Stripe-Mock-Version: 0.109.0
Date: Thu, 22 Jul 2021 11:44:24 GMT
Content-Length: 589
Content-Type: text/plain; charset=utf-8

{
  "address": null,
  "balance": 0,
  "created": 1234567890,
  "currency": "usd",
  "default_source": null,
  "delinquent": false,
  "description": null,
  "discount": null,
  "email": "foo@example.com",
  "id": "cus_H42rveoStCxpP4E",
  "invoice_prefix": "40BEC7C",
  "invoice_settings": {
    "custom_fields": null,
    "default_payment_method": null,
    "footer": null
  },
  "livemode": false,
  "metadata": {},
  "name": "Chris Fidao",
  "next_invoice_sequence": 1,
  "object": "customer",
  "phone": null,
  "preferred_locales": [],
  "shipping": null,
  "tax_exempt": "none"
}

Pretty neat! I would use this method for testing if I had a lot of tests that hit the Stripe API.