How to test Stripe Checkout with Cypress

The full guide to testing Stripe Checkout with Cypress

Testing is a crucial part of software development, but it can be difficult to know where to start, especially if you are interacting with the code of a third party on a different application; as it turns out, this is the case with Stripe, the most famous payment processor for developers.

Unfortunately, from what I've seen around, it's very hard to test its implementation, especially if you are using Stripe Checkout, a hosted portal that lives on Stripe's platform.

Testing external pages with Cypress can be challenging

Why is it hard, you ask?

  • Testing applications you do not own is very risky because the code can change anytime and without warning
  • Testing real applications can be very time-consuming

Long story short, the Cypress team discourages testing other applications and instead recommends mocking or programmatically interacting with the third party, which is what we'll be doing.

Testing your Stripe implementation is crucial

Testing your Stripe implementation is crucial to making sure your payments are being processed as intended. If you're building a website, it's best to test out the functionality early and often.

Skipping testing your Stripe may be tempting due to its complexity, and the fact that the Stripe Checkout portal actually does most of the heavy lifting, but it's not something we'd recommend for such a crucial part of your SaaS.

We assume you have already a working implementation

This guide assumes you have already a working implementation of Stripe in your application.

For this article, I'll show how we can test three scenarios:

  • Creating a subscription
  • Deleting a subscription

But first, we have to run a Stripe emulator to mock the calls to the Stripe backend.

Install stripe-mock, an official library to emulate a Stripe server locally

Stripe has released stripe-mock, an official library to emulate a Stripe server locally, it will allow you to run your tests against the mocked API and make sure that the changes you've made don't break anything.

To run it, we can use Docker with the following command:

docker run --rm -it -p 12111-12112:12111-12112 stripe/stripe-mock:latest

The server will run at localhost:12111 for HTTP and localhost:12112 for HTTPS.

Additionally, you need to make sure to be running the Stripe CLI server with the following command:

stripe listen --forward-to localhost:3000/api/stripe/webhook

Of course, remember to update the command with your application's details.

Stripe Emulator Limitations

The above is amazing, isn't it?

Well, yes, but you should be aware of the limitations of the library:

  • it's stateless: what does it mean? It does not store the data we create, which means we need to stick to the data that has been preloaded.
  • no webhooks: it does not automatically send webhooks, which means we have to do it manually.
  • does not cover the full API: it does not handle the full API, and it only works with the latest version's specs

This is not great, but we can make it work.

Changing your Stripe instance to work with the emulator

Now that the Stripe server is running, we have to update the Stripe client's to be pointing to the emulator's server, rather than the real Stripe endpoints.

To do so, we also need to discriminate between a production instance and a testing one. Of course, this will depend on how you structured your application; in our case, we use a simple check (the below is based on a Next.js application)

  • we check if the NODE_ENV variable is "development"
  • we check if the NEXT_PUBLIC_EMULATOR variable is "true"
function isCypressEnv() {
return Boolean(
process.env.NODE_ENV === `development` &&
process.env.NEXT_PUBLIC_EMULATOR === `true`
);
}

The Stripe emulator instance is created in the following way.

Loading the Stripe client

  1. First, we lazy load the Stripe client library to reduce the API execution time:
async function loadStripe() {
const { default: Stripe } = await import('stripe');
return Stripe;
}

Initialize the Stripe emulator

Then, we initialize the Stripe emulator instance by passing the correct host and port and changing the protocol to http (if you run localhost with HTTPS, feel free to ignore it)

async function getStripeEmulatorInstance() {
const Stripe = await loadStripe();
return new Stripe(`sk_test_12345`, {
host: `localhost`,
port: 12111,
apiVersion: STRIPE_API_VERSION,
protocol: `http`,
});
}

Finally, we export a factory that passes the correct instance based on the environment:

export async function getStripeInstance() {
if (isCypressEnv()) {
console.warn(`Stripe is running in Testing mode`);
return getStripeEmulatorInstance();
}
return getStripeProductionInstance();
}

And that's it! All our code that calls the Stripe API will now interact with the emulator instance rather than the real Stripe endpoints.

Creating a Stripe Page Object in Cypress

I'm old-fashioned, so I create page objects in my Cypress tests.

The code below is a small utility to help us write tests more declaratively and in a more reusable fashion:

import * as StripeLib from 'stripe';
import { StripeWebhooks } from '~/core/stripe/stripe-webhooks.enum';
const stripe = new StripeLib.Stripe(`sk_test_12345`, {
host: `localhost`,
port: 12111,
apiVersion: '2020-08-27',
protocol: `http`,
});
const $get = cy.cyGet.bind(cy);
const PORT = 12111;
const stripePo = {
$plans: () => $get('subscription-plan'),
$checkoutForm: () => $get('checkout-form'),
$subscriptionName: () => $get('subscription-name'),
createWebhookPayload,
sendWebhook(params: { type: StripeWebhooks; body: UnknownObject }) {
const body = this.createWebhookPayload(params.body, params.type);
const signature = stripePo.createSignature(body);
cy.request({
url: 'http://localhost:3000/api/stripe/webhook',
method: `POST`,
headers: {
['stripe-signature']: signature,
},
body,
});
},
selectPlan(number: number = 0) {
this.$plans().eq(number).click();
this.$checkoutForm().submit();
},
createSignature(payload: unknown) {
return stripe.webhooks.generateTestHeaderString({
payload: JSON.stringify(payload),
secret: Cypress.env('STRIPE_WEBHOOK_SECRET'),
});
},
};
function createWebhookPayload(data: unknown, type: StripeWebhooks) {
return {
id: 'evt_1LK3nDI1i3VnbZTqgFqyzkWx',
object: 'event',
api_version: '2020-08-27',
created: 1657473579,
data: {
object: data,
},
livemode: false,
pending_webhooks: 0,
request: {
id: null,
idempotency_key: null,
},
type,
};
}
export default stripePo;

Most of it is quite simple if you're not new to Cypress, but you may want to check out the snippet where we send Webhooks to our Stripe endpoint, which can help you write more tests if your code handles more scenarios.

Sending Webhooks to our Stripe endpoint manually

Makerkit implements a webhook handler at /api/stripe/webhook.ts that responds to the event sent by Stripe:

switch (event.type) {
case StripeWebhooks.Completed: {
// handle Completed
}
case StripeWebhooks.AsyncPaymentSuccess: {
// handle SubscriptionDeleted
}
case StripeWebhooks.SubscriptionDeleted: {
// handle SubscriptionDeleted
}
case StripeWebhooks.SubscriptionUpdated: {
// handle SubscriptionUpdated
break;
}
case StripeWebhooks.PaymentFailed: {
// handle PaymentFailed
}
}

That means, to send a webhook that targets one of the code paths below (for example, the Completed event), we would do the following request:

stripePo.sendWebhook({
body: session,
type: StripeWebhooks.Completed,
});

The request above (as you can see from the page object snippet), uses Cypress' cy.request to send an HTTP request to our endpoint. By doing so, we emulate the Stripe client:

  • instead of having the Stripe emulator run the webhooks requests (which cannot do), we send them manually with Cypress
  • the tricky part is sending the data with the correct payloads, which requires work and changing the fixture's data according to both our database's data and the Stripe emulator's static data (remember, it's stateless! data won't change, and we cannot use our own)
    • fortunately, we provide the gists at the end of the article that you can use with your tests

Where does session come from? I took them from the mock server's data: because it's static, we are forced to use the same as the server's to make it work.

To load them, we use a Cypress fixture, i.e. a piece of data we can load from the fixtures folder:

cy.fixture('session').then((session) => {
// session is the JSON stored in the file fixtures/session.json
});

1) Testing the creation of a Stripe subscription

Let's implement the first scenario, i.e. creating a simple Stripe subscription with immediate payment.

What we are going to do:

  • send the webhook above
  • verify the UI works
    • in our case, we verify is the subscription has been displayed
      • it's very important to reload your page using cy.reload() if you don't update your UI in real-time to see the changes
    • if you want to take it a step further, you could check if the DB's data matches the UI

Let's take a look below:

import stripePo from '../../support/stripe.po';
import { StripeWebhooks } from '~/core/stripe/stripe-webhooks.enum';
describe(`Create Subscription`, () => {
before(() => {
cy.signIn(`/settings/subscription`);
});
describe('Using Webhooks', () => {
describe(`When the user creates a subscription`, () => {
before(() => {
cy.fixture('session').then((session) => {
stripePo.sendWebhook({
body: session,
type: StripeWebhooks.Completed,
});
});
});
it(`should display a Subscription Card`, () => {
cy.reload();
stripePo.$subscriptionName().should('have.text', 'Testing Plan');
});
});
});
});

2) Testing the deletion of a Stripe subscription

The implementation is very similar to the above, with the exception that we pass:

  • the subscription fixture instead of session
  • the event type StripeWebhooks.SubscriptionDeleted
describe(`When the user unsubscribes`, () => {
before(() => {
cy.fixture('subscription').then((subscription) => {
stripePo.sendWebhook({
body: subscription,
type: StripeWebhooks.SubscriptionDeleted,
});
});
});
it('should delete the subscription', () => {
cy.reload();
stripePo.$subscriptionName().should('not.exist');
});
});

Demo: testing Stripe with Cypress

Here is a short video of the Stripe E2E tests with Cypress that are included in Makerkit:

It's fast, isn't it?

The fixtures

If you want, you can check out the Stripe fixtures I use for my tests in the gists below:

Click here to download the Subscription and Session gists

Edit the fixtures for your own data:

  • for example, client_reference_id in subscription.json is my own testing organization: you should edit this value to match your database's.

Conclusion

Testing Stripe with Cypress is a great way to ensure that your payment form, including the callbacks and server-side validation, is working as expected.

I hope the guide above can help you write your Stripe tests with ease and confidence.

Too bothered to write the code yourself? If you purchase Makerkit, it's already done for you!

If you want to give it a try and need a hand, please contact me at info@makerkit.dev; always happy to help!