Stripe Checkout is a quick and secure way of integrating one-time payments and subscriptions into your application., Their documentation is great so the integration is easy and if you use Laravel Cashier it is even easier to integrate and test with Laravel Dusk. We'll keep the integration for another post and just focus on the automated browser testing in this post.

https://stripe.com/docs/payments/checkout

If you've been using the legacy Stripe Checkout for some time and ever had to test you might've stumbled upon Testing the most important piece of your application which demonstrates how to interact with the modal and test it.

The new version of the checkout is overall easier to test but has a few odd behaviours that can throw you off when you first encounter them. All of those odd behaviours are security related and only affect headless browsers according to the Stripe support. If you don't run Dusk in headless mode you can ignore this post.

Test Case

We'll use below test throughout this post and explain what happens in it, explain how certain things relate to the previously mentioned odd behaviours you might encounter and how you can work around them.

<?php

namespace Tests\Browser;

use App\Plan;
use App\User;
use PlanSeeder;
use Tests\DuskTestCase;
use Laravel\Dusk\Browser;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Foundation\Testing\WithFaker;

class SubscriptionTest extends DuskTestCase
{
    use DatabaseMigrations;
    use WithFaker;

    /** @test */
    public function user_can_subscribe_to_a_plan()
    {
        $user = factory(User::class)->create();
        $user->createAsStripeCustomer();

        resolve(PlanSeeder::class)->run();

        $this->browse(function (Browser $browser) use ($user, $team) {
            $browser
                ->loginAs($user)
                ->visit('/app/team/subscription')
                ->assertSee('Choose a subscription plan based on your needs and budget.')
                ->press('#subscribe_' . Plan::first()->stripe_id)
                ->waitFor('#cardNumber')
                ->type('#email', $this->faker->safeEmail)
                ->type('#cardNumber', '4242424242424242')
                ->type('#cardCvc', '123')
                ->type('#cardExpiry', '01' . now()->addYear()->format('Y'))
                ->type('#billingName', $this->faker->name)
                ->select('billingCountry', 'US')
                ->type('#billingAddressLine1', $this->faker->address)
                ->type('#billingAddressLine2', $this->faker->address)
                ->type('#billingPostalCode', $this->faker->postcode)
                ->type('#billingLocality', $this->faker->city)
                ->waitFor('#billingAdministrativeArea')
                ->select('#billingAdministrativeArea', $this->faker->stateAbbr)
                ->waitFor('.SubmitButton--complete')
                ->press('.SubmitButton--complete')
                ->waitUntilMissing('#cardNumber', 60)
                ->assertPathIs('/app');
        });
    }
}

Random Credit Card Number Being Entered

The most off throwing behaviour you will encounter when you first run this test is that the credit card number that is being filled into the forms. When you run above test multiple times in headless mode you'll end up with screenshots from the failing tests where random credit card numbers are visible instead of what was instructed to be entered.

Before wasting time on testing I contacted the Stripe support and was told that this is a protection mechanism against headless browsers but improves testing for Stripe Checkout is on the way and got a quick response from a developer.

How To Fix It

The recommended solution of the developer was to add random delays between inputs with a somewhat large variance or alternative disable headless mode. Disabling headless mode is obviously not a solution if you use any kind of CI/CD setup so random delays are the solution we will go with.

With Laravel Dusk we can use the pause method to achieve this delay and the rand method to add some variance to it, for example pause(rand(100, 500)). Lets see how this looks in practice and what's important to keep in mind.

<?php

namespace Tests\Browser;

use App\Plan;
use App\User;
use PlanSeeder;
use Tests\DuskTestCase;
use Laravel\Dusk\Browser;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Foundation\Testing\WithFaker;

class SubscriptionTest extends DuskTestCase
{
    use DatabaseMigrations;
    use WithFaker;

    /** @test */
    public function user_can_subscribe_to_a_plan()
    {
        $user = factory(User::class)->create();
        $user->createAsStripeCustomer();

        resolve(PlanSeeder::class)->run();

        $this->browse(function (Browser $browser) use ($user, $team) {
            $browser
                ->loginAs($user)
                ->visit('/app/team/subscription')
                ->assertSee('Choose a subscription plan based on your needs and budget.')
                ->press('#subscribe_' . Plan::first()->stripe_id)
                ->waitFor('#cardNumber')
                ->pause(rand(1000, 2000))
                ->type('#email', $this->faker->safeEmail)
                ->pause(rand(100, 500))
                ->type('#cardNumber', '4242424242424242')
                ->pause(rand(100, 500))
                ->type('#cardCvc', '123')
                ->pause(rand(100, 500))
                ->type('#cardExpiry', '01' . now()->addYear()->format('Y'))
                ->pause(rand(100, 500))
                ->type('#billingName', $this->faker->name)
                ->pause(rand(100, 500))
                ->select('billingCountry', 'US')
                ->pause(rand(100, 500))
                ->type('#billingAddressLine1', $this->faker->address)
                ->pause(rand(100, 500))
                ->type('#billingAddressLine2', $this->faker->address)
                ->pause(rand(100, 500))
                ->type('#billingPostalCode', $this->faker->postcode)
                ->pause(rand(100, 500))
                ->type('#billingLocality', $this->faker->city)
                ->pause(rand(100, 500))
                ->waitFor('#billingAdministrativeArea')
                ->pause(rand(100, 500))
                ->select('#billingAdministrativeArea', $this->faker->stateAbbr)
                ->pause(rand(100, 500))
                ->waitFor('.SubmitButton--complete')
                ->pause(rand(100, 500))
                ->press('.SubmitButton--complete')
                ->waitUntilMissing('#cardNumber', 60)
                ->assertPathIs('/app');
        });
    }
}

Now if you'll run the test in headless mode it will pass most of the time. Note that I said most because Stripe seems to be able to learn after a few dozen runs that it isn't a human that is interacting with the form.

More Resilience

The more you run these tests the more they will start to fail as Stripe notices patterns. There are several things you can do to counteract this but I won't go too much into details and leave that to you as the developer but a I'll still share a few tips.

  1. Fully randomize the order in which data is entered. This will make your tests a lot more resilient combined with timeouts as patterns are more difficult to detect.
  2. Use different testing cards. If you always use the same card to fill out the form you are providing a constant in the pattern detection which will work against you.
  3. Make mistakes to make it look more human. If you just fill out the form in 2 seconds and press purchase you will most likely be noticed as a bot so add some typos and press buttons you shouldn't press until the form is fully filled out with the correct values.

Conclusion

All of these methods combined have helped me to build a resilient set of tests with Laravel Dusk that ensure that my subscription flow is working flawlessly. All of these tests are executed on GitHub Actions, haven't failed a single time and give me the confidence that everything is doing what it is supposed to be doing before triggering a deployment.