On my recent published article on the subject I got a request to go through a process of creating a custom React hook using TDD, but for a hook which has server interactions:
Challenge accepted 🤓
Well maybe “half accepted” since in this article you will be joining me as I create a custom hook which only does the fetching from the server, but I believe it will lay down the foundations for extending it to other hook-to-server interactions.
In this one I will be using MSW (Mock Service Worker) which is a pretty cool solution for mocking API's for tests.
As always I start from the basic requirements:
- This custom Fetch hook should
- Fetch data from a given URL
- Indicate the status of fetching (idle, fetching, fetched)
- Have the fetched data available to consume
Let’s start :)
My hook’s name is going to be, surprisingly enough, “useFetch”.
I crank up Jest in a watch mode, and have my index.test.js ready to go. The first thing to do is to check if this hook even exists:
import {renderHook} from '@testing-library/react-hooks';
import useFetch from '.';
describe('UseFetch hook', () => {
it('should exist', () => {
const {result} = renderHook(() => useFetch());
expect(result.current).toBeDefined();
});
});
Well you guessed it, it does not. Let’s create the index.js file for this hook and the minimum required for satisfying the test:
const useFetch = () => {
return {};
};
export default useFetch;
I’m returning an empty object at the moment cause I really don’t know yet how the returned values will be formatted, but an object is a good start.
The first thing I would like to tackle is the “idle” status.
This status is being returned when no “url” was given to the hook and thus it stands… idle. My test is:
it('should return an "idle" status when no url is given to it', () => {
const {result} = renderHook(() => useFetch());
expect(result.current.status).toEqual(IDLE_STATUS);
});
And here is the code to satisfy it.
NOTE: As you can see I’m jumping some refactoring steps on the way (which you can read about in more details on my previous article) - I’ve create a inner state for the status and also exported some status constants, but if I were to follow the strict TDD manner, I would just return a hard-coded status from the hook which would satisfy the test above as well
import {useState} from 'react';
export const IDLE_STATUS = 'idle';
export const FETCHING_STATUS = 'fetching';
export const FETCHED_STATUS = 'fetched';
const useFetch = ({url} = {}) => {
const [status, setStatus] = useState(IDLE_STATUS);
return {
status,
};
};
export default useFetch;
Now it is getting interesting -
I would like to check that when the hook receives a url argument it changes it status in the following order: idle -> fetching -> fetched
How can we test that?
I will use the renderHook result “all” property, which returns an array of all the returned values from the hook's update cycles. Check out the test:
it('should first go into an "idle" status and then "fetching" and then "fetched" when initiated with a URL', () => {
const {result} = renderHook(() => useFetch({url: mockUrl}));
expect(result.all.length).toEqual(3);
expect(result.all[0].status).toEqual(IDLE_STATUS);
expect(result.all[1].status).toEqual(FETCHING_STATUS);
expect(result.all[2].status).toEqual(FETCHED_STATUS);
});
Notice that I make sure that there are 3 update cycles of the hook. My test fails obviously since my hook does not do much now, so let’s implement the minimum to get this test passing. I will use the useEffect hook to tap to the url initialization and changes and make my state transitions there in a very naive manner:
const useFetch = ({url} = {}) => {
const [status, setStatus] = useState(IDLE_STATUS);
useEffect(() => {
setStatus(FETCHING_STATUS);
setStatus(FETCHED_STATUS);
}, [url]);
return {
status,
};
};
Hold on, I know. Hold on.
Well, I have now 2 tests which fail - the first is the test I wrote for the “idle” status since the status is no longer “idle” when there is url, so I need to make sure that if there is no url the useEffect will not do anything:
const useFetch = ({url} = {}) => {
const [status, setStatus] = useState(IDLE_STATUS);
useEffect(() => {
if (!url) return;
setStatus(FETCHING_STATUS);
setStatus(FETCHED_STATUS);
}, [url]);
return {
status,
};
};
The second test is a bit more tricky - React optimizes setting a sequence of states and therefore the test receives the “fetched” status instead of “fetching”. No async action is going on at the moment between those statuses, right?
We know that we’re going to use the “fetch” API so we can use that in order to create an async action which is eventually what we aim for, but there is nothing to handle this request when running the test - this is where MSW (Mock Service Worker) comes in.
I will bootstrap MSW for my test, making sure that when attempting to fetch the mock url it gets a response from my mocked server:
const mockUrl = 'https://api.instantwebtools.net/v1/passenger';
const mockResponse = {greeting: 'hello there'};
const server = setupServer(
rest.get(mockUrl, (req, res, ctx) => {
return res(ctx.json(mockResponse));
})
);
describe('UseFetch hook', () => {
beforeAll(() => server.listen());
afterAll(() => server.close());
...
});
And in my hook I will modify the code so it would make the request:
useEffect(() => {
if (!url) return;
const fetchUrl = async () => {
setStatus(FETCHING_STATUS);
const response = await fetch(url);
const data = await response.json();
setStatus(FETCHED_STATUS);
};
fetchUrl();
}, [url]);
But still when running the test, the last status is not available. Why?
The reason is that this is an async action and we need to allow our test to act accordingly. Put simply it means that it needs to wait for the hook to complete its next update cycle. Gladly there is an API just for that called waitForNextUpdate. I will integrate it in my test (notice the async on the “it” callback):
it('should first go into an "idle" status and then "fetching" and then "fetched" when initiated with a URL', async () => {
const {result, waitForNextUpdate} = renderHook(() => useFetch({url: mockUrl}));
await waitForNextUpdate();
expect(result.all.length).toEqual(3);
expect(result.all[0].status).toEqual(IDLE_STATUS);
expect(result.all[1].status).toEqual(FETCHING_STATUS);
expect(result.all[2].status).toEqual(FETCHED_STATUS);
});
Phew… that was hard, but hey, we have made good progress! My test passes and I know that when a url is given the hook goes through these 3 statuses: “idle”, “fetching” and “fetched”.
Can we check the data now? Sure we can :)
I will write a test to make sure that I’m getting the data which gets returned from my mock server:
it('should return the data from the server', async () => {
const {result, waitForNextUpdate} = renderHook(() => useFetch({url: mockUrl}));
await waitForNextUpdate();
expect(result.current.data).toMatchSnapshot();
});
I’m using “toMatchSnapshot” here since it is more convenient for me to check the snapshot a single time for the JSON I expect to return and leave it as is. This is what Jest snapshots are best at (and not for checking component’s rendering). You can also compare it to the mockResponse defined earlier - whatever does it for you.
The test fails with ringing bells. Of course it does! I don’t set any data, update or return it in any way. Let's fix that:
const useFetch = ({url} = {}) => {
const [status, setStatus] = useState(IDLE_STATUS);
const [data, setData] = useState(null);
useEffect(() => {
if (!url) return;
const fetchUrl = async () => {
setStatus(FETCHING_STATUS);
const response = await fetch(url);
const data = await response.json();
setData(data);
setStatus(FETCHED_STATUS);
};
fetchUrl();
}, [url]);
return {
status,
data,
};
};
But since I added another update to the hook, a previous test which asserted that there will be only 3 update cycles fails now since there are 4 update cycles. Let’s fix that test:
it('should first go into an "idle" status and then "fetching" and then "fetched" when initiated with a URL', async () => {
const {result, waitForNextUpdate} = renderHook(() => useFetch({url: mockUrl}));
await waitForNextUpdate();
expect(result.all.length).toEqual(4);
expect(result.all[0].status).toEqual(IDLE_STATUS);
expect(result.all[1].status).toEqual(FETCHING_STATUS);
expect(result.all[3].status).toEqual(FETCHED_STATUS);
});
The 3rd cycle (result.all[2]) is the data setting. I won’t add it to this test though cause this test focuses on the status only, but you can if you insist ;)
Now that my Fetch hook is practically done let’s tend to some light refactoring -
We know that if we’re updating the state for both the status and data we can reach a situation where 1) the status and data do not align and 2) redundant renders. We can solve that with using the useReducer hook.
One slight change before we do - we know that now we’re removing a single update cycle (setting the data) since it will be bundled along with dispatching the “fetched” status, so we need to adjust one of our tests before we start:
it('should first go into an "idle" status and then "fetching" and then "fetched" when initiated with a URL', async () => {
const {result, waitForNextUpdate} = renderHook(() => useFetch({url: mockUrl}));
await waitForNextUpdate();
expect(result.all.length).toEqual(3);
expect(result.all[0].status).toEqual(IDLE_STATUS);
expect(result.all[1].status).toEqual(FETCHING_STATUS);
expect(result.all[2].status).toEqual(FETCHED_STATUS);
});
And our refactored code looks like this:
import {useEffect, useReducer} from 'react';
export const IDLE_STATUS = 'idle';
export const FETCHING_STATUS = 'fetching';
export const FETCHED_STATUS = 'fetched';
const FETCHING_ACTION = 'fetchingAction';
const FETCHED_ACTION = 'fetchedAction';
const IDLE_ACTION = 'idleAction';
const initialState = {
status: IDLE_STATUS,
data: null,
};
const useReducerHandler = (state, action) => {
switch (action.type) {
case FETCHING_ACTION:
return {...initialState, status: FETCHING_STATUS};
case FETCHED_ACTION:
return {...initialState, status: FETCHED_STATUS, data: action.payload};
case IDLE_ACTION:
return {...initialState, status: IDLE_STATUS, data: null};
default:
return state;
}
};
const useFetch = ({url} = {}) => {
const [state, dispatch] = useReducer(useReducerHandler, initialState);
useEffect(() => {
if (!url) return;
const fetchUrl = async () => {
dispatch({type: FETCHING_ACTION});
const response = await fetch(url);
const data = await response.json();
dispatch({type: FETCHED_ACTION, payload: data});
};
fetchUrl();
}, [url]);
return state;
};
export default useFetch;
And here is our final test code:
import {renderHook} from '@testing-library/react-hooks';
import {rest} from 'msw';
import {setupServer} from 'msw/node';
import useFetch, {FETCHED_STATUS, FETCHING_STATUS, IDLE_STATUS} from '.';
const mockUrl = 'https://api.instantwebtools.net/v1/passenger';
const mockResponse = {greeting: 'hello there'};
const server = setupServer(
rest.get(mockUrl, (req, res, ctx) => {
return res(ctx.json(mockResponse));
})
);
describe('UseFetch hook', () => {
beforeAll(() => server.listen());
afterAll(() => server.close());
it('should exist', () => {
const {result} = renderHook(() => useFetch());
expect(result.current).toBeDefined();
});
it('should return an "idle" status when no url is given to it', () => {
const {result} = renderHook(() => useFetch());
expect(result.current.status).toEqual(IDLE_STATUS);
});
it('should first go into an "idle" status and then "fetching" and then "fetched" when initiated with a URL', async () => {
const {result, waitForNextUpdate} = renderHook(() => useFetch({url: mockUrl}));
await waitForNextUpdate();
expect(result.all.length).toEqual(3);
expect(result.all[0].status).toEqual(IDLE_STATUS);
expect(result.all[1].status).toEqual(FETCHING_STATUS);
expect(result.all[2].status).toEqual(FETCHED_STATUS);
});
it('should return the data from the server', async () => {
const {result, waitForNextUpdate} = renderHook(() => useFetch({url: mockUrl}));
await waitForNextUpdate();
expect(result.current.data).toMatchSnapshot();
});
});
Noice :)
I know - There is still a lot that can be done to make this relatively simple implementation much better (exposing fetch errors, caching, etc.), but as I mentioned earlier, this is a good start to lay the foundation for creating a server interaction React Hook using TDD and MSW.
Care for a challenge? Implement a caching mechanism for this hook using the techniques discussed in this post đź’Ş
As always, if you have any ideas on how to make this better or any other technique, be sure to share with the rest of us!
Hey! If you liked what you've just read check out @mattibarzeev on Twitter 🍻
Photo by Philipp Lublasser on Unsplash