How to test dependency on a React hook

Viewed 3467

I am trying to test a custom hook using @testing-library/react-hooks but I have trouble testing the dependency. Let's use useEffect as an example:

import { renderHook } from '@testing-library/react-hooks';
import { useEffect } from 'react';

test('test dependency', () => {
  const callback = jest.fn((value: number) => {});
  let currentValue = 5;

  renderHook(() => useEffect(() => callback(currentValue), [currentValue]));
  expect(callback).toBeCalledTimes(1);
  expect(callback).toHaveBeenLastCalledWith(5);

  renderHook(() => useEffect(() => callback(currentValue), [currentValue]));
  expect(callback).toBeCalledTimes(1); // error here: called 2 times in reality
  expect(callback).toHaveBeenLastCalledWith(5);

  currentValue = 6;
  renderHook(() => useEffect(() => callback(currentValue), [currentValue]));
  expect(callback).toBeCalledTimes(2);
  expect(callback).toHaveBeenLastCalledWith(6);
});

Expected behaviour: useEffect is not called again with the same dependency list.

Actual behaviour: useEffect is called every time, probably because the context is destroyed and re-created betweeb renderHook.

I also tried putting the render method into a constant like this:

const myHook = () => useEffect(() => callback(currentValue), [currentValue]);
renderHook(myHook);

But no luck. Is there any way that I can test whether or not the dependency list works correctly?

2 Answers

It's expected that renderHook mounts on every call, doing the opposite would prevent it from being used with unrelated hooks.

As with React Testing Library render, the result allows to control rendered instance, this includes unmount and rerender.

Additional values like currentValue can be passed the same way they would in a component, namely props object.

So it likely should be:

  let { rerender, unmount } = renderHook(
    ({ val }) => useEffect(() => callback(val), [val]),
    { initialProps: { val: 5 } }
  );

  expect(callback).toBeCalledTimes(1);
  expect(callback).toHaveBeenLastCalledWith(5);

  rerender({ val: 5 });

  expect(callback).toBeCalledTimes(1);
  expect(callback).toHaveBeenLastCalledWith(5);

  rerender({ val: 6 });

  expect(callback).toBeCalledTimes(2);
  expect(callback).toHaveBeenLastCalledWith(6);

Notice that the code tests React's own useEffect so it doesn't serve a practical purpose.

After some thinking, I came up with the following solution:

import { act, renderHook } from '@testing-library/react-hooks';
import { useEffect, useState } from 'react';

test('test dependency', () => {
  const callback = jest.fn((value: number) => {});
  const renderCounter = jest.fn();

  const { result } = renderHook(() => {
    const [{ value }, setValue] = useState({ value: 5 });
    renderCounter();
    useEffect(() => callback(value), [value]);
    return setValue;
  });

  expect(renderCounter).toHaveBeenCalledTimes(1);
  expect(callback).toBeCalledTimes(1);
  expect(callback).toHaveBeenLastCalledWith(5);

  act(() => result.current({ value: 5 }));
  expect(renderCounter).toHaveBeenCalledTimes(2);
  expect(callback).toBeCalledTimes(1);
  expect(callback).toHaveBeenLastCalledWith(5);

  act(() => result.current({ value: 6 }));
  expect(renderCounter).toHaveBeenCalledTimes(3);
  expect(callback).toBeCalledTimes(2);
  expect(callback).toHaveBeenLastCalledWith(6);
});

It's not that pretty but it works. The renderCounter is there to make sure that the inner hook is actually "re-rendering". For example, if you use 5 instead of {value: 5} for the state, then act(() => result.current(5)) does not "re-render" the hook.

Related