Testing JavaScript's Fetch with Jest - Happy Path
In this, and the next video, we are going to cover refactoring the API call logic, extracting out the generic request portion of code from the specific call to any given endpoint. By doing this, we can start re-using the chunk of code that sends the request and (hopefully) receives the response, from the configuration of any given request.
Currently our api.js
file looks as follows:
// /src/connectivity/api.js
import HttpApiCallError from '../errors/HttpApiCallError';
export async function login(username, password) {
const url = 'http://api.rest-user-api.dev/app_acceptance.php/login';
const requestConfig = {
method: 'POST',
mode: 'cors',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username,
password
})
};
const response = await fetch(url, requestConfig);
const data = await response.json();
if (response.status === 200) {
return data;
}
throw new HttpApiCallError(
data.message || response.statusText,
response.status
);
There are a few problems here.
Firstly, this only covers the process of Login. If we wanted to have another API call - e.g. a call to /profile
then we might be tempted to copy / paste the entire login
function, changing up the url
and parts of the requestConfig
accordingly. The chunk of code starting const response
would remain largely the same.
Secondly, our url
is hardcoded to our test environment. Don't worry too much about this, we will address that problem in an upcoming video.
Thirdly, our file is called - rather generically - api.js
. As our project grows it might make more sense to start grouping, and splitting off the various calls to any given endpoint into separate files. We might end up with api.auth.js
, api.profile.js
, api.widgets.js
, and so on. Of course, you don't have to do it like this, I just personally dislike tons of code in a single file.
Also, what happens here if our response.status
was a 201
(created), or a 204
(no content)? Well those would currently throw
, which is clearly wrong.
The plan then, is to separate these two concerns - setup, and actual call - and then test as appropriate.
A Different Kind Of Code Splitting
Let's start by splitting the two distinct concerns in the code - setup, and API call - into different functions.
We will leave the url
and requestConfig
variables alone for now, and concentrate on the API call code.
To begin with, I'm going to create a new test file to cover off the logic we are about to extract. Following the Jest standard directory structure, my tests live in __tests__
, and the sub-directory structure mirrors that of my src
dir. In creating a file called:
/__tests__/connectivity/async-fetch.js
It follows that I will have the real implementation under:
/src/connectivity/async-fetch.js
To begin with, all this test file will contain is a single test - the "happy path" - to ensure our code is testable, and that if everything goes to plan, our code behaves as expected. I always start with the happy path, but if you prefer otherwise, feel free to test any way you like.
Our initial test setup looks as follows:
// /__tests__/connectivity/async-fetch.js
describe('asyncFetch', () => {
it('can fetch', () => {
});
});
Not particularly interesting, I admit.
Here's the thing though, already we need to make an important change. As we're using async
/ await
in our code:
const response = await fetch(url, requestConfig);
We must use async
functions in our tests:
// /__tests__/connectivity/async-fetch.js
describe('asyncFetch', () => {
it('can fetch', async () => {
});
});
Notice the it
function is taking an async
function as its second argument. You don't need to use an async
function in the describe
block though. A decent IDE (cough WebStorm cough) will help you out if you accidentally leave off the async
part by helpfully underlining any await
calls you try to make.
Ok, so that's the very basic setup.
Looking back at our original code, we have the following that we'd like to test:
const response = await fetch(url, requestConfig);
const data = await response.json();
if (response.status === 200) {
return data;
}
throw new HttpApiCallError(
data.message || response.statusText,
response.status
);
Let's cut this right down in order to write the absolute least test code we can, in order to prove this works at a really basic level. With that in mind, let's just focus on getting the very first line to work:
const response = await fetch(url, requestConfig);
Looking at the documentation for fetch
we can deduce that again, the very least we can do is to just pass in a url
. We don't need to pass in any requestConfig
, which by the way, will just be a plain old JavaScript object.
Really then, all we are testing is:
const response = await fetch('http://some.url.here');
So, let's make a start on our test:
// /__tests__/connectivity/async-fetch.js
describe('asyncFetch', () => {
it('can fetch', async () => {
const response = await asyncFetch('http://fake.com');
expect(result).toEqual("something");
});
});
This still won't work.
Firstly, it won't work because we haven't imported anything called asyncFetch
. In fact, we haven't even created that file yet. Let's fix that immediately:
// /src/connectivity/async-fetch.js
export default async function asyncFetch(url) {
return await fetch(url);
}
And then remembering to import
it into our test:
// /__tests__/connectivity/async-fetch.js
import asyncFetch from '../../src/connectivity/async-fetch';
describe('asyncFetch', () => {
it('can fetch', async () => {
const response = await asyncFetch('http://fake.com');
expect(result).toEqual("something");
});
});
Ok, one problem down.
If we run our test now though, we get a different error - ReferenceError: fetch is not defined
.
Our tests are running from the command line via Jest, which in ultimately runs through Node JS. By default, fetch
doesn't work under Node JS. There are ways to fix this - e.g. using Isomorphic Fetch - isomorphic meaning runs "the same" on both client (browsers), and server (node JS). Still though, that wouldn't directly help us in this circumstance. Of course, we aren't the first developers to encounter this problem. And as such, there is an off-the-shelf solution:
Much like a tin of Ronseal's Quick Drying Woodstain, Fetch Mock does exactly what it says on... the tin? Erm...
yarn add --dev fetch-mock
Which adds fetch-mock
in to our project for our development environment only - as in, the files for fetch-mock
won't be included in our production build.
Fetch Mock has some great documentation, so I would strongly suggest you read that in the first instance if you get stuck in any way.
Given we now have fetch-mock
as a dependency, we can go ahead and use that in our test code:
// /__tests__/connectivity/async-fetch.js
const fetchMock = require('fetch-mock');
import asyncFetch from '../../src/connectivity/async-fetch';
describe('asyncFetch', () => {
it('can fetch', async () => {
fetchMock.get('http://fake.com');
const response = await asyncFetch('http://fake.com');
expect(result).toEqual("something");
});
});
We're getting closer now.
Using fetchMock
we can tell our code how to behave when a GET
request (fetchMock.get
) is received to the given URL - http://fake.com
. This is super nice as we don't have to rely on any real webservers being available or anything of that nature. The use of fake.com
is fairly explicit about this URL being for the purposes of test only.
Even so, our test still fails:
Expected value to equal:
"something"
Received:
{"abort": false, "_raw": Array [], "body": {"_events": {}, "_eventsCount": 0, "_maxListeners": undefined, "_readableState": {"awaitDrain": 0, "buffer": {"head": {"data": {"data": [123, 34, 104, 101, 108, 108, 111, 34, 58, 34, 119, 111, 114, 108, 100, 34, 125], "type": "Buffer"}, "next": null}, "length": 1, "tail": {"data": {"data": [123, 34, 104, 101, 108, 108, 111, 34, 58, 34, 119, 111, 114, 108, 100, 34, 125], "type": "Buffer"}, "next": null}}, "decoder": null, "defaultEncoding": "utf8", "emittedReadable": true, "encoding": null, "endEmitted": false, "ended": true, "flowing": null, "highWaterMark": 16384, "length": 17, "needReadable": false, "objectMode": false, "pipes": null, "pipesCount": 0, "ranOut": false, "readableListening": false, "reading": false, "readingMore": true, "resumeScheduled": false, "sync": true}, "domain": null, "readable": true}, "bodyUsed": false, "headers": {"headers": {}}, "ok": true, "size": 0, "status": 200, "statusText": "OK", "timeout": 0, "url": "http://fake.com"}
Say what?
Remember we are now faking fetch
. A fetch
call returns a Response
object. And that's exactly what fetch-mock
is giving us. A real looking, yet entirely faked Response
.
Therefore, it makes sense that simply expecting our const response
to directly equal the value we are testing for ("something"
) is likely to fail. Looking back at our original code, we expect to have to call response.json()
first:
const response = await fetch(url, requestConfig);
const data = await response.json();
Let's update our test to match this:
// /__tests__/connectivity/async-fetch.js
const fetchMock = require('fetch-mock');
import asyncFetch from '../../src/connectivity/async-fetch';
describe('asyncFetch', () => {
it('can fetch', async () => {
fetchMock.get('http://fake.com');
const response = await asyncFetch('http://fake.com');
const result = await response.json();
expect(result).toEqual("something");
});
});
But this still fails!
Invalid parameters passed to fetch-mock
Ok, this last fail is due to the way we've setup (or I guess, not fully setup) our call to fetchMock
.
We expect to get back "something", but we aren't telling fetchMock.get('http://fake.com');
to return anything. This is easy to fix. We pass in a second object, which will be returned verbatim:
fetchMock.get('http://fake.com', { anything: "we like" });
And now, we could write an assertion:
expect(result.anything).toEqual("we like");
Knowing this, let's update our test:
// /__tests__/connectivity/async-fetch.js
const fetchMock = require('fetch-mock');
import asyncFetch from '../../src/connectivity/async-fetch';
describe('asyncFetch', () => {
it('can fetch', async () => {
fetchMock.get('http://fake.com', {hello: "world"});
const response = await asyncFetch('http://fake.com');
const result = await response.json();
expect(result.hello).toEqual("world");
});
});
And at last, we have a passing test :)