DEV Community

loading...

Testing custom Apollo hooks with React Testing Library

Hugo
Sometimes I code, sometimes I read, sometimes I write, but I'm always learning.
・3 min read

The other day, checking my code, I found a graphQL query that was repeating in many places. So I decided to put that query into a custom hook. That was the easy part, the hard part was to know how to test it. This is how I did it:


For this tutorial, we will be using this Public GraphQL API for information about countries to fetch the country’s names and codes. This is the query:

query {
  countries {
    name
    code
  }
}

Now it’s time to create our custom hook, which it’s pretty straightforward.
The custom hook has two duties, the first one is to fetch the list of countries and the second one is to concatenate the country’s name and code.

/**
 * Custom hook to fecth a list of countries
 *
 * @export
 * @param {Object} [options={}] Apollo Query Options
 * @returns {Object} Object with the the countries, loading, and error variables.
 */
export default function useCountries(queryOptions = {}) {
  const { loading, error, data } = useQuery(COUNTRIES, queryOptions);
  const countries = useMemo(() => {
    if (data) {
      return data.countries.map(country => `${country.name} - ${country.code}`);
    }
  }, [data]);
  return { loading, error, countries };
}

Let’s see it in action

import React from "react";
import useCountries from "./hooks/useCountries";
import "./styles.css";

export default function App() {
  const { loading, error, countries } = useCountries();

  function renderCountryList(country, index) {
    return (
      <div className="list-item" key={index}>
        {country}
      </div>
    );
  }

  if (loading) {
    return <h2>Loading countries</h2>;
  }

  if (error) {
    return <h2>Uppps! There was an error</h2>;
  }

  return (
    <div className="App">
      <h1>List of Countries</h1>
      {countries.map(renderCountryList)}
    </div>
  );
}

How to test it

Now comes the fun part, how to test that hook. We will be using React Testing Library, @apollo/react-testing to mock our Apollo Provider, and react-hooks-testing-library

Let's start by creating our test cases and our mocked responses. We will be testing when it gets the list of countries successfully and when there is an error.

import React from "react";
import useCountries, { COUNTRIES } from "./useCountries";

describe("useCountries custom hook", () => {
  // :: DATA :: 
  const mexico = {
    name: "Mexico",
    code: "MX"
  };
  const argentina = {
    name: "Argentina",
    code: "AR"
  };
  const portugal = {
    name: "Portugal",
    code: "PT"
  };

  // :: MOCKS :: 
  const countriesQueryMock = {
    request: {
      query: COUNTRIES
    },
    result: {
      data: {
        countries: [argentina, mexico, portugal]
      }
    }
  };

  const countriesQueryErrorMock = {
    request: {
      query: COUNTRIES
    },
    error: new Error("Ohh Ohh!")
  };


  it("should return an array of countries", async () => {});

  it("should return error when request fails", async () => {});
});

First test case

The first test case checks that our hook returns an array of countries.

If you read the documentation of react-hooks-testing-library
, you know that we have to wrap our hook in renderHook:

const { result, waitForNextUpdate } = renderHook(() => useCountries());

But because we are using useQuery from Apollo inside our hook, we need to use MockedProvider to wrap renderHook and Mock the responses. We can use the wrapper option for renderHook to do that.

// Apollo Mocked Provider Wrapper
const wrapper = ({ children }) => (
  <MockedProvider>
    {children}
  </MockedProvider>
);

const { result, waitForNextUpdate } = renderHook(() => useCountries(), {
  wrapper
});

Because we will be using that code in both of our test cases, we can move it into a function

function getHookWrapper(mocks = []) {
  const wrapper = ({ children }) => (
    <MockedProvider mocks={mocks} addTypename={false}>
      {children}
    </MockedProvider>
  );
  const { result, waitForNextUpdate } = renderHook(() => useCountries(), {
  wrapper
  });
  // Test the initial state of the request
  expect(result.current.loading).toBeTruthy();
  expect(result.current.error).toBeUndefined();
  expect(result.current.countries).toBeUndefined();
  return { result, waitForNextUpdate };
}

Now we test the first case.

 it("should return an array of countries", async () => {
  const { result, waitForNextUpdate } = getHookWrapper([countriesQueryMock]);
  // Wait for the results
  await waitForNextUpdate();
  // We access the hook result using result.current
  expect(result.current.loading).toBeFalsy();
  expect(result.current.error).toBeUndefined();
  expect(result.current.countries).toEqual([
    `${argentina.name} - ${argentina.code}`,
    `${mexico.name} - ${mexico.code}`,
    `${portugal.name} - ${portugal.code}`
  ]);
});

Second test case

The second test case it's pretty similar, but now we test when there's an error.

it("should return error when request fails", async () => {
  // Similar to the first case, but now we use countriesQueryErrorMock
  const { result, waitForNextUpdate } = getHookWrapper([
    countriesQueryErrorMock
  ]);
  await waitForNextUpdate();
  expect(result.current.loading).toBeFalsy();
  expect(result.current.error).toBeTruthy();
  expect(result.current.countries).toBeUndefined();
});

As you can see, it's not that hard once you know how to do it. Here is the code in case you need it.

Thanks for reading.

Discussion (4)

Collapse
findlesticks1 profile image
Findlesticks

Thanks for this! From this setup, how would you go about changing the input to the hook and checking the hook's new return values? e.g if you wanted to change queryOptions, but the hook cares about old queries too?

Collapse
hugoliconv profile image
Hugo Author • Edited

I created this hook because I was using the query all over the place and in my case, I know that both the query and the input to the custom hook won't change. The only thing that could change is the queryOptions, maybe I want to change the fetchPolicy option or add a callback when the request is completed using the onCompleted option. But as I said, that is the only thing that could change.

const { loading, error, countries } = useCountries({
  fetchPolicy: "network-only"
});
//...
const { loading, error, countries } = useCountries({
  onCompleted: () => alert("completed")
});
Collapse
giolvani profile image
Giolvani de Matos • Edited

Greate job Hugo!
I wondering how to do this same example using the useLazyQuery

Collapse
hugoliconv profile image
Hugo Author

Hi Giolvani! I changed to the to use useLazyQuery you can check it here