stripe / react-stripe-js

React components for Stripe.js and Stripe Elements
https://stripe.com/docs/stripe-js/react
MIT License
1.76k stars 268 forks source link

Exposing mockStripe and mockElements for testing purposes #59

Closed danseaman6 closed 3 years ago

danseaman6 commented 4 years ago

Would it be possible to expose your testing mocks as part of your library? It would make it a lot easier to write jest tests for components that implement the useStripe and useElements hooks, since they need to be wrapped in "valid" Elements components.

dweedon-stripe commented 4 years ago

Hi @danseaman6,

Feel free to copy and use the mocks for your own tests, but I'm not sure they are something we want to export and document as official API surface-area. They are intended only for our own internal tests and are Jest specific (while popular, not everyone uses Jest).

I think rather than export these mocks, we want to provide a more cohesive testing strategy. It's a recognized need and is something we are thinking about.

Let's leave this issue open as a forum to discuss testing strategies until we have something more official.

danseaman6 commented 4 years ago

That sound good. Currently the issues I'm running into while trying to write tests for my checkout form:

As a side note, the mockElements function you have in your test directory does through TS errors on elements[type] being an implicit any.

hakunin commented 4 years ago

A testing guide with a simple example would be nice.

lauralouiset commented 4 years ago

testing examples would be SO USEFUL!

lauralouiset commented 4 years ago

for those who need to mock useStripe() and useElements(), here's my jest.mock:

const mockElement = () => ({
  mount: jest.fn(),
  destroy: jest.fn(),
  on: jest.fn(),
  update: jest.fn(),
})

const mockElements = () => {
  const elements = {};
  return {
    create: jest.fn((type) => {
      elements[type] = mockElement();
      return elements[type];
    }),
    getElement: jest.fn((type) => {
      return elements[type] || null;
    }),
  }
}

const mockStripe = () => ({
  elements: jest.fn(() => mockElements()),
  createToken: jest.fn(),
  createSource: jest.fn(),
  createPaymentMethod: jest.fn(),
  confirmCardPayment: jest.fn(),
  confirmCardSetup: jest.fn(),
  paymentRequest: jest.fn(),
  _registerWrapper: jest.fn(),
})

jest.mock('@stripe/react-stripe-js', () => {
  const stripe = jest.requireActual('@stripe/react-stripe-js')

  return ({
    ...stripe,
    Element: () => {
      return mockElement
    },
    useStripe: () => {
      return mockStripe
    },
    useElements: () => {
      return mockElements
    },
  })
})
k88manish commented 4 years ago

I am trying to render CardElement using react-testing-library, but it doesn't render anything. Am I missing anything

  const stripe = mockStripe()

  return render(
    <Elements stripe={stripe}>
      <CardElement {...props} />
    </Elements>,
  )
robwold commented 4 years ago

If anyone has any example of a working unit test against some sort of semi-realistic form, that would be wonderful. As an example of the kinds of problem that arise right now: In my form, I'm using the onChange prop of CardElement to validate input before allowing form submission. But once I've mocked out CardElement, I have no way of entering valid input, and hence no way of firing input validation- mocked or otherwise. (I guess this onChange must relate to the mocked function on in @lauralouiset 's example above, although it's not totally clear how.)

lauralouiset commented 4 years ago

@robwold i've been able to simulate onReady with

component.find('CardElement').simulate('ready', stripeElement)

perhaps this approach could work?

robwold commented 4 years ago

Thanks @lauralouiset - is component.find part of the Enzyme API? I'm new to testing react components and I'm using react-testing-library so it took me a little while to figure out what was going on there. The philosophy of that library discourages shallow rendering which perhaps exacerbates the difficulties I've been having in mocking out the API and UI simultaneously ...

lauralouiset commented 4 years ago

@robwold oh yeah, i should have mentioned that I'm using enzyme.

I should mention that I had to tweak my mocks a bit (mockElement, mockElements, and mockStripe, so they returned objects instead of functions) so YMMV. the jest.mock() is the important part of the above example.

robwold commented 4 years ago

OK, thanks @lauralouiset. I think the fundamental root of my problem is that it's not clear to me how the interface of your mocks (which I'm guessing you built based on the ones in this repo?) relates to the behaviour of the real components like <CardElement/>. I guess I need to figure out how these React components are defined relative to the API documented at https://stripe.com/docs/js/elements_object, as the underlying code basically only seems to be available in browserified form for reasons of PCI compliance.

lauralouiset commented 4 years ago

tbh i'm literally trying to figure this out now too :-(

i was able to trigger the onReady but am having a horrible time trying to mock out calling .focus() on the element ref.

gabrielreisn commented 4 years ago

Following the internal mocks, i could find something related to the change event

    mockElement = mocks.mockElement();
    mockElement.on = jest.fn((event, fn) => {
      switch (event) {
        case 'change':
          simulateChange = fn;
          break;
        ...
        default:
          throw new Error('TestSetupError: Unexpected event registration.');
      }
    });

but I don't see the mock being actually called, which not allow me to test the "complete" propriety

// actual code implementation
 elements?.getElement(CardElement)?.on('change', e => {
    if (e.complete) ...logic here
  )} 

any thoughts / help on this?

jonathanawesome commented 3 years ago

@robwold I believe we have a similar situation as you where we're using the onChange handler to give us feedback we can use in our UI about the CardElement state. Here's our StripeCardElement component, which includes error messages (from Stripe and possibly from our e-commerce platform, Saleor) below the Stripe form.

import { useState } from 'react';
import { CardElement } from '@stripe/react-stripe-js';

/* props */
import { StripeCardElementProps } from './types';

/* styles */
import {
  StyledStripeCardElementWrap,
  StyledStripeCardElement,
  StyledStripeError,
} from './styles';

export const StripeCardElement = ({
  setStripeSubmittable,
  saleorErrorMessage,
}: StripeCardElementProps) => {
  const [stripeError, setStripeError] = useState(null);

  // eslint-disable-next-line consistent-return
  const handleChange = (event: {
    complete: boolean;
    error: {
      message: string;
      code: string;
      type: string;
    };
    // eslint-disable-next-line consistent-return
  }) => {
    if (event.complete) {
      return setStripeSubmittable(true);
    }
    if (!event.complete) {
      return setStripeSubmittable(false);
    }

    if (event.error) {
      return setStripeError(event.error.message);
    }
    if (!event.error) {
      return setStripeError(null);
    }
  };

  return (
    <StyledStripeCardElementWrap data-testid="styled-stripe-card-element-wrap">
      <StyledStripeCardElement>
          <CardElement
            onChange={handleChange}
            options={{
              style: {
                base: {
                  fontSize: '16px',
                  color: '#424770',
                  '::placeholder': {
                    color: '#aab7c4',
                  },
                },
                invalid: {
                  color: '#9e2146',
                },
              },
            }}
          />
      </StyledStripeCardElement>
      {saleorErrorMessage && <StyledStripeError>{saleorErrorMessage}</StyledStripeError>}
      {stripeError && <StyledStripeError>{stripeError}</StyledStripeError>}
    </StyledStripeCardElementWrap>
  );
};

☝️ This component is used inside of a form that includes additional inputs that we need to collect before we submit the whole thing. We keep the button for that form disabled until all fields are validated (including the CardElement) and we're getting non-null values from useStripe and useElements, hence the need to get an event.complete response from the CardElement in order to properly test our parent form. Here's the function we use to unlock our button:

  const canBeSubmitted = () => {
    // if we're using the shipping address for billing, skip checking isSubmittable
    if (fieldValues.billingRadio.value === 'same-billing') {
      return !stripe || !elements || !stripeSubmittable;
    }
    // if we're using a different billing address, ensure the fields are submittable
    return !stripe || !elements || !stripeSubmittable || !isSubmittable();
  };

The solution I came up with, while being wonky and likely not something we'll keep around as soon as a better/more official way to simulate CardElement events comes around, is to replace the CardElement in our test env with a button that we can userEvent.click on using react-testing-library that allows our form button to unlock.

   <StyledStripeCardElement>
        {process.env.NODE_ENV === 'test' ? (
          <button
            type="button"
            onClick={() => setStripeSubmittable(true)}
            data-testid="set-stripe-submittable-true"
          >
            setStripeSubmittable(true)
          </button>
        ) : (
          <CardElement
            onChange={handleChange}
            options={{
              style: {
                base: {
                  fontSize: '16px',
                  color: '#424770',
                  '::placeholder': {
                    color: '#aab7c4',
                  },
                },
                invalid: {
                  color: '#9e2146',
                },
              },
            }}
          />
        )}
      </StyledStripeCardElement>

Do I like it? No.

Does it allow me to test out my form the way a user would? Yeah, mostly.

joeythomaschaske commented 3 years ago

This is some unrefined code I just got done fiddling with until I got it working. Hopefully it can help someone.

import React, { useState } from 'react';
import { Input } from 'reactstrap';

import * as stripe from '@stripe/react-stripe-js';
import * as stripeJs from '@stripe/stripe-js';
import user from '@testing-library/user-event';
import { mocked } from 'ts-jest/utils';

import { act, cleanup, render } from '../../../utils/test-utils';
import PaymentInformation from '../PaymentInformation';

jest.mock('@stripe/react-stripe-js');
const { Elements } = jest.requireActual('@stripe/react-stripe-js');
const mockedStripeReact = mocked(stripe);

type MockCardExpiryElementProps = {
  onChange: (event: Partial<stripeJs.StripeCardExpiryElementChangeEvent>) => unknown;
};
const MockCardExpiryElement: React.FC<MockCardExpiryElementProps> = ({ onChange }) => {
  const [value, setValue] = useState('');
  const changeHandler = ({ target }: React.ChangeEvent<HTMLInputElement>) => {
    if (!isNaN(Number(target.value))) {
      setValue(target.value);
      if (target.value.length === 4) {
        onChange({
          complete: true,
        });
      }
    }
  };
  return <Input onChange={changeHandler} value={value} id="expirationDate" />;
};

type MockCardNumberElementProps = {
  onChange: (event: Partial<stripeJs.StripeCardNumberElementChangeEvent>) => unknown;
};
const MockCardNumberElement: React.FC<MockCardNumberElementProps> = ({ onChange }) => {
  const [value, setValue] = useState('');
  const changeHandler = ({ target }: React.ChangeEvent<HTMLInputElement>) => {
    if (!isNaN(Number(target.value))) {
      setValue(target.value);
      if (target.value.length === 16) {
        onChange({
          complete: true,
        });
      }
    }
  };
  return <Input onChange={changeHandler} value={value} id="cardNumber" />;
};

mockedStripeReact.CardNumberElement.mockImplementation(MockCardNumberElement as React.FC<stripe.CardNumberElementProps>);
mockedStripeReact.CardExpiryElement.mockImplementation(MockCardExpiryElement as React.FC<stripe.CardExpiryElementProps>);

describe('Debit Card Form', () => {
    it('enables the save button when debit card fields are populated', async () => {
      // given
      const { getByLabelText, getByText } = render(
        <Elements stripe={null}>
          <PaymentInformation />
        </Elements>,
      );
      act(() => {
        user.click(getByText('Debit Card'));
      });
      const continueButton = getByText('Continue') as HTMLButtonElement;
      expect(continueButton.disabled).toBe(true);

      // when
      await act(async () => {
        await user.type(getByLabelText('Card Number'), '4111111111111111');
        await user.type(getByLabelText('Expiration Date'), '1299');
      });

      // then
      expect(continueButton.disabled).toBe(false);
    });
  });
});

And then in PaymentInformation we have

  const [cardNumberComplete, setCardNumberComplete] = useState(false);
  const [cardNumberError, setCardNumberError] = useState('');
  const [expirationDateComplete, setExpriationDateComplete] = useState(false);
  const [expirationDateError, setExpriationDateError] = useState('');

  const cardNumberValid = (): boolean => cardNumberComplete;
  const expirationDateValid = (): boolean => expirationDateComplete;

  const formValid = (): boolean => {
    if (showBankAccountForm) {
      return routingNumberValid() && accountNumberValid() && verifyAccountNumberValid();
    } else {
      return cardNumberValid() && expirationDateValid();
    }
  };

  const debitCardForm = (
    <>
      <FormGroup>
        <Label for="cardNumber">Card Number</Label>
        <CardNumberElement
          onChange={({ complete, error }) => {
            setCardNumberComplete(complete);
            setCardNumberError(error?.message || '');
          }}
        />
        <Input hidden invalid={!!cardNumberError} />
        <FormFeedback>{cardNumberError}</FormFeedback>
      </FormGroup>
      <FormGroup>
        <Label for="expirationDate">Expiration Date</Label>
        <CardExpiryElement
          onChange={({ complete, error }) => {
            setExpriationDateComplete(complete);
            setExpriationDateError(error?.message || '');
          }}
        />
        <Input hidden invalid={!!expirationDateError} />
        <FormFeedback>{expirationDateError}</FormFeedback>
      </FormGroup>
      <ButtonSpinner color="primary" showSpinner={isSaving} type="submit" disabled={!formValid()}>
        {isSaving ? 'Saving' : 'Continue'}
       </ButtonSpinner>
    </>
  );
MontoyaAndres commented 3 years ago

Any news about this?

stale[bot] commented 3 years ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

carpeliam commented 3 years ago

Feel free to copy and use the mocks for your own tests, but I'm not sure they are something we want to export and document as official API surface-area. They are intended only for our own internal tests and are Jest specific (while popular, not everyone uses Jest).

I think rather than export these mocks, we want to provide a more cohesive testing strategy. It's a recognized need and is something we are thinking about.

Let's leave this issue open as a forum to discuss testing strategies until we have something more official.

While I think it's great that some folks have offered their strategies on how they were able to mock Stripe/Elements, everyone individually maintaining their own mocks breaks the don't mock what you don't own rule of thumb and makes everyone's test brittle if the Stripe implementation ever changes. While it's workable, it doesn't feel like it makes for a healthy test base.

@dweedon-stripe and friends, would you be open to creating a react-stripe-js-jest library pegged at the same version as react-stripe-js? I know not everybody uses Jest, but Facebook does, and React does, and create-react-app does, and you do :) I don't think you'd be alienating anyone!

If an official mock doesn't sound good, can you speak more to what you'd want from something more cohesive?

cmacdonnacha commented 3 years ago

for those who need to mock useStripe() and useElements(), here's my jest.mock:

const mockElement = () => ({
  mount: jest.fn(),
  destroy: jest.fn(),
  on: jest.fn(),
  update: jest.fn(),
})

const mockElements = () => {
  const elements = {};
  return {
    create: jest.fn((type) => {
      elements[type] = mockElement();
      return elements[type];
    }),
    getElement: jest.fn((type) => {
      return elements[type] || null;
    }),
  }
}

const mockStripe = () => ({
  elements: jest.fn(() => mockElements()),
  createToken: jest.fn(),
  createSource: jest.fn(),
  createPaymentMethod: jest.fn(),
  confirmCardPayment: jest.fn(),
  confirmCardSetup: jest.fn(),
  paymentRequest: jest.fn(),
  _registerWrapper: jest.fn(),
})

jest.mock('@stripe/react-stripe-js', () => {
  const stripe = jest.requireActual('@stripe/react-stripe-js')

  return ({
    ...stripe,
    Element: () => {
      return mockElement
    },
    useStripe: () => {
      return mockStripe
    },
    useElements: () => {
      return mockElements
    },
  })
})

Hey @lauralouiset your suggestion has gotten me most of the way but I keep getting the following warning: TypeError: elements.getElement is not a function. Any ideas?

AyeshW commented 2 years ago

You can modify the @lauralouiset 's code as follows if you get any TS errors.

const mockElements = () => {
  const elements: { [key: string]: ReturnType<typeof mockElement> } = {};
  return {
    create: jest.fn((type) => {
      elements[type] = mockElement();
      return elements[type];
    }),
    getElement: jest.fn((type) => {
      return elements[type] || null;
    }),
  };
};
teseo commented 2 years ago

@AyeshW your code doesn't solve the issue @cmacdonnacha is having. I'm also having it too.

efloden commented 2 years ago

@teseo @cmacdonnacha Encountering the same issue, it seemed that elements was resolving to the mockElement function, so I could resolve it by modifying @lauralouiset's code as follows:

jest.mock('@stripe/react-stripe-js', () => {
    const stripe = jest.requireActual('@stripe/react-stripe-js');

    return {
        ...stripe,
        Element: () => {
            return mockElement();
        },
        useStripe: () => {
            return mockStripe();
        },
        useElements: () => {
            return mockElements();
        },
    };
});
hongNianYS commented 2 years ago

for those who need to mock useStripe() and useElements(), here's my jest.mock:

const mockElement = () => ({
  mount: jest.fn(),
  destroy: jest.fn(),
  on: jest.fn(),
  update: jest.fn(),
})

const mockElements = () => {
  const elements = {};
  return {
    create: jest.fn((type) => {
      elements[type] = mockElement();
      return elements[type];
    }),
    getElement: jest.fn((type) => {
      return elements[type] || null;
    }),
  }
}

const mockStripe = () => ({
  elements: jest.fn(() => mockElements()),
  createToken: jest.fn(),
  createSource: jest.fn(),
  createPaymentMethod: jest.fn(),
  confirmCardPayment: jest.fn(),
  confirmCardSetup: jest.fn(),
  paymentRequest: jest.fn(),
  _registerWrapper: jest.fn(),
})

jest.mock('@stripe/react-stripe-js', () => {
  const stripe = jest.requireActual('@stripe/react-stripe-js')

  return ({
    ...stripe,
    Element: () => {
      return mockElement
    },
    useStripe: () => {
      return mockStripe
    },
    useElements: () => {
      return mockElements
    },
  })
})

in the end, i think there should be '()' after the return like this:

return ({ ...stripe, Element: () => { return mockElement() }, useStripe: () => { return mockStripe() }, useElements: () => { return mockElements() }, })

GLosch commented 2 years ago

@lauralouiset thank you so much for sharing your mocks!

I was running into errors trying to write tests using PaymentElement (which has to be wrapped in the Elements provider) In case anyone runs into a similar issue to mine, I added PaymentElement: () => null to the returned stripe mock from @lauralouiset 's example above, and that allows for writing tests using Enzyme's mount (which is needed when testing a component with useEffect)

josemigallas commented 1 year ago

My 2 cents. In our project we only use CardElement with hooks, and this is everything we need for the tests to work. It will probably need to be expanded/improved eventually, but it's enough for basic testing.

// __mocks__/@stripe/stripe-js.js
module.exports = {
  loadStripe: () => Promise.resolve(null)
}

// __mocks__/@stripe/react-stripe-js.js
module.exports = {
  Elements: () => null,
  CardElement: () => null,
  useStripe: () => ({
    confirmCardSetup: jest.fn(),
  }),
  useElements: () => ({
    getElement: jest.fn()
  })
}
bentwistbsc commented 1 year ago

To those of us who are using react-testing-library and don't test implementation details, these mocking worked out for me:

export const ElementsMock = ({children}: {children: ReactChildren}) => <div>{children}</div>;
export const MockedInput =
  (elementName: string) =>
  ({onChange}: {onChange: (event: StripeElementChangeEvent) => any}) => {
    const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
      onChange({
        empty: !e.currentTarget.value,
        elementType: elementName as StripeElementType,
        complete: false,
        error: null,
      });
    };
    return <input data-testid={elementName} onChange={onInputChange} />;
  };

Then, in the test it self I mocked the components:

            loadStripe.mockImplementation(() => null);
            Elements.mockImplementation(ElementsMock);
            CardNumberElement.mockImplementation(MockedInput('card-number'));
            CardCvcElement.mockImplementation(MockedInput('card-cvc'));
            CardExpiryElement.mockImplementation(MockedInput('card-expiry'));