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
Top comments (5)
This is good. This is pure tests on custom hook. Can you use that custom hook in any component and write a test case for the component.
I am trying to do that, i get undefined from the custom hook on the component.
Can you help me with this scenario.
Thanks.
Hi, can you please share some code (on github fir instance) so I can understand more what it is you're trying to do?
Hi,
Here is the index page and i am getting data from a custom hook that is wrapped by useSWR hook.
Now i want to write tests for index page by mocking the custom hook.(mock service worker can help here ?)
stackblitz.com/edit/nextjs-gcee4q?...
Well, I think I understand where you struggle with it, so I took the opportunity to write a post about it here - dev.to/mbarzeev/mocking-data-fetch...
Hope this helps :)
Nice example, thank you đ
result.all
andwaitForNextUpdate
have been deprecated in renderHook, as it encourages testing implemenation details. see here. I'm curious how you would update this example knowing this... maybe TDD requires the testing of implementation details?If you want to follow along, you'll need to update this test as follows (I'm sure there must be other ways but this is the best I found)
in addition I had trouble using the global fetch, which "I think" is not supported yet by msw (but should be part of node from V18 by default): github.com/mswjs/msw/issues/686#is...
So I imported 'node-fetch' and used that instead... I bootstrapped the project using create-react-app with the jest option, but node-fetch was throwing an error: "SyntaxError: Cannot use import statement outside a module". I googled around and the best way I found to solve this problem was by installing an earlier version of node-fetch
npm install node-fetch@2
as well as the types if you are using typescriptnpm i --save-dev @types/node-fetch
. @matti you might have a better way?Thanks again đ