Testing network requests
There are a number of different ways to approach unit testing functions that make API requests. In this article, I will assume that we are making API requests to a REST API. I will discuss how we can test our API requests using:
Functions that will be tested#
Usually there are usually three kinds of requests that we can make.
- Retrieving data from a server
- Sending data to a server and handling errors if the server returns an error
- Sending data to a server and handling errors if we do not care what the server returns
For the purposes of this article, I will be using the following API requests to demonstrate how we can test network requests. They each involve retrieving, creating or tracking profile data with the axios HTTP client. A profile will look like this:
export type Profile = { profileId: string; name: string;};
Get profile#
The first function retrieves a profile by sending a GET /profiles/${profileId}
request with a
bearer token.
import request from 'axios';
const profileDataSource = { getProfile: async ({ bearerToken, profileId }) => { const response = await request<Profile>({ url: `/profiles/${profileId}`, baseURL: baseUrl, method: 'GET', headers: { Authorization: `Bearer ${bearerToken}`, }, });
return response.data; },};
Create a profile#
Similarly, this function creates a profile by sending a POST /profiles
with a
payload and bearer token.
const profileDataSource = { createProfile: async ({ bearerToken, name }) => { const response = await request<Profile>({ url: '/profiles', baseURL: baseUrl, method: 'POST', headers: { Authorization: `Bearer ${bearerToken}`, }, data: { name, }, });
return response.data; },};
Send profile created event#
This function is very similar to createProfile
except the validateStatus
function
is used to ensure that we ignore the response from the server. This means that
sendProfileCreatedTrackingEvent
does not care if there is a network error or if the
server returns an invalid response status code.
This is quite common for either sending events for analytics or logging where we do not want to throw an error for non-critical use cases.
const profileDataSource = { sendProfileCreatedTrackingEvent: async (profileId) => { await request({ url: '/analytics/profile-created-event', baseURL: baseUrl, method: 'POST', data: { profileId, }, validateStatus: () => true, }); },};
Native vitest
/ jest
mocking#
The first thought we might have is to use the native mocking functionality that is provided by our testing framework.
I will be using vitest
for the examples in this article, but jest
will be very similar.
Setup#
To setup our test, we need to mock out the request
function from our HTTP client, in this case, axios
.
vi.mock('axios');
This replaces the implementation of request
with a mock stub, vi.fn()
.
Since we will be setting the return value of request
in each test, we should make
sure that each test is cleaned up appropriately so that the tests are run in isolation.
afterEach(() => { vi.resetAllMocks();});
Mocking when retrieving data#
To test our getProfile
function we could mock out what request
returns:
it('should return profile when calling getProfile', async () => { vi.mocked(request).mockResolvedValueOnce({ data: profile, });
const result = await profileDataSource.getProfile({ bearerToken, profileId: profile.profileId, });
expect(result).toEqual(profile);});
Then, we could assert that request
was called with the correct parameters:
expect(request).toHaveBeenCalledWith<[AxiosRequestConfig]>( expect.objectContaining<AxiosRequestConfig>({ url: `/profiles/${profile.profileId}`, headers: { Authorization: `Bearer ${bearerToken}`, }, }),);
Mocking when sending data with response handled#
Similarly, to test our createProfile
function we could mock out what request
returns and assert that request
is called with the correct parameters:
it('should create a profile when calling createProfile', async () => { vi.mocked(request).mockResolvedValueOnce({ data: profile, });
const result = await profileDataSource.createProfile({ bearerToken, name: profile.name, });
expect(result).toEqual(profile); expect(request).toHaveBeenCalledWith<[AxiosRequestConfig]>( expect.objectContaining<AxiosRequestConfig>({ url: '/profiles', data: { name: profile.name, }, headers: { Authorization: `Bearer ${bearerToken}`, }, }), );});
Mocking when sending with response unhandled#
Similarly, to test our sendProfileCreatedTrackingEvent
function we could mock out what request
returns and assert that request
is called with the correct parameters:
it('should send tracking event with profileId when calling sendProfileCreatedTrackingEvent', async () => { vi.mocked(request).mockResolvedValueOnce({});
await profileDataSource.sendProfileCreatedTrackingEvent(profile.profileId);
expect(request).toHaveBeenCalledWith<[AxiosRequestConfig]>( expect.objectContaining<AxiosRequestConfig>({ url: '/analytics/profile-created-event', data: { profileId: profile.profileId, }, }), );});
As mentioned before, vi.mock
will replace the implementation of request
with a mock stub vi.fn()
and so we do not actually need to mock out the return value of request
because sendProfileCreatedTrackingEvent
never uses the return value of request
.
it('should send tracking event with profileId when calling sendProfileCreatedTrackingEvent', async () => { vi.mocked(request).mockResolvedValueOnce({}) await profileDataSource.sendProfileCreatedTrackingEvent(profile.profileId); //...})
Error handling#
The main problem with mocking out request
is that it is usually very challenging to correctly mirror the behavior of your HTTP client when it receives an error.
For example, axios
will throw an error when it receives a non-2xx HTTP status code whereas fetch
will not.
So, if I added a check to getProfile
so that it returned null
if the response status code was 404
.
const getProfile = async ({ bearerToken, profileId }) => { const response = await request<Profile>({ url: `/profiles/${profileId}`, baseURL: baseUrl, method: 'GET', headers: { Authorization: `Bearer ${bearerToken}`, }, });
if (response.status === 404) { return null; }
return response.data;}
You may think that the following test case covers the scenario where the server returns 404
:
it('should return null when server returns 404 on getProfile', async () => { vi.mocked(request).mockResolvedValueOnce({ status: 404, });
const result = await profileDataSource.getProfile({ bearerToken, profileId: profile.profileId, });
expect(result).toEqual(null);});
The test case even passes!
However, when you run getProfile
against a live API that returns 404
you will discover that getProfile
throws an error instead of returning null
. This is because, as mentioned before, axios
throws an error by default when receiving a non-2xx status code like 404
.
You would need to explicitly define a validateStatus
function in the request
parameters which tells axios
that 404
is an expected status code.
const response = await request<Profile>({ url: `/profiles/${profileId}`, baseURL: baseUrl, method: 'GET', headers: { Authorization: `Bearer ${bearerToken}`, }, validateStatus: (status) => status >= 200 || status < 300 || status === 404,});
Another very common mistake when dealing with HTTP clients is that we log the error from the HTTP client directly:
try { await getProfile({ profileId, bearerToken, });} catch (error) { console.error(error); throw error;}
We would test what happens when request
throws an error like this:
it('should throw error when server returns 500', async () => { expect.hasAssertions(); vi.mocked(request).mockRejectedValueOnce(new Error('Internal Server Error'));
try { await profileDataSource.createProfile({ name: profile.name, bearerToken, }); } catch (error) { const axiosError = error as AxiosError; expect(axiosError).toMatchInlineSnapshot('[Error: Internal Server Error]'); }});
The key line is:
vi.mocked(request).mockRejectedValueOnce(new Error('Internal Server Error'));
axios
does not actually throw simple errors like new Error('Internal Server Error')
. It actually throws an AxiosError
that contains sensitive information like your authorization headers:
expect(axiosError.config?.headers).toMatchInlineSnapshot(` { "Accept": "application/json, text/plain, */*", "Accept-Encoding": "gzip, compress, deflate, br", "Authorization": "Bearer some-bearer-token", "Content-Length": "17", "Content-Type": "application/json", "User-Agent": "axios/1.7.3", }`);
This is hard to discover because usually we do not sanity check what happens with error scenarios when developing.
The following sections look at libraries that help with providing more accurate mocks.
nock#
nock is a “HTTP server mocking and expectations library for Node.js”. nock
works by intercepting requests that match the HTTP server mocks.
Setup#
By default, nock
will allow any HTTP requests that do not match the HTTP server mocks to be executed as a real HTTP request. This is usually not ideal, so it can be good to simply disable all real HTTP requests and restore them after running the tests.
beforeAll(() => { nock.disableNetConnect();});
afterAll(() => { nock.enableNetConnect();});
We can also add nock.cleanAll()
to clean up all HTTP mocks that we have setup in each test so that our tests are run in isolation.
afterEach(() => { nock.cleanAll();});
Mocking when retrieving data#
Firstly, we need to setup the baseUrl
for where our requests are going:
import nock from 'nock';
nock(baseUrl);
Then, we can match the path and method of the request:
nock(baseUrl).get(`/profiles/${profile.profileId}`);
Then, we can specify what the server should return. In this case, we will return a 200
status code and a profile object.
nock(baseUrl).get(`/profiles/${profile.profileId}`).reply(200, profile);
We can also verify that requests are sent with the correct authorization headers:
nock(baseUrl) .matchHeader('authorization', `Bearer ${bearerToken}`) .get(`/profiles/${profile.profileId}`) .reply(200, profile);
So, our test case would look like:
import nock from 'nock';
it('should return profile when calling getProfile', async () => { const scope = nock(baseUrl) .matchHeader('authorization', `Bearer ${bearerToken}`) .get(`/profiles/${profile.profileId}`) .reply(200, profile);
const result = await profileDataSource.getProfile({ bearerToken, profileId: profile.profileId, });
expect(result).toEqual(profile);
scope.isDone();});
I have added scope.isDone()
at the end of the test which verifies that all nock
mocks in the test have been used.
Mocking when sending data with response handled#
Similarly, we can use nock
for POST
requests:
it('should create a profile when calling createProfile', async () => { const scope = nock(baseUrl) .matchHeader('authorization', `Bearer ${bearerToken}`) .post('/profiles', { name: profile.name, }) .reply(200, profile);
const result = await profileDataSource.createProfile({ bearerToken, name: profile.name, });
expect(result).toEqual(profile);
scope.isDone();});
Mocking when sending with response unhandled#
There is basically no difference with this case compared to when the response is handled.
Error handling#
Error handling is where it gets interesting. Since we are not mocking out axios
, request
will throw real AxiosError
s.
In the case where we should return null
when the server returns a 404
, we could have the following test:
it('should return null when server returns 404 on getProfile', async () => { const scope = nock(baseUrl) .matchHeader('authorization', `Bearer ${bearerToken}`) .get(`/profiles/${profile.profileId}`) .reply(404);
const result = await profileDataSource.getProfile({ bearerToken, profileId: profile.profileId, });
expect(result).toEqual(null);
scope.isDone();});
When we run this test without the appropriate validateStatus
function:
const response = await request<Profile>({ url: `/profiles/${profileId}`, baseURL: baseUrl, method: 'GET', headers: { Authorization: `Bearer ${bearerToken}`, }, validateStatus: (status) => status >= 200 || status < 300 || status === 404,});
We receive the following error:
AxiosError: Request failed with status code 404
This is great! This means that our tests have meaningfully shown us that our implementation is incorrect because it does not behave the way we expect.
Similarly, when we receive an unexpected error like the server returning a 500
, we can see that the error contains sensitive authorization header information which we should not be logged.
it('should throw error when server returns 500', async () => { expect.hasAssertions(); const scope = nock(baseUrl).post('/profiles').reply(500);
try { await profileDataSource.createProfile({ name: profile.name, bearerToken, }); } catch (error) { const axiosError = error as AxiosError; expect(axiosError.config?.headers).toMatchInlineSnapshot(` { "Accept": "application/json, text/plain, */*", "Accept-Encoding": "gzip, compress, deflate, br", "Authorization": "Bearer some-bearer-token", "Content-Length": "17", "Content-Type": "application/json", "User-Agent": "axios/1.7.3", } `); }
scope.isDone();});
For this test case, expect.hasAssertions is used to ensure that at least one expect
assertion is run. In the case where createProfile
resolves without throwing an error, the expect(axiosError.config?.headers).toMatchInlineSnapshot
assertion would not run and so expect.hasAssertions
would cause the test to fail.
Browser support#
I have had poor experiences with getting nock
to work with browser environments and support for mocking with nock
varies wildly between different HTTP clients. Personally, I have not tried to use nock
with the native browser fetch
API and so the following advice is for usage with axios
.
Usually, in a frontend repository, the default test environment for tests will be jsdom. A hacky workaround for getting nock
to work with specific test files which do not need browser specific APIs is to override the test environment for that particular test file to use node
.
According to the vitest documentation, you can add the following to the top of your test file:
// @vitest-environment node
According to the jest documentation, you can add the following to the top of your test file:
/** * @jest-environment node */
However, for use cases where you do need browser specific APIs, you can add the following override the default axios
config at the top of your test file:
axios.defaults.adapter = 'http';
See this section in the nock README for more details.
This is problematic because the axios
config will be different between your tests and what is actually run in production but that is a tradeoff that you may need to make.
msw#
msw is another API mocking library which has good support for both browser and Node.js.
Setup#
Firstly, we need to setup msw
so that it intercepts all HTTP requests. We use the onHandledRequest
option to ensure that requests which do not match any HTTP mocks throw an error instead of executing a real HTTP request.
import { setupServer } from 'msw/node';
const server = setupServer();
beforeAll(() => { server.listen({ onUnhandledRequest: 'error', });});
After all tests are run, we use server.close()
to stop mocking the HTTP requests.
afterAll(() => { server.close();});
msw
calls the functions which mock out HTTP requests “handlers”. After each test, we reset the handlers and remove the life cycle event listeners to ensure test isolation.
afterEach(() => { server.resetHandlers(); server.events.removeAllListeners();});
Mocking when retrieving data#
I use the server.use method to add a handler for each specific test. I prefer adding the handler within the scope of the test for unit tests so that the handler setup is collocated with what we are testing.
A REST API handler is defined using http which has methods corresponding to the HTTP methods and it takes in a path and the request handler.
import { http } from 'msw';
server.use( http.get(`${baseUrl}/profiles/${profile.profileId}`), requestHandler,);
We can return a HTTP response by using HttpResponse in our request handler.
import { http, HttpResponse } from 'msw';
server.use(http.get(`${baseUrl}/profiles/${profile.profileId}`), () => HttpResponse.json(profile),);
Now I want to assert that the request sends an appropriate authorization header. The philosophy of msw is to emulate what the server would do if we did not send authorization headers e.g. return a 401
status code. Request handlers take in a couple of parameters including the request that was sent to the server.
import { http, HttpResponse } from 'msw';
it('should return profile when calling getProfile', async () => { server.use( http.get(`${baseUrl}/profiles/${profile.profileId}`, ({ request }) => { if (request.headers.get('authorization') !== `Bearer ${bearerToken}`) { return new HttpResponse(null, { status: 401, }); } return HttpResponse.json(profile); }), );
const result = await profileDataSource.getProfile({ bearerToken, profileId: profile.profileId, });
expect(result).toEqual(profile);});
Mocking when sending data with response handled#
When we are sending data to the server, I want to ensure that the server is receiving the right data.
type Payload = { name: string;};
One approach is to assert that the server is receiving the expected data:
server.use( http.post(`${baseUrl}/profiles`, async ({ request }) => { if (request.headers.get('authorization') !== `Bearer ${bearerToken}`) { return new HttpResponse(null, { status: 401, }); }
const body = await request.json(); expect(body).toEqual({ name: profile.name, });
return HttpResponse.json(profile); }),);
If we want to follow the philosophy of msw
, we could also simply validate that the data in the payload has the right shape, similar to what the server would actually do. For example, I am using zod to do a runtime check on the shape of the data.
import { z } from 'zod';
it('should create a profile when calling createProfile', async () => { server.use( http.post(`${baseUrl}/profiles`, async ({ request }) => { if (request.headers.get('authorization') !== `Bearer ${bearerToken}`) { return new HttpResponse(null, { status: 401, }); }
const body = await request.json(); if ( !z .object({ name: z.string(), }) .safeParse(body).success ) { return new HttpResponse(null, { status: 400, }); }
return HttpResponse.json(profile); }), );
const result = await profileDataSource.createProfile({ bearerToken, name: profile.name, });
expect(result).toEqual(profile);});
Mocking when sending data with response unhandled#
There’s a few things that we want to assert in this test:
- We are sending a network request at all
- We are sending a network request to the correct URL with the correct method
- We are sending a network request with the correct payload
Asserting that we are sending a network request to the correct URL and method is handled by the onUnhandledRequest: 'error'
option that we set in our setup along with our msw
handler.
Asserting that we are sending the correct payload is difficult because previously, we would validate the request payload in the msw
handler and if the payload was incorrect, then we would have the handler return an unexpected status code resulting in the test failing. However, sendProfileCreatedTrackingEvent
does not care about the response from the server so we can no longer use this approach.
Instead, we need to use the msw
life-cycle events API to apply our assertions. server.events.removeAllListeners()
which we setup in our afterEach
block cleans up any event listeners that we will setup in this test.
Initially, I thought that we could do:
server.events.on('response:mocked', async ({ request }) => { const body = await request.clone().json(); expect(body).toEqual({ profileId: profile.profileId, });});
server.events.on('response:mocked')
sets up an event listener for response:mocked
which is an event that is emitted whenever a mocked response is sent e.g. one of our msw
handlers is invoked.
Note that before unpacking the request
to JSON
, I used clone
to create a copy. This is noted in the msw documentation.
This approach solves asserting that the request payload is correct, but there are two issues. We still have not solved assertion 1 because the test still passes when sendProfileCreatedTrackingEvent
does not send a network request at all. You will also notice that since we are asserting on an event listener, the assertion will occur after the test finishes running which causes issues where when the assertion fails, the error is harder to read. Also, we cannot use expect.hasAssertions
to assert that the network request occurred because expect.hasAssertions
runs immediately after the test has finished running and so the assertion within the event listener has not been executed yet.
So, using the wisdom from the maintainers, we can use a DeferredPromise to set the request body and assert that it is correct.
import { DeferredPromise } from '@open-draft/deferred-promise';
it('should send tracking event with profileId when calling sendProfileCreatedTrackingEvent', async () => { server.use( http.post( `${baseUrl}/analytics/profile-created-event`, () => new HttpResponse(null, { status: 200, }), ), );
const requestBody = new DeferredPromise(); server.events.on('response:mocked', async ({ request }) => { const body = await request.clone().json(); requestBody.resolve(body); });
await profileDataSource.sendProfileCreatedTrackingEvent(profile.profileId);
await expect(requestBody).resolves.toEqual({ profileId: profile.profileId, });});
Installing an additional library which is an open draft to solve this particular scenario is not ideal.
Error handling#
Very similarly to the nock
example, we can simply set up our msw
mock server to always return an error and we will see the error that axios
will actually throw when it receives an error from the server. We also use expect.hasAssertions
in case createProfile
never throws an error.
it('should throw error when server returns 500', async () => { expect.hasAssertions(); server.use( http.post( `${baseUrl}/profiles`, async () => new HttpResponse(null, { status: 500, }), ), );
try { await profileDataSource.createProfile({ bearerToken, name: profile.name, }); } catch (error) { const axiosError = error as AxiosError;
expect(axiosError.config?.headers).toMatchInlineSnapshot(` { "Accept": "application/json, text/plain, */*", "Accept-Encoding": "gzip, compress, deflate, br", "Authorization": "Bearer some-bearer-token", "Content-Length": "17", "Content-Type": "application/json", "User-Agent": "axios/1.7.3", } `); }});