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:
- 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.
- Tests should be isolated. A test should not affect other tests in any way, nor should it depend on any code inside another test.
- 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:
- For hooks that return values, we test if the expected values are returned for the same sets of inputs;
- For hooks that provides actions (for example,
useState
returns a function that lets you change thestate
value), we can fire those actions and see if the outcome is correct; - 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:
- Causes a side-effect: it sends a request over the network (using
fetch
) - Takes one parameter:
url
and returns values:isLoading
,data
, anderror
; 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 somedata
; if anything bad happens,error
value will be populated. - 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();
});
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();
});
In our tests, we can tell the fake server how to handle each request, like so:
server.respondWith('GET', url, [
200,
{},
JSON.stringify(mockData),
]);
The code above tells our server that:
- It accepts "GET" requests to the
url
- It should respond with status code
200
(OK) - It doesn't return any headers
- 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.
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 }));
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();
});
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();
});
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 }
});
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' });
Putting it together, to write this test we will:
- setup our server to respond to two URLs with different data
- render the hook with an initialUrl
-
rerender
our hook with a new url - tell the fake server that it's time to send back responses
- 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)
It doesn't work. I get errors such as
Error: getaddrinfo ENOTFOUND api.example.com
when the XHR request URL includes the origin andError: connect ECONNREFUSED 127.0.0.1:80
when it doesn't.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?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.
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 usingfetch
- 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 withaxios
correctly if you are using that.The error you are getting indicates the fake server is not mocking the correct
fetch
(orXMLHttpRequest
) - 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 withjest
.Yeah, I gave up and mocked my endpoint functions, which are a simple layer atop superagent, which uses XHR.