In my previous article I tried to find a way to decouple fetch-logic from my React components using React hooks. Not only does it allow me to have a clean state management, it also simplifies the automated testing.
What should I test and why
Writing automated tests is quite crucial for bigger applications. It allows us to test expected behaviour of small parts of our application so we're more likely not to break anything with our changes. But in reality I think most of us can relate that writing tests is only used to increase the over all test coverage.
One quite nice approach is Test Driven Development (TDD), where you specify the tests first (the expected behaviour) and then continue with the implementation to pass the tests. But that would be enough material for a separate post.
For now I think we need to look at our code and we need find our own parts that we want to test.
For my "decoupled fetch" set-up I have two functions that need to be tested:
useApi
-Hook
The hook should always return an object with state
, error
and the data
. Depending on the state of the fetch-event there are three possible outcomes: loading
, failed
, succeeded
. In this case I think it makes sense to mock the fetch-event and to test the output.
PostList
-Component
The component would then use the output of the hook and render the specified elements. Now since it is completely decoupled we can just mock the hook and only compare the snapshot of our component with the reference snapshot.
Because it's decoupled it makes it way easier to write meaningfull, well structured, separated test cases.
Testing the component
React makes it really easy to test the outcome of a Component using the react-test-renderer. As the name suggests it will just render the component.
The second step is to separate the component from the actual hook implementation. With Jest it's quite simple to mock a specific implementation using jest.mock()
and then pass a mockReturnValue
or mock all kinds of stuff.
Yes, I am using Jest here. But not because I have strong arguments for Jest or against the alternatives, but simply out of habit.
// ./PostList.test.jsx
import React from 'react';
import PostList from './PostList';
import renderer from 'react-test-renderer';
import useApi from './useApi.jsx';
jest.mock('./useApi.jsx');
describe('PostList Snapshots', () => {
it('loading renders correctly', () => {
useApi.mockReturnValue({
state: 'LOADING',
error: '',
data: [],
});
const tree = renderer.create(<PostList title="Test" />).toJSON();
expect(tree).toMatchSnapshot();
});
it('success renders correctly', () => {
useApi.mockReturnValue({
state: 'SUCCESS',
error: '',
data: [
{
title: 'Hello',
}, {
title: 'World',
}
],
});
const tree = renderer.create(<PostList title="Test" />).toJSON();
expect(tree).toMatchSnapshot();
});
it('error renders correctly', () => {
useApi.mockReturnValue({
state: 'ERROR',
error: 'General Error',
data: [],
});
const tree = renderer.create(<PostList title="Test" />).toJSON();
expect(tree).toMatchSnapshot();
});
});
So in the end in this simplified example I covered all possible states.
But is it save to just mock the more complex logic?
Yes, because the logic will be tested separately.
Testing the hook
React hooks are ultimately functions. But since they are being used in a React context you can't just test them as normal JavaScript-functions. If you try it, you will most likely run into the following error:
Error: Invalid hook call. Hooks can only be called inside of the body of a function component.
Therefore React provides a different library called @testing-library/react-hooks. This allows us to test React hooks and it even makes it possible to wait for updates of the hook. Let's have a look at a very simple example:
// ./useTimeout.jsx
import React from 'react';
export default () => {
const [done, setDone] = React.useState(false);
setTimeout(() => setDone(true), 2000);
return done;
};
// ./useTimeout.test.jsx
import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import useTimeout from './useTimeout';
describe('useTimeout Hook', () => {
it('test state and nextUpdated state', async () => {
const { result, waitForNextUpdate } = renderHook(() => useTimeout());
expect(result.current).toEqual(false);
await waitForNextUpdate();
expect(result.current).toEqual(true);
});
});
As you can see we can now render the hook, test the state and we can then even wait for the next update. By default, jest waits 5000ms for the next update. If there is no update until then, it will throw:
Timeout - Async callback was not invoked within the 5000ms timeout specified by jest.setTimeout.
But you can easily adjust this with jest.setTimeout(/*time in ms*/);
.
mocking the fetch event
My biggest problem was mocking the fetch event. Since we're writing unit tests and not integration tests it's ok to not process the actual request, but to just assume you get the correct data (the API testing should take place somewhere else).
But how can we tell jest to mock a global function like fetch
?
Ideally would use jest.spyOn(global, 'fetch').mockImplementation(() => someMockPromise);
. But now we will run into another problem:
Cannot spy the fetch property because it is not a function; undefined given instead
While Fetch
exists on most modern browsers, it does not exists on Jestβs JSDOM environment. Therefore we need to first create a global function called fetch
with the expected behaviour and then destroy it afterwards.
const mockFetch = (mockData) => {
global.fetch = jest.fn().mockImplementation(() =>
Promise.resolve({
json: () => Promise.resolve(mockData),
})
);
};
const mockFetchError = (error) => {
global.fetch = jest.fn().mockImplementation(() => Promise.reject(error));
};
const mockFetchCleanUp = () => {
global.fetch.mockClear();
delete global.fetch;
};
mocking axios
If you're using axios you can just jest.mock('axios');
and afterwards use axios.get.mockResolvedValue({});
A full example of the same test using axios can be found here: https://github.com/nico-martin/react-hooks-loadingstate/blob/master/src/common/hooks/useApi.test.jsx
Putting it together
A basic implemenmtation of the useApi-tests could look like this:
// ./useApi.test.js
import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import useApiFetch from './useApiFetch.jsx';
const useApiFetchMock = [{ title: 'Hello' }, { title: 'World' }];
const mockFetch = (mockData) => {
global.fetch = jest.fn().mockImplementation(() => Promise.resolve({
json: () => Promise.resolve(mockData),
}});
};
const mockFetchError = (error) => {
global.fetch = jest.fn().mockImplementation(() => Promise.reject(error));
};
const mockFetchCleanUp = () => {
global.fetch.mockClear();
delete global.fetch;
};
describe('useApi Hook', () => {
it('initial and success state', () => {
mockFetch(useApiFetchMock);
const { result } = renderHook(() => useApiFetch('lorem'));
expect(result.current).toMatchObject({
data: [],
error: '',
state: 'LOADING',
});
await waitForNextUpdate();
expect(result.current).toMatchObject({
data: useApiFetchMock,
error: '',
state: 'SUCCESS',
});
mockFetchCleanUp();
});
it('error state', async () => {
mockFetchError('Network Error');
const { result, waitForNextUpdate } = renderHook(() => useApiFetch('lorem'));
// we will skip the tests for the initial state
await waitForNextUpdate();
expect(result.current).toMatchObject({
data: [],
error: 'Fetch failed',
state: 'ERROR',
});
mockFetchCleanUp();
});
});
Conclusion
I'm not saying that hooks will solve all the problems that come with unit testing. But I do think that the smaller the fragments are, the easier it is to write tests for those encapsulated parts of your application. And hooks are a great place to separate logic from presentation.
Top comments (1)
Nice article! Thank you!