Subject Under Test
A generic hook wrapping useMutation
of React-Query
for all of my master-detail edit forms. It is an adaptor responsible for generating URL by entity name, error handling and invalidating relevant queries.
Behaviours
it takes an
entityName
and generates the correct URL to send a post request toit forwards models and request configurations to the HTTP request
it wires up
ErrorHandler
for error handlingit invalidates queries with keys containing the
entityName
MSW and @mockapi/msw
The tests use MSW and @mockapi/msw to mock a post
endpoint for a dummy entity Supplier
. @mockapi/msw provides a standard set of CRUD endpoints so that I do not have to write any endpoints for this test suite. For more information: @mockapi/msw
Code
import { render } from '@testing-library/react'; | |
import { MessageProvider, useMessage } from '../message'; | |
import { QueryClientProviderForTest, useTranslationForTest } from '@epic/testing/react'; | |
import { useSaveItem, UseSaveItemOptions } from './useSaveItem'; | |
import userEvent from '@testing-library/user-event'; | |
import { Supplier, supplierKey } from './msw/supplier'; | |
import { useGetItems } from './useGetItems'; | |
import { baseUrl, repositoryFactory } from './msw/mockapi'; | |
import { server } from './msw/server'; | |
import { AxiosRequestConfig } from 'axios'; | |
import { rest } from 'msw'; | |
import { suppliers } from './msw/seedSupplier'; | |
const supplier = { | |
id: '3', | |
name: 'Dev.to', | |
}; | |
function TestComponent(props: Omit<UseSaveItemOptions, 'entityName'>) { | |
const { t } = useTranslationForTest(); | |
const { message } = useMessage(); | |
const { mutate, isError, isSuccess } = useSaveItem<Supplier>({ | |
entityName: supplierKey, | |
t, | |
...props, | |
}); | |
const { data: suppliers, isSuccess: isLoadingSuppliersSuccess } = useGetItems<Supplier>({ | |
entityName: supplierKey, | |
}); | |
const save = () => { | |
mutate(supplier); | |
}; | |
return ( | |
<> | |
<button onClick={() => save()}>Save</button> | |
{isSuccess && <div>Success</div>} | |
{isLoadingSuppliersSuccess && suppliers ? ( | |
<div data-testid={'suppliers'}>{suppliers.map((supplier) => supplier.name).join(', ')}</div> | |
) : null} | |
{isError ? ( | |
<div data-testid={'errorMessages'}> | |
{message?.message ? <div>{message.message}</div> : null} | |
</div> | |
) : null} | |
</> | |
); | |
} | |
function setup(props: Omit<UseSaveItemOptions, 'entityName'> = {}) { | |
return render( | |
<QueryClientProviderForTest> | |
<MessageProvider> | |
<TestComponent {...props} /> | |
</MessageProvider> | |
</QueryClientProviderForTest> | |
); | |
} | |
describe('useSaveItem', function () { | |
const repository = repositoryFactory<Supplier>(supplierKey); | |
it('should send post request', async function () { | |
const { findByText, getByText } = setup(); | |
expect(repository.getItems().data).toMatchObject(suppliers); | |
const button = getByText('Save'); | |
userEvent.click(button); | |
// await for react-query to finish | |
await findByText('Success'); | |
expect(repository.getItems().data).toMatchObject([...suppliers, supplier]); | |
}); | |
it('should pass request configuration to the http call', async function () { | |
let expectedId; | |
server.use( | |
rest.post(`${baseUrl}/${supplierKey}`, (req) => { | |
const id = req.url.searchParams.get('id') as string; | |
if (id) { | |
expectedId = id; | |
} | |
}) | |
); | |
async function setupAndAct(requestConfig?: AxiosRequestConfig) { | |
const { getByText, findByText, unmount } = setup({ | |
requestConfig: requestConfig, | |
}); | |
const button = getByText('Save'); | |
userEvent.click(button); | |
await findByText('Success'); | |
unmount(); | |
} | |
await setupAndAct(); | |
expect(expectedId).toBeUndefined(); | |
await setupAndAct({ params: { id: '1' } }); | |
expect(expectedId).toBe('1'); | |
}); | |
it('should invalidate query so that other queries can be updated by react-query', async function () { | |
const { getByText, findByText } = setup(); | |
expect(await findByText('EpicERP Ltd, Microsoft')).toBeInTheDocument(); | |
const button = getByText('Save'); | |
userEvent.click(button); | |
expect(await findByText('EpicERP Ltd, Microsoft, Dev.to')).toBeInTheDocument(); | |
}); | |
function mockErrorResponse() { | |
server.use( | |
rest.post(`${baseUrl}/${supplierKey}`, (_req, res, ctx) => { | |
return res.once( | |
ctx.status(500), | |
ctx.json({ detail: 'Something Went Wrong on The Server' }) | |
); | |
}) | |
); | |
} | |
it('should send default translated error message to the MessageContext, if no error message is given', async function () { | |
const { getByText, findByTestId } = setup(); | |
mockErrorResponse(); | |
const button = getByText('Save'); | |
userEvent.click(button); | |
const errorMessage = await findByTestId('errorMessages'); | |
expect(errorMessage.innerHTML).toMatchInlineSnapshot( | |
`"<div>Save failed. Something Went Wrong on The Server[Translated]</div>"` | |
); | |
}); | |
it('should send translated custom error message to the MessageContext', async function () { | |
mockErrorResponse(); | |
const { getByText, findByTestId } = setup({ errorMessage: 'Saving Supplier Failed.' }); | |
const button = getByText('Save'); | |
userEvent.click(button); | |
const errorMessage = await findByTestId('errorMessages'); | |
expect(errorMessage.innerHTML).toMatchInlineSnapshot( | |
`"<div>Saving Supplier Failed. Something Went Wrong on The Server[Translated]</div>"` | |
); | |
}); | |
}); |
import { useMutation, useQueryClient } from 'react-query'; | |
import { getApiBaseUrl } from './getApiBaseUrl'; | |
import { useClient } from './useClient'; | |
import { invalidateQueriesByEntityName } from './invalidateQueriesByEntityName'; | |
import { useErrorHandler } from './useErrorHandler'; | |
import { AxiosRequestConfig } from 'axios'; | |
export interface UseSaveItemOptions { | |
entityName: string; | |
t?: (english?: string) => string | undefined; | |
requestConfig?: AxiosRequestConfig; | |
errorMessage?: string; | |
} | |
export const useSaveItem = <T extends { id: string }>({ | |
entityName, | |
t = (english?: string) => english, | |
requestConfig = {}, | |
errorMessage, | |
}: UseSaveItemOptions) => { | |
const { post } = useClient(); | |
const { errorHandler } = useErrorHandler({ message: errorMessage ?? 'Save failed.', t }); | |
const queryClient = useQueryClient(); | |
return useMutation((model: T) => post(`${getApiBaseUrl(entityName)}`, model, requestConfig), { | |
onError: errorHandler, | |
onSuccess: () => { | |
setTimeout(() => invalidateQueriesByEntityName(queryClient)(entityName), 250); | |
}, | |
}); | |
}; |
Notes
TestComponent
shows how the SUT is to be used. It also has auseGetItems
hook to test if the queries are invalidated properly. The code and tests foruseGetItems
are here, it also introduced the use ofuseTranslationForTest
andQueryClientProviderForTest
the tests use
findByText
as a way to wait for the SUT to finish its operationsserver.use
from MSW is used to override the target endpoint to test theparams
and error responses.With the help of MSW, the tests only care about the data flows. I love the setup because it doesn't care about how the HTTP requests are made. I could have replaced React Query and Axios without touching the tests.
Top comments (0)