One of the less exciting things about being a Frontend Developer is having to handle error, empty and loading states. It may not be the most fun thing to do, but it's necessary in order to give your users the best experience possible. Thankfully, Mock Service Worker (MSW) makes this really simple, and in turn...kinda fun. ๐
In this article, we take a look at how we can use MSW to mock these states for both local development and in our tests.
Getting started
While mocking these states, you'll see that we make use of page URL query parameters. I find this to be a great pattern as it gives us the flexibility to change them on the fly using only the browser, which makes developing UIs to handle these scenarios a breeze!
Mocking errors
Here's an example of how you can mock a simple error:
export const handlers = [
rest.get(`/your/api/endpoint`, (req, res, ctx) => {
// Check if the '?error=true' query param has been
// included in the page url.
const pageParams = new URLSearchParams(window.location.search);
const isError = pageParams.get('error') === 'true';
// Example: http://localhost:3000/your_page_url?error=true
if (isError) {
// Query param was found, so return an error response.
return res(
ctx.status(404),
ctx.json({ message: 'Oops! Something went terribly wrong.' })
);
}
// Otherwise - return a 200 OK response.
return res(ctx.status(200), ctx.json({ data: 'Some cool data' }));
})
];
As you can see, you have full control over what data MSW will return, depending on the query parameter included in the page URL.
You can also include this same error scenario in your tests:
describe('MyPage', () => {
afterEach(() => {
// Make sure to reset page url state after each test, otherwise
// tests written after this one will still include the
// `?error=true` param.
window.history.replaceState({}, '', '/');
});
it('should display an error for some reason', async () => {
// Add a query param to the page url so that we get
// an error response back.
window.history.replaceState({}, '', '/?error=true');
await renderPage();
// Check that the error message was displayed.
expect(await screen.findByText('Oops! Something went terribly wrong.')).toBeInTheDocument();
});
});
Suppressing intentional errors
When testing network errors, you may notice some console.error
messages polluting the test output. If this is the case for you, and since these errors are expected, you can simply suppress them inside your test:
it('should display an error for some reason', async () => {
// Suppress console errors that we intentionally trigger in this test.
jest.spyOn(console, 'error').mockImplementation(() => {});
}
Mocking empty states
This is similar to mocking errors, but you return an empty response instead:
rest.get(`/your/api/endpoint`, (req, res, ctx) => {
const pageParams = new URLSearchParams(window.location.search);
const isEmpty = pageParams.get('empty') === 'true';
if (isEmpty) {
// Example: http://localhost:3000/your_page_url?empty=true
return res(ctx.status(200), ctx.json([]));
}
})
Mocking loading states
This is something I find extremely useful when developing loading components. Here, we apply an infinite delay to the response so that it never resolves:
rest.get(`/your/api/endpoint`, (req, res, ctx) => {
const pageParams = new URLSearchParams(window.location.search);
const isEmpty = pageParams.get('loading') === 'true';
if (isLoading) {
// Example http://localhost:3000/your_page_url?loading=true
return res(ctx.status(200), ctx.json({}), ctx.delay("infinite"));
}
})
Alternative mocking patterns
Though the examples given above which make use of page URL query parameters are what I recommended, here are some other ways to simulate states:
In tests, you could use runtime overrides so that the response will only occur inside your single test.
Return an error depending on the payload sent. e.g A certain username.
Look up the request URL query parameters using conditional responses. This is similar to what we've used above, except you would get the query parameter from the request URL instead of the page URL.
Utility functions
After using this pattern for a while, I decided to create some utility functions which I've found useful while creating handlers.
hasQueryParam()
As you can see from the examples above, we have some code that checks if a page URL query parameter is present:
const pageParams = new URLSearchParams(window.location.search);
const isSomething = pageParams.get('something') === 'true';
This can get quite repetitive when you have multiple query parameters to check for. Let's go ahead and improve this by creating a re-usable utility function:
// utils.ts
export function hasQueryParam(
queryName: string,
queryValue: string = 'true'
) {
const searchParams = new URLSearchParams(window.location.search);
return searchParams.get(queryName) === queryValue;
}
// handlers.ts
import { hasQueryParam } from './utils';
export const handlers = [
rest.get(`/your/api/endpoint`, (req, res, ctx) => {
if (hasQueryParam('error') {
return res(
ctx.status(404),
ctx.json({ message: 'Oops! Something went terribly wrong.' })
);
}
// You can also check for different values (true is the default).
// Example http://localhost:3000/your_page_url?animal=dog
if (hasQueryParam('animal', 'dog') {
return res(
ctx.status(200),
ctx.json({ type: 'dog', name: 'Banjo' })
);
}
return res(ctx.status(200), ctx.json({ data: 'Some cool data' }));
})
];
customResponse()
As your project grows, so will your handlers. Having to deal with error, empty and loading scenarios for every single request handler will start to get quite verbose. Therefore, I take advantage of MSW's custom response composition to automatically include these scenarios in each response. It will also add a realistic server delay to the response, mimicking a real-world app. This gives us some nice "built-in" features which are abstracted away from the handlers.
// utils.ts
import { compose, context, response, ResponseTransformer } from 'msw';
export function customResponse(...transformers: ResponseTransformer[]) {
if (hasQueryParam('loading')) {
return response(
context.delay('infinite'),
context.json({})
);
}
if (hasQueryParam('error')) {
return response(
context.status(400),
context.json({ error: 'Oops! Something went terribly wrong.' })
);
}
if (hasQueryParam('empty')) {
return response(
context.status(200),
context.json([])
);
}
// Return the exact same response transforms which were passed in,
// but also add a realistic server delay.
return response(...transformers, ctx.delay());
}
// handlers.ts
import { customResponse } from './utils';
export const handlers = [
rest.get(`/your/api/endpoint`, (req, res, ctx) => {
// Now error, loading and empty states are handled
// under the hood by customResponse(). Nifty!
return customResponse(
ctx.status(200),
ctx.json({ data: 'Some cool data' })
);
})
];
These utility functions have really improved the developer experience within our team as we no longer have to write the same error, loading and empty checks over and over. We also used to add delays to every single response individually, so not having to even think about that has been awesome!
Page vs request URL query parameters
Another popular pattern is to use request URL query parameters to return the appropriate response. For example:
rest.post('/your/api/endpoint', async (req, res, ctx) => {
// Check if the 'userId' param exists in the request url.
const userId = req.url.searchParams.get('userId')
if (userId === 'mr-bad-user-1234') {
return res(
ctx.status(403),
ctx.json({ errorMessage: 'User not found' }),
);
}
})
The above is fine when only using MSW for tests, but not so great when using it for local development in the browser.
I prefer to use page URL query parameters for the following reasons:
We can achieve the same behaviour across both tests and local development as you will be re-using the same mock definitions.
We can easily change the UI by swapping out query parameters in the browser's URL address bar.
We can bookmark URLs to quickly display certain scenarios, without having to remember the query parameters.
Live code example
Here's a CodeSandbox app where I've added live examples for the scenarios shown above.
You can also run the tests by opening a new terminal within CodeSandbox and running the yarn test
command.
Upcoming beta
It's worth mentioning that at the time of writing this article, there's a new and improved version of MSW on the horizon which is currently in beta. Though I look forward to trying it out and applying these patterns to it, I decided to reference the latest production version (0.47.1
) as it's what most folks will continue to use for the next while.
Final thoughts
That's about it! You can see just how useful MSW can be, not just for testing, but for local development too. At the end of the day, it's just Javascript, so there's nothing stopping you from returning whatever data you want depending on a query parameter. The sky is truly the limit!
If you found this article helpful or have additional tips, please leave a comment below. ๐
Want to see more?
I mainly write about real tech topics I face in my everyday life as a Frontend Developer. If this appeals to you then feel free to follow me on Twitter: https://twitter.com/cmacdonnacha
Bye for now. ๐