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
- 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
- fortunately, we provide the
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
- it's very important to reload your page using
- if you want to take it a step further, you could check if the DB's data matches the UI
- in our case, we verify is the subscription has been displayed
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 ofsession
- 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
insubscription.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!