Unit Testing Hooks containing GraphQL Queries

If you jumped on the hooks train on react, you would know that it is exciting. But React officially doesn’t (yet) have a way to test hooks. Yes, you could set up a dummy component that renders the hook and test it out. But soon, you want to do some advance handling inside the hook that the dummy component doesn’t allow testing for.

Testing Hooks

Before we jump on to testing hooks containing GraphQL queries, let’s get this out of the way. What if you want to test just a simple hook and you are seeing the following error:

Invariant Violation: Hooks can only be called inside the body of a function component.

The react-hooks-testing-library provides a renderHook method that allows to render the hook in a stand alone component for testing. The usage is very simple, just return the results of the hook from the callback of renderHook. The current return value of the hook will be present in result.current. So if your hook returns a number (count) and a method (increment) to update that count, you could write:

test('should increment counter', () => {
  const { result } = renderHook(() => useCounter())
  expect(result.current.count).toBe(0);

  act(() => {
    result.current.increment()
  })
  expect(result.current.count).toBe(1)
});

Hooks with GraphQL Queries

So we know it is simple to test regular hooks. What if there’s a hook that contains some useQuery statements, or uses client.query from an effect. Something along the lines of:

export function useModelWithNestedFetch({ id, query }) {
  const client = useApolloClient();
  const { data, ...queryState } = useQuery(query, { variables: { id, skip: id === 'new' });
  const [model, setModel] = useState(id === 'new' ? newModel() : data?.model);

  useEffect(() => {
    setModel(id === 'new' ? newModel() : data?.model);

    const nestedIds = data?.model?.nestedIds;
    if (!nestedIds?.length) { return data?.model && setModel(update(data.model, { nesteds: { $set: [] } })); }

    // Load nesteds
    client.query({
      query: gqlDynamic`
        query Nesteds {
          ${nestedIds.map((id) => `
          nested_${id}: nested(id:${id}) {
            ...NestedInfo
          }
          `).join('\n')}
        }
      `,
    })
      .then((nestedData) => setModel(update(data.model, { nesteds: { $set: Object.values(nestedData.data) } })))
      .catch((error) => console.warn('failed to fetch nesteds', error));
  }, [id, data, client, setModel]);
  return { model, setModel, ...queryState };
}

I know this is a lot of code, but we never said we were testing a simple hook, did we. Let’s first see what it is doing. We are first fetching (or initializing if it is a new record) a model. The API, unfortunately, doesn’t embed the nested records and returns only nestedIds inside the field. So after fetching the record, we run another query that is generated at runtime to fetch each of the nested records and then use setModel callback to update or fetched model with the loaded nested records.

Now comes the interesting part, how do we test this?

The first thing that we need to do is mock out the queries. But it is difficult to do that inside a hook. So what we will do instead is mock out those hooks themselves. It is easy with jest.mock:

jest.mock('@apollo/react-hooks', () => {
  const { nested1 } = require('../../../fixtures/nesteds');
  const { model1 } = require('../../../fixtures/models');

  const data = {
    model: model1,
  };
  const fetchMore = jest.fn();
  const client = {
    query: jest.fn(() => new Promise((resolve) => {
      setTimeout(() => resolve({ data: { nested1 } }), 100);
    })),
  };
  return {
    __esModule: true,
    useMutation: jest.fn(() => [jest.fn(), null]),
    useQuery: jest.fn(() => ({ data, fetchMore })),
    useApolloClient: jest.fn(() => client),
  };
});

Now, time for our first test, where we will just test that the new model is initialized properly

it('doesn\'t query and returns new model when id new', () => {
  const { result } = renderHook(() => useModelWithNestedFetch({ id : 'new', query: 'QUERY' }));
  expect(result.current.model).toEqual(newModel());
  expect(useQuery.mock.calls[0][1].skip).toBeTruthy();
});

The second test will validate that we query for our model and then after an effect, we also query for the nested records. Here we will use the waitForNextUpdate from the testing library’s renderHook return value to wait for the effect to update the state.

it('queries for model', async () => {
  const { result, waitForNextUpdate } = renderHook(() => useModelWithNestedFetch({ id : 1, query: 'QUERY' }));
  expect(result.current.model).toEqual(model1);
  expect(useQuery.mock.calls[0][1].skip).toBeFalsy();
  expect(useQuery.mock.calls[0][1].variables.id).toBe(1);

  expect(useApolloClient.mock.results[0].value.query).toHaveBeenCalledTimes(1);

  act(() => jest.advanceTimersByTime(100));
  await waitForNextUpdate();

  expect(result.current.model.nesteds).toEqual([nested1]);
});

If you are following along, you might be wondering why your tests are intermittently failing, right? That is because, we are missing a very important part of the setup. You remember we returned a promise from the Apollo client.query mock? That promise would sometimes resolve before the next statement and sometimes not. We need to use fake timers to make sure the promises/timeouts are properly handled:

beforeEach(() => {
  jest.useFakeTimers();
  [
    useMutation,
    useQuery,
    useApolloClient,
  ].forEach(mock => mock.mockClear());
});
Published 20 Sep 2020

I build mobile and web applications. Full Stack, Rails, React, Typescript, Kotlin, Swift
Pulkit Goyal on Twitter