Creating Reusable Mocks: Continued
Continue the discussion about reusable mocks and testing practices while we further develop the "useTasks" hook.
We'll cover the following
Where we left off
In the last lesson, we wrote the very first test for the useTasks
hook. This hook is supposed to be a bridge between APIs and the TaskList
component. Our first test verified that useTasks
loads the tasks from the API. To do this, we had to mock the API functions. Firstly, we did it manually (using the jest.mock
function with a generator function). Then, we did it automatically (using the __mocks__
folder and jest.mock
function). Now, we will complete the testing and implementation of useTasks
.
it('must create tasks')
Once we get to the loading tasks, it is time to think about creating new ones. As we agreed before, the API for the useTasks
will be as follows:
const [tasks, {createTask, toggleTask}] = useTasks();
In our test, we must:
- Call the hook and extract the
createTask
function from its return value. - Call the function with the new task name.
- Verify that the underlying API function was called with the correct argument.
- Verify that the
tasks
result was updated with the new task.
Let’s begin with rendering the hook and extracting the function:
it('must create tasks', async () => {
const {result, waitForNextUpdate} = renderHook(() => useTasks());
await waitForNextUpdate();
const [_, {createTask}] = result.current;
});
The
waitForNextUpdate()
call is required sinceuseTasks
is supposed to always load new tasks. Without this call, we would get warnings from React later on.
Then, we will call the createTask
function that we just extracted. There is a catch, though. This function needs to be wrapped in an act
function because the tasks
array updating causes a rerender. You import this function from @testing-library/react-hooks
like this:
import {renderHook, act} from '@testing-library/react-hooks';
And use it like:
await act(() => createTask('New task!'));
Now, Jest will call this function and wait until the state of useTasks
is refreshed.
The
act
function can also be used when testing components. However, it is done very rarely.
Now, we can verify that the tasks
array is refreshed with new tasks:
const [tasks] = result.current;
expect(tasks[tasks.length - 1]).toEqual({id: expect.anything(), label: 'New task!', completed: false});
The last thing we need to do is verify that the underlying API function was called correctly. To do this, we first must import it:
import {getTasks, createTask as apiCreateTask} from '../api';
We rename createTask
to apiCreateTask
since there is already a createTask
in scope, which is the one we extracted from useTasks
. Now, we can assert on it (remember, it is already mocked by jest.mock
call):
expect(apiCreateTask).toHaveBeenCalledWith('New task!');
Here is the entire test code if you were lost:
it('must create tasks', async () => {
const {result, waitForNextUpdate} = renderHook(() => useTasks());
await waitForNextUpdate();
const [_, {createTask}] = result.current;
await act(() => createTask('New task!'));
const [tasks] = result.current;
expect(tasks[tasks.length - 1]).toEqual({id: expect.anything(), label: 'New task!', completed: false});
expect(apiCreateTask).toHaveBeenCalledWith('New task!');
});
it('must update tasks')
In the last test for this module, we will make sure that useTasks
gives us the ability to toggle tasks as completed/uncompleted.
This test will be even simpler than the last one, so I am going to skip the explanation. Here is the code:
it('must update tasks', async () => {
const {result, waitForNextUpdate} = renderHook(() => useTasks());
await waitForNextUpdate();
const [tasks, {toggleTask}] = result.current;
await act(() => toggleTask(0));
expect(updateTask).toHaveBeenCalledWith({...tasks[0], completed: !tasks[0].completed});
});
In this test, we extract the toggleTask
function from useTasks
hook and try to toggle the task with index 0
. Then we verify that the updateTask
was correctly called with the flipped completed
value.
Implementation
If you try running these tests now, they would fail, since we did not write the useTasks
hook just yet. However, they tell us all the information that we need to write that implementation. Create a file useTasks.js
:
// /src/hooks/useTasks.js
const useTasks = () => {
}
export default useTasks;
We need a way to store the tasks. Also, we agreed that until tasks arrive from the server, they are set to null
. To store state, we will use the useState
hook:
const useTasks = () => {
const [tasks, setTasks] = useState(null);
return [tasks];
}
While this does not yet pass the tests, it does get us further. But, how do we request tasks from the server on render? The answer is the useEffect
hook, of course!
const useTasks = () => {
const [tasks, setTasks] = useState(null);
const refreshTasks = async () => {
setTasks(await getTasks());
};
useEffect(() => {refreshTasks()}, []);
return [tasks];
}
This hook will run on the first render only (hence, the empty dependencies array). In it, we call refreshTasks
. In turn, it requests new tasks from the API and saves them to state. All saves to state will be propagated and cause the component who uses this hook to re-render.
This code will get us through the first test (try running it yourself). Now, to get the rest of them, we need to expose the createTask
and toggleTask
functions:
// rest of the hook above
const toggleTask = async (index) => {
const newTask = {...tasks[index], completed: !tasks[index].completed};
await updateTask(newTask);
setTasks(await getTasks());
};
const createTask = async (taskName) => {
const newTask = await _createTask(taskName);
setTasks(tasks ? [...tasks, newTask] : [newTask]);
};
return [tasks, {toggleTask, createTask}];
Do not forget to add imports for API helpers and useState/useEffect
hooks as we go. With all of this code in place, the tests should pass. Here is the whole file if you were got lost:
import {useEffect, useState} from 'react';
import {createTask as _createTask, getTasks, updateTask} from '../api';
const useTasks = () => {
const [tasks, setTasks] = useState(null);
const refreshTasks = async () => {
setTasks(await getTasks());
};
const toggleTask = async (index) => {
const newTask = {...tasks[index], completed: !tasks[index].completed};
await updateTask(newTask);
setTasks(await getTasks());
};
const createTask = async (taskName) => {
const newTask = await _createTask(taskName);
setTasks(tasks ? [...tasks, newTask] : [newTask]);
};
useEffect(() => {
refreshTasks();
}, []);
return [tasks, {toggleTask, createTask}]
};
export default useTasks;
Quick recap
In this lesson, we finished the testing and implementation for the useTasks
hook, a vital part for our persistence feature. In the next lesson, we will connect it with the rest of the app to make our integration test pass. Here is the entire project so far for reference (branch step-20
on Github):
Get hands-on with 1400+ tech skills courses.