Mocking Stripe.js methods with Cypress

In this post we will be looking at how to create mock functions for any of the methods returned by Stripe.js within a Cypress test and also go through an example of leveraging this to test Apple Pay in the Chrome browser.

How the Stripe library works

According to the documentation the Stripe function returns an object which contains the various methods that form the Stripe API:

const stripe = Stripe('pk_test_TYooMQauvdEDq54NiTphI7jx', {
  /* options */
});

// stripe holds all the methods we'd like to mock
{
  confirmCardPayment,
  updatePaymentIntent
  paymentRequest
  // -- etc
}

There are also a lot of internal methods and other functionality that we will want to preserve alongside the mocked methods. This means things like locales will still work.

To achieve this the following steps are needed:

  • In our tests load the Stripe library
  • Store a reference to the global Stripe function
  • Create our own Stripe global function that returns the same object as the original, and then mix in our own mock overrides.

Overriding the Stripe library in the tests

In our tests we will be adding the following snippet:

<script src="https://js.stripe.com/v3/"></script>
<script>
  if (window.mockStripeMethods) {
    const origStripe = window.Stripe;
    window.Stripe = (...args) => {
      return {
        ...origStripe(...args),
        ...window.mockStripeMethods,
      };
    };
  }
</script>

So where does window.mockStripeMethods come from, and why are we adding the Stripe library via a script element just above?

It's important that we can get a reference to the original Stripe library, and to guarantee that it is available at this point we ensure it's loaded synchronously before the snippet. You can skip this step if you've already loading Stripe in this way.

Remember that the above snippet can go in the head or at the end of the body, it doesn't really matter. So adjust this based on how your application uses Stripe.

But what about if you load the library asynchronously in your application via the loadStripe function?.

Thankfully we can take advantage of the fact that if Stripe is already detected on the window object then loadStripe just returns that instead of trying to load the library. Within the HTML template for your application we can restrict loading Stripe synchronously to only happen in Cypress. Let's look at that next.

Loading Stripe synchronously only in Cypress

The following will work with the html-webpack-plugin but a similar technique can be applied to any templating language. The first step is to pass an environment variable through to the template at build time:

new HtmlWebpackPlugin({
  filename: 'index.html',
  template: './src/templates/index.html',
  isCypress: process.env.IS_CYPRESS === 'true',
})

Then in the template the value can be detected and the snippet is output:

<% const isCypress = htmlWebpackPlugin.options.isCypress;
if (isCypress) { %>
  <script src="https://js.stripe.com/v3/"></script>
  <script>
    if (window.mockStripeMethods) {
      const origStripe = window.Stripe;
      window.Stripe = (...args) => {
        return {
          ...origStripe(...args),
          ...window.mockStripeMethods,
        };
      };
    }
  </script>
<% } %>

And finally, prepend the test command with the environment variable:

IS_CYPRESS=true yarn test:cypress

Adding the mock methods to the window object

The last part of this setup is to address where the window.mockStripeMethods will come from.

Cypress has the onBeforeLoad option that is passed to the visit() command. This callback is called as soon as possible with the window of our current test and before any scripts have loaded. This makes it the ideal place to add our desired overrides:

// example.spec.js
const confirmCardPaymentStub = cy.stub();

cy.visit('http://localhost:3000/#dashboard', {
  onBeforeLoad: (window) => {
    window.mockStripeMethods = {
      confirmCardPayment: confirmCardPaymentStub
    }
  },
})

And for TypeScript users we can add a simple helper function to provide the correct types for available Stripe methods:

// mockStripe.ts
import {Stripe} from '@stripe/stripe-js';

type MockMethods = Partial<Record<keyof Stripe, unknown>>;
type Win = Cypress.AUTWindow & {mockStripeMethods?: MockMethods};

export const mockStripeMethods = (window: Win, methods: MockMethods) => {
  window.mockStripeMethods = methods;
};

// example.spec.ts
const confirmCardPaymentStub = cy.stub();

cy.visit('http://localhost:3000/#dashboard', {
  onBeforeLoad: (window) => {
    mockStripeMethods(window, {
      confirmCardPayment: confirmCardPaymentStub
    });
  },
})

ts complete

Assert that the stub was called

With the stub in place the next step would be to perform some actions in your application to submit a payment and then use expect to validate that the stub was called as expected:

const confirmCardPaymentStub = cy.stub();
// `confirmCardPayment` always returns a promise, so mock that here
confirmCardPaymentStub.resolves({});

cy.visit('http://localhost:3000/#dashboard', {
  onBeforeLoad(window) {
    mockStripeMethods(window, {
      confirmCardPayment: confirmCardPaymentStub,
    });
  },
});
// Some custom helpers here to perform some user actions
cy.populateCardDetails();

cy.submitForm().then(() => {
  // Ensure we validate the stub has been called in a `then` callback
  expect(confirmCardPaymentStub).to.have.been.called;
});

Real world example: Mocking the stripe.paymentRequest API

Now that all the pieces are in place we can attempt to test something a bit trickier, the Payment Request API that Stripe conveniently wraps for us.

This API is used to detect whether a browser supports payment methods like Apple or Google Pay and then handles accepting payments via these APIs.

Testing Apple Pay is an interesting one because it only works in Safari and of course Cypress primarily runs on the Blink engine via Chromium.

However in reality we don't actually need Safari to be able to test this flow, and we can use our new mocking skills to trick Stripe into rendering the Apple Pay button in Chrome.

How the Stripe API works

If we take a look at the documentation we can see there a few steps:

  • Create an instance of a paymentRequest object
  • Check if the browser supports Apple with canMakePayment
  • Open the browser interface with show
  • Listen for the paymentmethod event on the paymentRequest object when the user submits the payment

Let's look at that in steps of code so we can figure out what our mock will look like:

Create the payment request object. We will want a paymentRequest stub that can return an object from stripe.paymentRequest:

const paymentRequest = stripe.paymentRequest({
  country: 'US',
  currency: 'usd',
  total: {
    label: 'Demo total',
    amount: 1099,
  },
});

Next is to await a promise from canMakePayment. It will return an object that indicates support of the two payment methods:

// {googlePay: false, applePay: true}
const support = await paymentRequest.canMakePayment();

Then show the dialog to the user based on some form of user interaction (if you're using a custom Apple Pay button):

button.addEventListener('click', (event) => {
  paymentRequest.show();
  event.preventDefault();
}, false);

To complete the payment an event handler needs to be added to paymentmethod via an on method. We will need some form of event registry that can store the event callback here and allow us to call it in the test with some additional data on the event object:

paymentRequest.on('paymentmethod', async (event) => {
  const {paymentIntent, error: confirmError} = await stripe.confirmCardPayment(
    clientSecret,
    // Our test will need to supply this data on the event object
    {payment_method: event.paymentMethod.id},
    {handleActions: false}
  );

  // And this `complete` method
  if (confirmError) {
    event.complete('fail');
  } else {
    event.complete('success');
  }
});

Putting the mock together in the test

First thing to do is create the two mocks for confirmCardPayment and paymentRequest:

The below steps are all within a single it() test

const eventRegistry: Record<string, (event: any) => void> = {};
const paymentRequestReturn = {
  on: (event: string, handler: () => void) => {
    eventRegistry[event] = handler;
  },
  show: cy.stub(),
  canMakePayment: cy.stub().resolves({applePay: true, googlePay: false}),
};

const confirmCardPaymentStub = cy.stub().resolves({});
const paymentRequestStub = cy.stub().returns(paymentRequestReturn);

And then add them via our mockStripeMethods helper:

cy.visit('http://localhost:3000/#dashboard', {
  onBeforeLoad: (window) => {
    mockStripeMethods(window, {
      confirmCardPayment: confirmCardPaymentStub,
      paymentRequest: paymentRequestStub,
    });
  },
});

With these two steps in place we can now complete the necessary actions in the browser for the payment and then trigger the event callback with a custom event object:

cy.populateCardDetails();

const event = {
  complete: cy.stub(),
  paymentMethod: {
    id: 'apple_pay_id',
  },
};

cy.submitForm().then(() => {
  // Ensure that the `paymentMethod` method was called with
  // the expected arguments from our application
  expect(paymentRequestStub.getCall(0).args[0]).to.deep.contain({
    country: 'US',
    currency: 'usd',
    total: {
      label: 'Demo total',
      amount: 1099,
    },
  });

  // Trigger our event handler after the form has been submitted
  eventRegistry['paymentmethod'](event);
});

// Wait for something to update in the UI or for the URL to change to indicate
// payment is complete and then assert everything was called correctly
cy.location('pathname')
  .should('eq', '/payment-complete')
  .then(() => {
    expect(confirmCardPaymentStub.getCall(0).args).to.deep.equal([
      'clientSecret',
      {payment_method: 'apple_pay_id'},
      {handleActions: false},
    ]);
    expect(event.complete).to.have.been.calledWith('success');
    expect(event.complete.calledOnce).to.be.true;
  })

And there you have it! Stripe mocking in Cypress.

Additional reading