DEV Community

Rex
Rex

Posted on

Testing a Generic Save Item Hook with MSW and @mockapi/msw

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

  1. it takes an entityName and generates the correct URL to send a post request to

  2. it forwards models and request configurations to the HTTP request

  3. it wires up ErrorHandler for error handling

  4. it 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);
},
});
};
view raw useSaveItem.ts hosted with ❤ by GitHub

Notes

  1. TestComponent shows how the SUT is to be used. It also has a useGetItems hook to test if the queries are invalidated properly. The code and tests for useGetItems are here, it also introduced the use of useTranslationForTest and QueryClientProviderForTest

  2. the tests use findByText as a way to wait for the SUT to finish its operations

  3. server.use from MSW is used to override the target endpoint to test the params and error responses.

  4. 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)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs