A Guide to Unit Testing React Apollo Components
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:
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:
RepoStarButton
that has been starred by the current userRepoStarButton
that has not been starred by the current userRepoForRepoStar
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 __typename
s 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.