I am working on an App Bridge app and I need to mock shopify.resourcePicker
when rendering my route.
We have a convenience function for rendering a route:
import { createRemixStub } from '@remix-run/testing'
import { AppProvider } from '@shopify/shopify-app-remix/react'
import { render } from '@testing-library/react'
type StubRouteObject = Parameters<typeof createRemixStub>[0][0]
type ComponentType = NonNullable<StubRouteObject['Component']>
type RenderArgs = Parameters<typeof render>[1]
export default function renderRoute(
Component: ComponentType,
stubOptions?: Partial<StubRouteObject>,
renderOptions?: Partial<RenderArgs>
) {
const RemixStub = createRemixStub([
{
path: '/',
Component,
...stubOptions,
},
])
return render(<RemixStub />, {
wrapper: (props) => (
<AppProvider apiKey="foo" isEmbeddedApp>
{props.children}
</AppProvider>
),
...renderOptions,
})
}
The route I'm rendering has a hook that calls shopify.resourcePicker
. If I simply render the route in the test and click the button that calls the resource picker, I get
TypeError: shopify.resourcePicker is not a function
❯ app/tools/offers/routes/new/useNewOfferForm.tsx:80:22
78| const selectProduct = useCallback(async () => {
79| const [pickedProduct] =
80| (await shopify.resourcePicker({
| ^
81| type: 'product',
82| filter: { variants: false },
Which I suppose makes sense. I've tried a few different ways of mocking this shopify global, including reexporting it from my own file so that I'm mocking that file instead of mocking globals.
export const AppBridge = shopify
I've set up my test so that it renders the route, picks the product, sets the form values, and submits the form. The Remix Stub has a simple action wired up that currently just returns 200 (code is at the bottom).
Various mocking strategies don't work:
This has the same result as above (TypeError: shopify.resourcePicker is not a function
)
vi.mock('shopify', async (importOriginal) => {
const mockResource = [{ id: 'gid://shopify/Product/123' }]
const original = await importOriginal<typeof shopify>()
return {
...original,
resourcePicker: () => Promise.resolve(mockResource),
}
})
If I instead import the resource picker by way of my simple re-export and then mock my re-export file:
vi.mock('./ShopifyUtilities', async (importOriginal) => {
const mockResource = [{ id: 'gid://shopify/Product/123' }]
const original = await importOriginal<{ AppBridge: typeof AppBridge }>()
return {
...original,
AppBridge: {
...original.AppBridge,
resourcePicker: () => Promise.resolve(mockResource),
},
}
})
then a debugging breakpoint shows that the resource picker is actually correctly mocked, and returns the way I want it to. However, something is broken in the action:
AssertionError: expected "spy" to return with: 200 at least once
Received:
1st spy call return:
- Expected:
200
+ Received:
undefined
The debugger won't stop anywhere in the action, and the log at the top of the action doesn't appear in the test results. I've tried vi.spyOn
, I've tried vi.stubGlobals
and they all either:
- appear to not mock the function at all (and give the "resourcePicker is not a function" error) or
- mock correctly but break the action and I can't figure out why
- another case I haven't illustrated where it says "resourcePicker doesn't exist"
I do notice when I put a breakpoint at the call to resourcePicker
that the only member in shopify
is loading()
, whereas when I inspect it in my running app using DevTools, shopify
has over a dozen members. That seems like a clue but I don't know what to do with it.
Has anyone encountered and succeeded with this problem before?
The code for the test is below.
describe('New Offers Route component', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
const ACTION_RETURN = 200
const action = vi
.fn()
.mockImplementation(async ({ request }: ActionFunctionArgs) => {
console.log('TOP OF ACTION')
return ACTION_RETURN
})
it('renders the page', async () => {
renderRoute(Component, {
loader: () => ({ /* stubbed loader data */}),
action,
})
const form = await screen.findByTestId(TestId.newOfferPage)
const button = form.querySelector(
`#${TestId.selectProductButton}`
) as HTMLButtonElement
fireEvent.click(button)
// Fill the form fields with the values from formValues
Object.entries(formValues).forEach(([key, value]) => {
const input = form.querySelector(`input[name=${key}]`) as HTMLInputElement
if (input && value) input.value = value
})
const saveButton = await screen.findByTestId(TestId.saveButton)
fireEvent.click(saveButton)
await waitFor(() => {
expect(action).toHaveBeenCalledOnce()
expect(action).toHaveReturnedWith(ACTION_RETURN)
})
})
})