DEV Community

Carl Alexander
Carl Alexander

Posted on • Originally published at carlalexander.ca on

How to test Stripe Checkout and Customer portal with Laravel Dusk

How to test Stripe Checkout and Customer portal with Laravel Dusk

Recently, I added billing to Ymir. It's a Laravel application, and adding payment functionality to it was a breeze with Laravel Cashier. Cashier supports different payment gateways, but it was originally designed for Stripe. It's also what I'm using with Ymir.

Over the last year or so, Stripe has released two products to ease the engineering burden dealing with payments. First, there's Checkout, which lets you not worry about building a payment page. Second, there's the customer portal which lets people manage their subscription and billing information.

Laravel Cashier has support for both Checkout and the Customer portal, which means you don't have to code any payment backend. But that doesn't mean that you don't want to test the integration between Stripe and your Laravel application. After all, payment is one of the most important part of your application!

To do that, you'll want to use Laravel Dusk to do browser testing for your application. If you're not doing Browser testing already, you should consider looking into it. You could use another framework, but Dusk is the standard in the Laravel ecosystem.

Getting webhooks to work during tests

Doing browser testing with Stripe Checkout and the Customer portal comes with certain challenges. The biggest one is that these products communicate with your application via webhooks. When developing your Laravel application, you use the Stripe CLI to forward webhooks to it.

We'll also need to do that when we run our browser tests. So an important aspect of using Laravel Dusk is having the Stripe CLI to forward our webhooks during the tests. We need to automate that as part of the test suite.

A simple way to automate the use of the Stripe CLI is to run it as a background process during our tests. We can use the PHPUnit setUp and tearDown methods to do it. Whenever we need the Stripe CLI, the setUp method starts the process and then stops it in the tearDown method.

namespace Tests\Browser;

use Symfony\Component\Process\Process;
use Tests\DuskTestCase;

class StripeTest extends DuskTestCase
{
    /**
     * @var Process
     */
    private $process;

    protected function setUp(): void
    {
        parent::setUp();

        $this->process = new Process(['stripe', 'listen', '--api-key', env('STRIPE_SECRET'), '--forward-to', sprintf('%s/stripe/webhook', env('APP_URL'))]);
        $this->process->start();
    }

    protected function tearDown(): void
    {
        parent::tearDown();

        $this->process->stop();
    }
}
Enter fullscreen mode Exit fullscreen mode

Above is the base test case class you can use to scaffold the Stripe CLI. It uses the Symfony Process component to run the process in the background. It runs until the stop method gets called in the tearDown method.

The code expects two environment variables to be present. First, there's the STRIPE_SECRET environment variable containing your Stripe secret key. This allows the Stripe CLI to login without you having to enter your email and password. (You can read more about it here.)

Second, there's the APP_URL environment variable. We use it to generate the URL to pass to the --forward-to option. The --forward-to URL is where the Stripe CLI will forward webhook requests from Stripe. The default route with Laravel Cashier is /stripe/webhook so we just prepend the APP_URL environment variable to it.

Cleaning up the customer after a test

Another challenge with testing the Stripe Checkout and Customer portal is cleaning up after the tests. You don't want to end up with hundreds of customers in your Stripe test environment. So you need to set up your test classes so that they can clean up any customer they might have created.

A simple way to achieve this is by storing the created customer in your test class. Once the test completes, you delete the customer in Stripe. Here's one way you could do this.

namespace Tests\Browser;

use App\Model\User;
use Stripe\Customer;
use Stripe\Exception\InvalidRequestException;
use Stripe\Stripe;
use Symfony\Component\Process\Process;
use Tests\DuskTestCase;

class StripeTest extends DuskTestCase
{
    /**
     * @var Process
     */
    private $process;

    /**
     * @var User
     */
    private $user;

    protected function setUp(): void
    {
        parent::setUp();

        $secret = env('STRIPE_SECRET');

        $this->process = new Process(['stripe', 'listen', '--api-key', $secret, '--forward-to', sprintf('%s/stripe/webhook', env('APP_URL'))]);
        $this->process->start();

        Stripe::setApiKey($secret);
    }

    protected function tearDown(): void
    {
        parent::tearDown();

        $this->process->stop();

        if ($this->user instanceof User) {
            try {
                (new Customer($this->user->stripe_id))->delete();
            } catch (InvalidRequestException $exception) {
            }
        }

        $this->user = null;
    }
}
Enter fullscreen mode Exit fullscreen mode

Above is the updated code sample from earlier. We added a few more things to take care of the customer cleanup. If you'd rather do this clean up inside each of your tests, you don't have to add any of this code.

First, there's a new user property. It'll store the User model object that has the Stripe customer ID that we want to clean up. (If you're using another model to store the Stripe customer ID, you should store that model instead.) During your tests, if you create a User model, you want to assign it to that property instead of creating a variable for it.

Next, there's a minor change in the setUp method. We added code to initialize the Stripe PHP SDK by calling the setApiKey method. We pass it the API secret key which Laravel Cashier expects to be in STRIPE_SECRET environment variable by default.

After that, in the tearDown method, we added code to delete the customer in Stripe. We start by checking if the user property has a User object stored in it. If it does, we call the code to delete the customer in Stripe.

To delete the customer, we create an instance of the Stripe Customer class using the ID stored in the User model. We then call the delete method. We put that code in a simple try-catch block. This is so we can discard errors if the customer didn't exist in Stripe.

Creating page classes for Stripe

This wraps up the base test class. Next, we want to look at what we need to create page classes. Page classes are a good way to group actions you want to perform on specific web pages.

With Stripe, we need a page class for both the checkout page and the customer portal. Both will share some similar elements. To begin, let's create the checkout page class. (You can do this with the php artisan dusk:page command.)

namespace Tests\Browser\Pages;

use Laravel\Dusk\Browser;

class Checkout extends Page
{
    /**
     * Get the URL for the page.
     */
    public function url(): string
    {
        return '/';
    }

    /**
     * Assert that the browser is on the page.
     */
    public function assert(Browser $browser)
    {
        $browser->assertPathIs($this->url());
    }

    /**
     * Get the element shortcuts for the page.
     */
    public function elements(): array
    {
        return [
            '@element' => '#selector',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Above is what you'd get if you ran the command php artisan dusk:page Checkout. The command creates a Checkout class with some methods. We only care about the url and assert methods for this article so we won't discuss the elements method.

Asserting external URLs

Now, by default, Dusk lets you test pages that aren't part of your Laravel application. You can just replace the value returned by url method by a full URL. You then use the method assertUrlIs to check the URL in the assert method.

The issue is that the Stripe URLs are dynamic. So the assertUrlIs method won't work to validate that we're on the right page. To fix this, we'll need to create a macro to add a custom assertion method in the Browser class.

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Laravel\Dusk\Browser;

class DuskServiceProvider extends ServiceProvider
{
    /**
     * Register Dusk's browser macros.
     */
    public function boot()
    {
        Browser::macro('assertUrlBeginsWith', function (string $url) {
            $pattern = str_replace('\*', '.*', preg_quote($url, '/'));

            $segments = parse_url($this->driver->getCurrentURL());

            $currentUrl = sprintf(
                '%s://%s%s%s',
                $segments['scheme'],
                $segments['host'],
                Arr::get($segments, 'port', '') ? ':'.$segments['port'] : '',
                Arr::get($segments, 'path', '')
            );

            PHPUnit::assertThat(
                $currentUrl,
                new RegularExpression('/^'.$pattern.'/u'),
                "Actual URL [{$this->driver->getCurrentURL()}] does not begin with the expected URL [{$url}]."
            );

            return $this;
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Above is the modified DuskServiceProvider class with the macro for the assertUrlBeginsWith method. It's a method that checks if the URL starts a specific way, but it doesn't have to be identical.

The code itself is nearly identical to the assertUrlIs method. The only change was to remove the $ in the regular expression. This changes the regular expression from requiring an exact match to just having the string begin with a specific URL.

Modifying the assert and url methods

With our macro created, we can go back to our Checkout class. We're going to fill in the assert and url methods. After the changes, our Checkout class will look like this:

namespace Tests\Browser\Pages;

use Laravel\Dusk\Browser;

class Checkout extends Page
{
    /**
     * Assert that the browser is on the page.
     */
    public function assert(Browser $browser)
    {
        $browser->assertUrlBeginsWith($this->url());
    }

    /**
     * Get the URL for the page.
     */
    public function url()
    {
        return 'https://checkout.stripe.com/pay';
    }
}
Enter fullscreen mode Exit fullscreen mode

The url method returns https://checkout.stripe.com/pay which is the URL that all Stripe Checkout links start with. The assert method passes that URL to our macroed assertUrlBeginsWith method to check the page URL starts with the URL. We then do the same thing for the Stripe customer portal.

namespace Tests\Browser\Pages;

use Laravel\Dusk\Browser;

class CustomerPortal extends Page
{
    /**
     * Get the URL for the page.
     */
    public function url()
    {
        return 'https://billing.stripe.com/session';
    }

    /**
     * Assert that the browser is on the page.
     */
    public function assert(Browser $browser)
    {
        $browser->assertUrlBeginsWith($this->url());
    }
}
Enter fullscreen mode Exit fullscreen mode

The CustomerPortal class is also nearly identical to the Checkout class. We just changed the URL returned by the url method. The customer portal URL starts with https://billing.stripe.com/session.

Testing Stripe subscription

Now, we have all we need to write our browser tests. These tests are going to vary from one Laravel application to another. But we can go over a small hypothetical one that goes over some common operations you might want to do.

namespace Tests\Browser;

use Symfony\Component\Process\Process;
use Tests\DuskTestCase;

class StripeTest extends DuskTestCase
{
    // ...

    public function testSubscribe()
    {
        $this->browse(function (Browser $browser) {
            $this->user = User::factory()->create();

            $this->assertTrue($this->user->subscriptions->isEmpty());

            $browser->loginAs($this->user)
                    ->goToBilling()
                    ->on(new Checkout())
                    ->enterValidCreditCard()
                    ->enterNameOnCreditCard($this->user->name)
                    ->enterAddress()
                    ->subscribe()
                    ->on(new CustomerPortal());

            $this->user = $this->user->refresh();

            $this->assertTrue($this->user->subscription()->active());
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Above is the updated StripeTest class with our sample test called testSubscribe. It tests that a subscription gets created in our Laravel application when we enter a valid credit card in the Stripe Checkout. Once subscribed, we should get redirected to the customer portal. (You can do this by setting the checkout return URL to the application URL that redirects to the customer portal.)

Walking through the test code, it starts by creating a User using its model factory. We then do a quick assertion to check that the user has no subscriptions. You can skip this if you prefer.

Browser actions

After that, we start our test by performing browser actions through the Browser class. First, we need to ensure that the browser is logged in by using the loginAs method.

Then we have goToBilling method. This is a hypothetical method that represents whatever actions you'd need the browser to do to get to and click the Stripe checkout link in your Laravel application. Clicking that link should then send us to the Stripe checkout page.

We verify this by using the on method with the Checkout class we created. Because of the changes we did earlier, Dusk should correctly detect that the browser is on a Stripe checkout page.

Checkout class methods

Once we know that we're on the Stripe checkout page, we need to enter the payment information. We do that with some page methods. For clarity, we did one page method per form section. But if you'd prefer one for the entire process, that's fine too.

namespace Tests\Browser\Pages;

use Laravel\Dusk\Browser;

class Checkout extends Page
{
    // ...

    /**
     * Enter an address.
     */
    public function enterAddress(Browser $browser)
    {
        $browser->select('#billingCountry', 'CA')
                ->type('billingPostalCode', 'H0H0H0');
    }

    /**
     * Enter the name on the credit card.
     */
    public function enterNameOnCreditCard(Browser $browser, string $name)
    {
        $browser->type('#billingName', $name);
    }

    /**
     * Enter a valid credit card.
     */
    public function enterValidCreditCard(Browser $browser)
    {
        $browser->type('#cardNumber', '4242424242424242')
                ->type('#cardExpiry', '0129')
                ->type('#cardCvc', '111');
    }

    /**
     * Press the subscribe button.
     */
    public function subscribe(Browser $browser)
    {
        $browser->press('Subscribe')
                ->pause(20000);
    }
}
Enter fullscreen mode Exit fullscreen mode

Above is the updated Checkout class with the new page methods. Most of the values for the form are hardcoded. (Fun fact: H0H 0H0 is the Santa Claus' Canadian postal code.) If you'd rather pass them as arguments, that's ok as well. You're really free to build out these methods in the way that makes the most sense to you.

It's worth noting that you can't change the 4242424242424242 credit card number. It's the valid Visa credit card number that Stripe will accept in test mode. (You can view all the different card numbers in the Stripe documentation.) The CVC number can be anything, and the expiry date only needs to be a future date.

Another thing that might be unusual is the call to the pause method after pressing the subscribe button. You don't have to put the pause there, but a pause is necessary. You need to give Stripe the time to process the request, but also send over the subscription information to your Laravel application via the Stripe CLI.

20 seconds is enough time in my experience. I tried lower values, but sometimes the tests would fail. So be aware if you choose to put a smaller pause time.

Redirecting to the customer portal

After you subscribe, you’ll notice that our test expects Stripe to redirect us to the customer portal. To do that, you need to set up a route in your application that redirects to the customer portal. The Cashier documentation shows how you can do that.

use Illuminate\Http\Request;

Route::get('/billing-portal', function (Request $request) {
    return $request->user()->redirectToBillingPortal();
})->name('billing-portal');
Enter fullscreen mode Exit fullscreen mode

You then want to use that route when creating your Stripe checkout link.

$checkout = Auth::user()->newSubscription('default', 'price_xxx')->checkout([
    'success_url' => route('billing-portal'),
    'cancel_url' => route('your-cancel-route'),
]);
Enter fullscreen mode Exit fullscreen mode

Verifying the subscription

Once we're done testing with the browser, we want to check that our Laravel app received the changes from the Stripe API. To do that, we need an up-to-date version of our user. So we need to reload the model using the refresh method.

After that, we need to get the Subscription model for the user. If all worked well, you should have one. We then check that the subscription is active by asserting that the active method returns true.

Test cancelling an active subscription

Lastly, we’ll look at a test using the customer portal. There are a few tests you can do there, but the one we’ll look at is cancelling an existing subscription. Below is the updated StripeTest class:

namespace Tests\Browser;

use Symfony\Component\Process\Process;
use Tests\DuskTestCase;

class StripeTest extends DuskTestCase
{
    // ...

    public function testCancelSubscription()
    {
        $this->browse(function (Browser $browser) {
            $this->user = User::factory()->create();

            $this->user->newSubscription()
                       ->create('pm_card_visa');

            $this->assertFalse($this->user->subscription()->cancelled());

            $browser->loginAs($this->user)
                    ->goToBilling()
                    ->on(new CustomerPortalPage())
                    ->cancelSubscription();

            $this->user = $this->user->refresh();

            $this->assertTrue($this->user->subscription()->cancelled());
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

This test starts the same as the one to test the subscription checkout flow. We create a user using the model factory.

But after that, we create a Stripe subscription using the newSubscription method followed by the create method. This will create a subscription in Stripe using the given payment method ID. pm_card_visa is the payment method ID of a Visa credit card in the Stripe test environment.

Once the Stripe subscription created, we do a quick sanity check to check that the subscription isn’t in a cancelled state. Once that’s done, we can start performing the browser actions. There’s only one specific one for the customer portal for this test. It’s clicking on the cancel subscription button.

But first, we need to navigate to the customer portal. We’re going to use the goToBilling method again for that. Then we ensure that we're on the customer portal using the on method. Then we can cancel the subscription.

namespace Tests\Browser\Pages;

use Laravel\Dusk\Browser;

class CustomerPortal extends Page
{
    // ...

    /**
     * Cancel an active subscription.
     */
    public function cancelSubscription(Browser $browser)
    {
        $browser->clickLink('Cancel plan')
                ->pause(5000)
                ->press('Cancel plan')
                ->pause(10000);
    }
}
Enter fullscreen mode Exit fullscreen mode

Above is the cancelSubscription method. On the Stripe customer portal, you need to click on “Cancel plan” twice to cancel your subscription. The first time you’re clicking on a link and the second time it’s a button. A pause is necessary between both clicks to allow the site to react to the first press.

The second pause is there for the same reason as the one for testing the subscription. It allows Stripe to communicate back to the Laravel application via the Stripe CLI.

The test also wraps up similarly as the subscription one. We reload the User model to load the changes from the Stripe. We then do an assertion to ensure that the subscription is in a cancelled state.

A good foundation

So with this, you have enough to test a subscription with Stripe checkout and a cancellation using the customer portal. There are still a lot of other scenarios you might want (and should!) to test. But this should give you the foundation you need to make writing those tests a breeze!

Top comments (0)