Creating Reusable Mocks: Continued

Continue the discussion about reusable mocks and testing practices while we further develop the "useTasks" hook.

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:

  1. Call the hook and extract the createTask function from its return value.
  2. Call the function with the new task name.
  3. Verify that the underlying API function was called with the correct argument.
  4. 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 since useTasks 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.