A Guide to Unit Testing React Apollo Components

WEB
11 min read

DoltHub is a place on the internet to share, discover, and collaborate on Dolt databases. It's a Next.js application written in Typescript, backed by a GraphQL server that calls gRPC services written in Golang. We use Apollo's built-in integration with React to manage our GraphQL data within our React components. You can read more about our front end architecture here.

We rebuilt and redesigned DoltHub in early 2020, and have been working on a testing solution since. We started with Cypress tests for end-to-end testing our production website. You can take a look at our open-source Cypress tests here. However, our Cypress tests don't run in continuous integration (CI), so we've been slowly adding more unit tests for our React components to hopefully catch more bugs and issues in CI before deploying changes to production.

Bringing our unit test coverage up to speed has been a process. While using React Testing Library to test components is well documented and easily Googleable, using both React Testing Library and Apollo Client together is a bit more niche.

This guide goes through setup, mocking queries, writing tests, and some gotchas I encountered during the whole process.

Setting up Jest

First, we needed to install a few dev dependencies and set up our Jest configuration. Here's a look at our package.json:

{
  "devDependencies": {
    "@testing-library/jest-dom": "^5.9.0",
    "@testing-library/react": "^11.1.0",
    "@types/jest": "^26.0.20",
    "babel-jest": "^26.0.1",
    "identity-obj-proxy": "^3.0.0", // For CSS modules
    "jest": "^26.6.3",
    "jest-environment-jsdom-sixteen": "^1.0.3", // Use JSDOM v16 instead of v15
    "jest-localstorage-mock": "^2.4.3" // Used with components that use @rooks/use-localstorage
  }
}

We added two these two files to the root of our app:

// jest.setup.ts
import "@testing-library/jest-dom/extend-expect";
import "jest-localstorage-mock";
// jest.config.js
const TEST_REGEX = "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|js?|tsx?|ts?)$";

module.exports = {
  setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
  testRegex: TEST_REGEX,
  transform: {
    "^.+\\.tsx?$": "babel-jest",
  },
  moduleNameMapper: {
    "\\.(css|less)$": "identity-obj-proxy",
  },
  testPathIgnorePatterns: ["<rootDir>/.next/", "<rootDir>/node_modules/"],
  moduleFileExtensions: ["ts", "tsx", "js", "jsx"],
  collectCoverage: false,
};

We added the script "test": "jest --env=jest-environment-jsdom-sixteen" to our package.json and we were ready to start testing our components.

Writing tests

I'm going to use our RepoStarButton component, which contains both queries and mutations, as an example. It's a button that can be found on repository pages and repository list items. It shows the number of stars for a repository and whether the current user has starred the repository or not (designated by a filled or outlined star). The current user is able to star and unstar the repo. It looks like this:

RepoStarButton

First, I'll go through adding tests for the component, which depends on the results of a query that gets the repository's star information. Then we'll add the mutations that allow a user to star and unstar a repository.

Testing queries

Our components with queries have an "outer" and "inner" component. The "outer" component fetches the relevant queries and handles the loading and error states. The "inner" component renders the elements with the results of the query. This is what the "outer" RepoStarButton component looks like:

// RepoStarButton/index.tsx

import React from "react";
import ReactLoader from "react-loader";
import Inner from "./Inner";
import { useRepoForRepoStarsQuery } from "../../gen/graphql-types";

type Props = {
  params: {
    ownerName: string;
    repoName: string;
  };
  username?: string;
};

export default function RepoStarButton(props: Props) {
  const res = useRepoForRepoStarsQuery({ variables: props.params });

  if (res.loading) return <ReactLoader loaded={false} />;

  // Normally we'd return an ErrorMsg if the query returns an error.
  // In this case, we just don't render the repo star button.
  if (res.error || !res.data) return null;

  return <Inner repo={res.data.repo} username={props.username} />;
}

Our components with queries come with a queries.ts file, which defines the shape of the GraphQL queries and mutations using graphql-tag. We also use GraphQL Code Generator to generate types and hooks for the queries defined in these files. You can see them being imported below from gen/graphql-types.tsx.

The query hook above, useRepoForRepoStarsQuery, is generated from this query:

// RepoStarButton/queries.ts

import { gql } from "@apollo/client";

export const REPO_FOR_REPO_STARS_QUERY = gql`
  fragment RepoStarsRepo on Repo {
    _id
    repoName
    ownerName
    starredByCaller
    starCount
  }
  query RepoForRepoStars($ownerName: String!, $repoName: String!) {
    repo(ownerName: $ownerName, repoName: $repoName) {
      ...RepoStarsRepo
    }
  }
`;

Based on the result of our "outer" component query results, our "inner" component will render with the result's data:

// RepoStarButton/Inner.tsx

import React from "react";
import { RepoStarsRepoFragment } from "../../gen/graphql-types";
import css from "./index.module.css";

type Props = {
  repo: RepoStarsRepoFragment;
  username?: string;
};

export default function Inner({ repo, username }: Props) {
  const iconPath = repo.starredByCaller
    ? "/images/star_filled.svg"
    : "/images/star_outline.svg";

  return (
    <button type="button" className={css.button} disabled={!username}>
      <div className={css.left}>
        <img src={iconPath} alt="" />
        <span>Star</span>
      </div>
      <div className={css.right}>
        <span>{repo.starCount}</span>
      </div>
    </button>
  );
}

Before we can start writing tests, we need to mock the RepoForRepoStarsQuery query. The Apollo Docs are a great place to start for more information about using MockedProvider and mocking. We added these mocks to mocks.ts:

// RepoStarButton/mocks.ts

import { MockedResponse } from "@apollo/client/testing";
import {
  RepoForRepoStarsDocument,
  RepoStarsRepoFragment,
} from "../../gen/graphql-types";

// Define some constants
const username = "foouser";

export const params = {
  ownerName: "dolthub",
  repoName: "corona-virus",
};

const repoId = `repositoryOwners/${params.ownerName}/repositories/${params.repoName}`;

// Repo fragment mock
export const repo = (starredByCaller: boolean): RepoStarsRepoFragment => {
  return {
    __typename: "Repo",
    _id: repoId,
    repoName: params.repoName,
    ownerName: params.ownerName,
    starredByCaller,
    starCount: 10,
  };
};

export const mocks = (starredByCaller: boolean): MockedResponse[] => [
  // Repo query mock, which returns the repo fragment mock
  {
    request: { query: RepoForRepoStarsDocument, variables: params },
    result: { data: { repo: repo(starredByCaller) } },
  },
];

export const mocksWithError: MockedResponse[] = [
  // Repo query mock, which returns an error
  {
    request: { query: RepoForRepoStarsDocument, variables: params },
    error: new Error("repo not found"),
  },
];

From these mocks, we can write three tests:

  1. RepoStarButton that has been starred by the current user
  2. RepoStarButton that has not been starred by the current user
  3. RepoForRepoStar that has an error
// RepoStarButton/index.test.tsx

import { MockedProvider } from "@apollo/client/testing";
import { render } from "@testing-library/react";
import React from "react";
import RepoStarButton from ".";
import * as mocks from "./mocks";

describe("test RepoStarButton", () => {
  it("renders component for not starred by user", async () => {
    const starredByCaller = false;
    const { findByText, findByRole, getByText, getByLabelText } = render(
      <MockedProvider mocks={mocks.mocks(starredByCaller)}>
        <RepoStarButton params={mocks.params} />
      </MockedProvider>
    );

    // Check loading state
    expect(await findByRole("progressbar")).toBeTruthy();

    // Check query result
    expect(
      await findByText(mocks.repo(starredByCaller).starCount)
    ).toBeTruthy();

    const button = getByLabelText("button-with-count");
    expect(button).not.toBeNull();
    expect(button).toBeEnabled();

    expect(getByText("Star")).toBeVisible();

    const img = getByLabelText("button-icon");
    expect(img).toBeVisible();
    expect(img).toHaveAttribute("src", "/images/star_outline.svg");
  });

  it("renders component for starred by user", async () => {
    // Same as above, with `starredByCaller = true` and a check for a filled star img src
  });

  it("renders nothing for query error", async () => {
    const { findByRole, queryByLabelText } = render(
      <MockedProvider mocks={mocks.mocksWithError}>
        <RepoStarButton params={mocks.params} />
      </MockedProvider>
    );
    // Check loading state
    expect(await findByRole("progressbar")).toBeTruthy();
    // Should not render component
    expect(queryByLabelText("button-with-count")).toBeNull();
  });
});

We are now successfully testing a component with a query!

Testing with Mutations

Now that we have our query working and properly tested, we can add our mutations to create and delete repo stars when the button is toggled. We first add the mutations to queries.ts (and generate the types):

// RepoStarButton/queries.ts

export const CREATE_STAR = gql`
  mutation CreateRepoStar(
    $ownerName: String!
    $repoName: String!
    $username: String!
  ) {
    createStar(
      ownerName: $ownerName
      repoName: $repoName
      username: $username
    ) {
      _id
    }
  }
`;

export const DELETE_STAR = gql`
  mutation DeleteRepoStar(
    $ownerName: String!
    $repoName: String!
    $username: String!
  ) {
    deleteStar(ownerName: $ownerName, repoName: $repoName, username: $username)
  }
`;

Then we add the mutations to our "inner" component. They both need to include a refetchQueries array to ensure we update all the appropriate queries (RepoForRepoStar and RepoListForStarred) when the mutation is called. Our button's onClick property can now look like this: onClick={repo.starredByCaller ? onDelete : onCreate}.

// RepoStarButton/Inner.tsx

// Within in the `Inner` component
const refetchQueries = [
  {
    query: RepoForRepoStarsDocument,
    variables: { ownerName: repo.ownerName, repoName: repo.repoName },
  },
  { query: RepoListForStarredDocument, variables: { username } },
];

const [createStar] = useCreateRepoStarMutation({ refetchQueries });
const [deleteStar] = useDeleteRepoStarMutation({ refetchQueries });

const onCreate = async () => {
  if (!username) return;
  await createStar({
    variables: {
      ownerName: repo.ownerName,
      repoName: repo.repoName,
      username,
    },
  });
};

const onDelete = async () => {
  if (!username) return;
  await deleteStar({
    variables: {
      ownerName: repo.ownerName,
      repoName: repo.repoName,
      username,
    },
  });
};

Next, we added mocks for the mutations and refetched queries:

// RepoStarButton/mocks.ts

export const createStarNewData = jest.fn(() => {
  return {
    data: {
      createStar: {
        __typename: "RepoStar",
        _id: `${repoId}/stars/${username}`,
      },
    },
  };
});

export const deleteStarNewData = jest.fn(() => {
  return { data: { deleteStar: true } };
});

export const mocks = (starredByCaller: boolean): MockedResponse[] => [
  // Query from above

  // Mutations
  {
    request: {
      query: CreateRepoStarDocument,
      variables: { ...params, username },
    },
    newData: createStarNewData,
  },
  {
    request: {
      query: DeleteRepoStarDocument,
      variables: { ...params, username },
    },
    newData: deleteStarNewData,
  },

  // Refetched queries
  {
    request: { query: RepoForRepoStarsDocument, variables: params },
    result: { data: { repo: repo(starredByCaller) } },
  },
  {
    request: { query: RepoListForStarredDocument, variables: { username } },
    result: {
      data: {
        starredRepos: { __typename: "RepoList", nextPageToken: "", list: [] },
      },
    },
  },
];

Using jest.fn() to return the newData for the mutations allows us to test if the correct mutation is called when the button is clicked. You can also use this strategy to test whether the refetched queries have been called. We can add this to our first test for a RepoStarButton that has not been starred:

// RepoStarButton/index.test.ts

fireEvent.click(button);
await waitFor(() => {
  // Clicking the button should create a repo star for a repo that has not been starred by the current user
  expect(mocks.createStarNewData).toHaveBeenCalled();
});
// Delete star should not have been called
expect(mocks.deleteStarNewData).not.toHaveBeenCalled();

Our RepoStarButton queries and mutations now have test coverage! 🎉

Some gotchas

Getting the mocks right took some time and was a little tricky. The errors from MockedProvider are vague and difficult to debug. Most things that went wrong resulted in the same error: No more mocked responses for the query: .... This blog does a good job summarizing the reasons for getting this error. Here are some noteworthy places I ran into problems writing mocks and tests for RepoStarButton.

Using __typename

The Problem

● test RepoStarButton › renders component for starred by user
TestingLibraryElementError: Unable to find an element with the text: 10. This
could be because the text is broken up by multiple elements. In this case, you
can provide a function for your text matcher to make your matcher more flexible.

<body>
  <div>
    <button
      aria-label="button-with-count"
      class="button"
      data-cy="repo-star"
      type="button"
    >
      <div class="left">
        <img alt="" aria-label="button-icon" src="/images/star_outline.svg" />
        <span> Star </span>
      </div>
      <div class="right">
        <span> undefined </span> // we expected this to show the repo count "10"
      </div>
    </button>
  </div>
</body>

I initially had not added __typenames to the fragments in our mocks, thinking that adding addTypename={false} to MockedProvider was a workaround. When I went to test for the presence of repo.starCount in the button, it kept showing "undefined" within the span I expected to show the count. This was difficult to debug because not only did I not get an error, but attempting to throw an error if the data returned undefined also didn't work.

The Solution

When I added the __typename to the repo fragment mock ("Repo" in this case), the star count was no longer undefined and the tests passed.

Variables not matching

The Problem

test RepoStarButton › renders component for starred by user

    No more mocked responses for the query: mutation DeleteRepoStar($ownerName: String!, $repoName: String!, $username: String!) {
      deleteStar(ownerName: $ownerName, repoName: $repoName, username: $username)
    }
    , variables: {"__typename":"Repo","_id":"repositoryOwners/dolthub/repositories/corona-virus","repoName":"corona-virus","ownerName":"dolthub","starredByCaller":true,"starCount":10,"username":"foouser"}

We use the spread operator (...) in a lot of places across our components to pass in variables to our queries and mutations. Before writing tests for RepoForRepoStar, we passed in the variables to createStar and deleteStar like so:

await createStar({ variables: { ...repo, username } });

While this works for the functionality of this button within our app, the variables did not match up with our mocked mutations and resulted in an error.

The Solution

The variables in the mocks needed to exactly match the variables passed in to the mutation in the component. This was solved by removing the use of the spread operator and being more specific with the variables being passed in to the mutation.

await createStar({
  variables: { ownerName: repo.ownerName, repoName: repo.repoName, username },
});

Handling refetchQueries

The Problem

test RepoStarButton › renders component for not starred by user

  No more mocked responses for the query: query RepoForRepoStars($ownerName: String!, $repoName: String!) {
    repo(ownerName: $ownerName, repoName: $repoName) {
      ...RepoStarsRepo
      __typename
    }
  }

  fragment RepoStarsRepo on Repo {
    _id
    repoName
    ownerName
    starredByCaller
    starCount
    __typename
  }
  , variables: {"ownerName":"dolthub","repoName":"corona-virus"}

Each query in refetchQueries that's being refetched when a mutation is called needs to be mocked as well. Since we already had a mock for RepoForRepoStars from the top-level query, I thought it could be reused when the query is refetched.

The Solution

I added the RepoForRepoStar mock twice to satisfy both the original query and the refetched query.

Further work

As you can see, writing out the mocks for queries and mutations can be difficult and time-consuming. RepoStarButton is one of our simpler components, and the mocks alone require about 90 lines of code. We have 100+ React components we need tests for and only two devs to write them.

The mocks also take time and effort to maintain, as they need to be updated any time there are changes to a component's queries or mutations. That opens up room for more bugs. Is it worth it? We think so, but it seems like this whole space could use more thought on how to test.

One of the solutions we've been looking into is auto-generating mocks for our GraphQL queries. This would save us a lot of time, and help avoid human errors that come from writing out and maintaining the mocks by hand. There are a few blogs out there like this one that go through auto-generating mocks based on the GraphQL schema. It's something we're working on, and I hope to write another blog about our solution in the future!

Conclusion

Unit testing is important for catching mistakes in CI before changes and features are made live in production. We're still working on improving our test coverage and building out a framework for more easily writing unit tests for our React components.

Have ideas, questions, or just want to chat? Join our Discord server and find me in our #dolthub channel.

SHARE

JOIN THE DATA EVOLUTION

Get started with Dolt

Or join our mailing list to get product updates.