Hedam
Hedam

Reputation: 2259

Testing Stripe in Laravel

I'm creating a subscription-based SaaS platform in Laravel, where Laravel Cashier does not suit my needs. Therefore I need to implement the subscription-engine myself using the Stripe library.

I found it easy to implement the connection between Laravel and Stripe via hooking into the creation and deletion events of a Subscription class, and then create or cancel a Stripe subscription accordingly.

The Stripe library is unfortunately largely based on calling static methods on some predefined classes (.. like \Stripe\Charge::create()).

This makes it hard for me to test, as you normally would allow dependency injection of some custom client for mocking, but since the Stripe library is referenced statically, there is no client to inject. Is there any way of creating a Stripe client class or such, that I can mock?

Upvotes: 6

Views: 3929

Answers (2)

Mike
Mike

Reputation: 56

Based off Colin's answer, here is an example that uses a mocked interface to test creating a subscription in Laravel 8.x.

    /**
     * @test
     */
    public function it_subscribes_to_an_initial_plan()
    {
        $client = \Mockery::mock(ClientInterface::class);

        $paymentMethodId = Str::random();

        /**
         * Creates initial customer...
         */
        $customerId = 'somecustomerstripeid';
        $client->shouldReceive('request')
            ->withArgs(function ($method, $path, $params, $opts) use ($paymentMethodId) {
                return $path === "https://api.stripe.com/v1/customers";
            })->andReturn([
                "{\"id\": \"{$customerId}\" }", 200, []
            ]);


        /**
         * Retrieves customer
         */
        $client->shouldReceive('request')
            ->withArgs(function ($method, $path, $params) use ($customerId) {
                return $path === "https://api.stripe.com/v1/customers/{$customerId}";
            })->andReturn([
                "{\"id\": \"{$customerId}\", \"invoice_settings\": {\"default_payment_method\": \"{$paymentMethodId}\"}}", 200, [],
            ]);

        /**
         * Set payment method
         */
        $client->shouldReceive('request')
            ->withArgs(function ($method, $path, $params) use ($paymentMethodId) {
                return $path === "https://api.stripe.com/v1/payment_methods/{$paymentMethodId}";
            })->andReturn([
                "{\"id\": \"$paymentMethodId\"}", 200, [],
            ]);

        $subscriptionId = Str::random();

        $itemId = Str::random();

        $productId = Str::random();

        $planName = Plan::PROFESSIONAL;
        $plan = Plan::withName($planName);

        /**
         *  Subscription request
         */
        $client->shouldReceive('request')
            ->withArgs(function ($method, $path, $params, $opts) use ($paymentMethodId, $plan) {
                $isSubscriptions = $path === "https://api.stripe.com/v1/subscriptions";
                $isBasicPrice = $opts["items"][0]["price"] === $plan->stripe_price_id;

                return $isSubscriptions && $isBasicPrice;
            })->andReturn([
                "{
                \"object\": \"subscription\",
                \"id\": \"{$subscriptionId}\",
                \"status\": \"active\",
                \"items\": {
                    \"object\": \"list\",
                    \"data\": [
                        {
                            \"id\": \"{$itemId}\",
                            \"price\": {
                                \"object\": \"price\",
                                \"id\": \"{$plan->stripe_price_id}\",
                                \"product\": \"{$productId}\"
                            },
                            \"quantity\": 1
                        }
                    ]
                }
                }", 200, [],
            ]);

        ApiRequestor::setHttpClient($client);


        $this->authenticate($this->user);
        $res = $this->putJson('/subscribe', [
            'plan'              => $planName,
            'payment_method_id' => $paymentMethodId,
        ]);

        $res->assertSuccessful();

        // Actually interesting assertions go here
    }

Upvotes: 2

Colin
Colin

Reputation: 882

Hello from the future!

I was just digging into this. All those classes extend from Stripe's ApiResource class, keep digging and you'll discover that when the library is about to make an HTTP request it calls $this->httpClient(). The httpClient method returns a static reference to a variable called $_httpClient. Conveniently, there is also a static method on the Stripe ApiRequestor class called setHttpClient which accepts an object which is supposed to implement the Stripe HttpClient\ClientInterface (this interface only describes a single method called request).

Soooooo, in your test you can make a call to ApiRequestor::setHttpClient passing it an instance of your own http client mock. Then whenever Stripe makes an HTTP request it will use your mock instead of its default CurlClient. Your responsibility is then have your mock return well-formed Stripe-esque responses and your application will be none the wiser.

Here is a very dumb fake that I've started using in my tests:

<?php

namespace Tests\Doubles;

use Stripe\HttpClient\ClientInterface;

class StripeHttpClientFake implements ClientInterface
{
    private $response;
    private $responseCode;
    private $headers;

    public function __construct($response, $code = 200, $headers = [])
    {
        $this->setResponse($response);
        $this->setResponseCode($code);
        $this->setHeaders($headers);
    }

    /**
     * @param string $method The HTTP method being used
     * @param string $absUrl The URL being requested, including domain and protocol
     * @param array $headers Headers to be used in the request (full strings, not KV pairs)
     * @param array $params KV pairs for parameters. Can be nested for arrays and hashes
     * @param boolean $hasFile Whether or not $params references a file (via an @ prefix or
     *                         CURLFile)
     *
     * @return array An array whose first element is raw request body, second
     *    element is HTTP status code and third array of HTTP headers.
     * @throws \Stripe\Exception\UnexpectedValueException
     * @throws \Stripe\Exception\ApiConnectionException
     */
    public function request($method, $absUrl, $headers, $params, $hasFile)
    {
        return [$this->response, $this->responseCode, $this->headers];
    }

    public function setResponseCode($code)
    {
        $this->responseCode = $code;

        return $this;
    }

    public function setHeaders($headers)
    {
        $this->headers = $headers;

        return $this;
    }

    public function setResponse($response)
    {
        $this->response = file_get_contents(base_path("tests/fixtures/stripe/{$response}.json"));

        return $this;
    }
}

Hope this helps :)

Upvotes: 7

Related Questions