Closed danseaman6 closed 3 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.
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
.
A testing guide with a simple example would be nice.
testing examples would be SO USEFUL!
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
},
})
})
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>,
)
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.)
@robwold i've been able to simulate onReady with
component.find('CardElement').simulate('ready', stripeElement)
perhaps this approach could work?
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 ...
@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.
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.
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.
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?
@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.
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>
</>
);
Any news about this?
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.
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?
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?
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;
}),
};
};
@AyeshW your code doesn't solve the issue @cmacdonnacha is having. I'm also having it too.
@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();
},
};
});
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() }, })
@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
)
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()
})
}
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'));
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.