Testing JavaScript's Fetch with Jest - Happy Path

This video is available to view for members only.

Click here to Join!

Already a member?

Login


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:

Fetch Mock.

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 :)


Code For This Course

Get the code for this course.

Code For This Episode

Get the code for this episode.

Share This Episode

If you have found this video helpful, please consider sharing. I really appreciate it.


Episodes in this series

# Title Duration
1 App Walkthrough - User Experience 03:15
2 App Walkthrough - Developer Experience 07:41
3 Development Environment Setup 06:34
4 Login - Part 1 09:15
5 Login - Part 2 07:55
6 Login - Part 3 12:37
7 Login - Part 4 10:22
8 Login - Part 5 08:00
9 Saving Redux State to Local Storage 08:50
10 Logout 10:57
11 Adding an Auth-aware NavBar 14:43
12 Cleanup, Linting, and Login Form Styling 09:58
13 Showing Spinning Icons, Because Why Not? 08:11
14 More Robust Request Tracking 09:07
15 Getting Started Testing With Jest 06:43
16 Testing Request Reducer - Part 1 11:35
17 Testing Request Reducer - Part 2 05:25
18 Testing AuthSaga - Happy Path 09:19
19 Testing AuthSaga - Unhappy Paths 04:38
21 Testing JavaScript's Fetch with Jest - Happy Path 05:15
21 Testing JavaScript's Fetch with Jest - Unhappy Paths 04:35
22 Getting Started with Jest Mocks 08:52
23 Using Webpack Environment Variables in Jest Tests 09:37
24 User Profile Page - Part 1 07:31
25 User Profile Page - Part 2 10:25
26 User Profile Page - Part 3 07:23
27 Change Password - Part 1 10:01
28 Change Password - Part 2 07:59
29 Change Password - Part 3 - Displaying Errors 06:28
30 Change Password - Part 4 - Converting Errors From Symfony to Redux Form 05:39
31 Change Password - Part 5 - Adding More Tests 05:06
32 Change Password - Part 6 - Avoid Blocking, and Wrap Up 06:23
33 Registration - Part 1 08:50
34 Registration - Part 2 06:25
35 Registration - Part 3 05:25