DEV Community

Cover image for Testing API Request Hooks with Jest, Sinon, and react-testing-library
Yurui Zhang
Yurui Zhang

Posted on

Testing API Request Hooks with Jest, Sinon, and react-testing-library

In this mini series we have developed a simple hook that abstracts away the logic of managing some common states used in data fetching. Now let's talk about testing.

I'm assuming that you are already familiar with the basics of unit testing React apps with Jest. If that's not the case, Jest's official document site is a great place to start: https://jestjs.io/docs/en/getting-started

What To Test

Before we start writing any tests, we need to know what we need to test. This is a bit different from Test Driven Development (TDD) where we know what our desired outcomes are so we write tests first. But our tests should follow the same sets of rules, for example:

  1. Test the outcomes, not the implementation. Treat the components/functions you are testing like black boxes - we feed it with data and check what we are getting back - try to avoid testing implementation details.
  2. Tests should be isolated. A test should not affect other tests in any way, nor should it depend on any code inside another test.
  3. Tests should be deterministic. Given the same input, a test should always give the same results.

Testing React components are usually pretty straight forward - we "render" the component (sometimes with props), and check if its output matches our expectations. If the component is interactive, we will simulate the user interactions (events) and see if it behaves correctly.

Testing hooks is somewhat trickier, however with the same rules, we can confidently say:

  1. For hooks that return values, we test if the expected values are returned for the same sets of inputs;
  2. For hooks that provides actions (for example, useState returns a function that lets you change the state value), we can fire those actions and see if the outcome is correct;
  3. For hooks that causes "side effects" - we will try to observe the side effect, and make sure that everything is cleaned up so other tests won't be affected.

Now let's take a quick look at our useGet hook - it:

  1. Causes a side-effect: it sends a request over the network (using fetch)
  2. Takes one parameter: url and returns values: isLoading, data, and error; The values changes based on the outcome of the side-effect it causes: when a request is pending, isLoading is true; when the request is successful, we will receive some data; if anything bad happens, error value will be populated.
  3. discards the first side-effect, if we provide a new value before fetch is resolved.

Now we know what to test!

Mocking Async Requests

Now how do we observe the side-effect the hook is causing? Spinning up a server that responds to our testing requests sounds cumbersome - and the tests won't be isolated & deterministic - there could be network issues and they are going to make the tests fail; our tests will depend on the server to return correct responses, instead of user inputs/actions.

Luckily there are couple of mocking libraries that allow us to observe asynchronous requests and control their results. To test React apps, I usually prefers sinon which provides a very easy API to setup fake request handlers and clean things up.

Here we will need to use its fakeServer method:

import { fakeServer } from 'sinon';

// setup a fake server
// we will need to hold a reference to the server so we can tell it when/what to respond to requests (and clean it up later)
let server;

beforeEach(() => {
  server = fakeServer.create();
});
Enter fullscreen mode Exit fullscreen mode

sinon doesn't really spin up a "server" that runs along side of our tests. Under the hood, it just fakes the native XMLHttpRequest so all of our outgoing requests are intercepted. This change is global - we want to make sure that one request fired in one tests won't interfere with a different test, so we need to remove the fake after each test:

afterEach(() => {
  server.restore();
});
Enter fullscreen mode Exit fullscreen mode

In our tests, we can tell the fake server how to handle each request, like so:

server.respondWith('GET', url, [
  200,
  {},
  JSON.stringify(mockData),
]);
Enter fullscreen mode Exit fullscreen mode

The code above tells our server that:

  1. It accepts "GET" requests to the url
  2. It should respond with status code 200 (OK)
  3. It doesn't return any headers
  4. The body of the response is mockData (as a string)

If we want a request to fail, we can just change the status code to 4xx (e.g. 400 for "Bad Request",403 for "Forbidden") or 5xx (500 for "Internal Server Error"), and provide an error message in the response body.

respondWith is very flexible - you can find all the options, and all the stuff you can do here.

Often we don't want the server to respond right away, we can control when the server should respond by calling: server.respond();.

Writing the Test

Hooks look like they are just plain old JavaScript functions, but if we call one directly outside a React component we are going to see this:

    Invariant Violation: Invalid hook call. Hooks can only be called inside of the body of a function component.
Enter fullscreen mode Exit fullscreen mode

There are a couple of different ways to get around this - one of them is creating a simple function component that uses this hook, and we can test the rendered output of that component. It's not a bad solution honestly, however there is a much easier, and more elegant way - using @testing-library/react-hooks. I'm fairly new to "@tesing-library" packages but I fell in love with this one immediately just after writing a few tests.

To setup our hook, we can simply call renderHook like so:

import { renderHook } from '@testing-library/react-hooks';

// ... test setup

const url = '/foo/bar';
const { result, waitForNextUpdate } = renderHook(() => useGet({ url }));
Enter fullscreen mode Exit fullscreen mode

It returns lots of useful goodies, here we only need result and waitForNextUpdate.

  • result, as its name suggests, is an object that holds the values that our hook returns;
  • waitForNextUpdate is a function that allows us to wait until all async stuff our hook is doing. This is where this testing library really shines.

Now let's write our first test: we want to make sure that the initial states are as expected:

it('returns proper initial states', () => {
  const url = '/foo/bar';
  const { result } = renderHook(() =>
    useGet({ url })
  );

  expect(result.current.isLoading).toEqual(true);
  expect(result.current.data).toBeNull();
  expect(result.current.error).toBeNull();
});
Enter fullscreen mode Exit fullscreen mode

Isn't it easy? Now let's combine it with fake server - we want to make sure returns the data from the server when the request finishes.

// note, this is an `async` test 
it('GETs data from the server', async () => {
  const url = '/foo/bar';
  const expectedData = { some: 'data' }; // we define some data the server will be returning
  // setup the server
  server.respondWith('GET', url, [
    200,
    {},
    JSON.stringify(expectedData),
  ]);

  // setup our hook
  const { result, waitForNextUpdate } = renderHook(() =>
    useGet({ url })
  );

  // just to make sure our data is still `null` at this point
  expect(result.current.data).toBeNull();

  // tell our server it's time to respond!
  server.respond();

  // magic! we will wait until our hook finishes updating its internal states;
  await waitForNextUpdate();

  // assert the outcomes! 
  expect(result.current.data).toEqual(expectedData);
  expect(result.current.isLoading).toEqual(false);
  expect(result.current.error).toBeNull();
});
Enter fullscreen mode Exit fullscreen mode

Similarly we can test that it returns expected messages when the server responds with an error code.

How do we test the request cancellation bit? How do we provide the hook with a new url before we call server.respond()? I'm glad you asked 😄 renderHook also returns a rerender method that allows us to provide some props to the hook - the setup looks slightly different from the example above though:

const initialUrl = '/first/request';
const { rerender } = renderHook(({ url }) => useGet({ url }), {
  initialProps: { url: initialUrl }
});
Enter fullscreen mode Exit fullscreen mode

Now the function we provide to renderHook accepts a url prop which is in turn used in the useGet call. And with the second argument we are telling renderHook that the initial value of url should be '/first/request'.

In order to re-run our hook with new props, we can simply do:

rerender({ url: '/new/url' });
Enter fullscreen mode Exit fullscreen mode

Putting it together, to write this test we will:

  1. setup our server to respond to two URLs with different data
  2. render the hook with an initialUrl
  3. rerender our hook with a new url
  4. tell the fake server that it's time to send back responses
  5. assert that our result should only include data from the second call

Now you've got everything you need to write this test, would you accept this challenge?

Hint: You probably will need to use a different method to handle requests in order to resolve the second request before the first one. Read the docs here.

It's a Wrap

Thanks for reading my very first blog series on React & testing! React is a wonderful library to work with and its community is actively working to improve experiences of both the developers and the end-users. And hooks make things much easier to share common states / workflows within the codebase. I hope you find those posts helpful 🤗 and please stay tuned for more React best practices posts!

Top comments (5)

Collapse
 
stevetaylor profile image
Steve Taylor

It doesn't work. I get errors such as Error: getaddrinfo ENOTFOUND api.example.com when the XHR request URL includes the origin and Error: connect ECONNREFUSED 127.0.0.1:80 when it doesn't.

Collapse
 
pallymore profile image
Yurui Zhang

Hi - it sounds like your test is making real requests - which means the fakeServer did not catch it. it could be a lot of things but the most likely explaination is the url in the setup is wrong.

could you try using a regex instead of a string for respondWith setup? could you share what you have?

Collapse
 
stevetaylor profile image
Steve Taylor

Sinon’s fake server doesn’t fall back to real requests. The issue is possibly that the current versions of sinon and jest are incompatible.

Thread Thread
 
pallymore profile image
Yurui Zhang

yea I looked into it and I think you are right for the first part.
However I don't think it's incompatibility issue between jest / sinon.

Since jest runs the code in the node environment, not all browser native functions are properly implemented. If you are using fetch - some polyfill libraries work correctly with sinon (e.g. whatwg-fetch), while others don't (e.g. isomorphic-fetch). sinon's fakeServer also does not work with axios correctly if you are using that.

The error you are getting indicates the fake server is not mocking the correct fetch (or XMLHttpRequest) - real requests are being made in the test environment - maybe it's better to investigate how you can mock the request library/method you are using directly with jest.

Thread Thread
 
stevetaylor profile image
Steve Taylor

Yeah, I gave up and mocked my endpoint functions, which are a simple layer atop superagent, which uses XHR.