For this guide, we will be using these two library:
- Jest - javascript testing framework
- React Testing Library - a set of helpers that let us test React components without relying on their implementation details.
Setup
- We will be using codesandbox for a quick demo
-
Click here to spin up a react project
-
Add these devDependencies
jest
@testing-library/jest-dom
@testing-library/react
-
Create a file
index.test.js
insidesrc
folder and add thisimport React from "react"; import { render } from "@testing-library/react"; import "@testing-library/jest-dom"; // we need this to interact with dom import App from "./App"; test("should render App", () => { const { container } = render(<App />); expect(container).toHaveTextContent("Hello CodeSandbox"); });
-
Click the
Tests
tab and click play, you should see something like this.
Congrats 🎉 you made your first test, and it passed!
So recap
-
We use
render
from testing-library to render our component -
We imported the
jest-dom
to simulates a DOM environment, and provides a set of custom jest matchers that we can use to extend jest. For this use case, we use thetoHaveTextContext
on our assertion to check if there is a text content with"Hello CodeSandbox"
string.The rest of the custom matchers is can be seen here.
Example 1: With Jest Mock Function
Final version https://github.com/mharrvic/unit-test/tree/jest-fetch-mock
Why do we need to mock?
In a unit test, mock objects can simulate the behavior of complex, real objects and are therefore useful when a real object is impractical or impossible to incorporate into a unit test.
When do we need to use mock?
- mock API calls
- mock databases queries
- mock conditions difficult to generate in a test environment
Setup
- Setup CRA with jest
npx create-react-app unit-test
cd unit-test
-
Create
utils
folder andadd api-client.js
undersrc
folder -
Create
Contributions.js
file undersrc
folderimport React from "react"; import { client } from "./utils/api-client"; function ContributorProfile({ total, avatar, username }) { return ( <div data-testid="contributor" className="contributor"> <div className="profile"> <img src={avatar} width="60px" alt={username} /> <p>{username}</p> </div> <div className="total"> <p className="number">{total}</p> commits </div> </div> ); } function Loading() { return ( <div aria-label="loading"> <p>loading</p> </div> ); } export default function FetchContributors() { const [contributors, setContributors] = React.useState({}); const [status, setStatus] = React.useState("idle"); const fetchContributors = async () => { setStatus("loading"); client("stats/contributors") .then((data) => data.map(({ total, author }) => ({ total: total, username: author.login, avatar: author.avatar_url, id: author.id, })) ) .then((contributorList) => { setContributors({ contributorList }); setStatus("success"); }); }; return ( <div> <button onClick={fetchContributors}>Fetch contributors</button> <> {status === "loading" && <Loading />} {status === "success" && ( <div data-testid="contributors" className="contributors"> {contributors.contributorList.map((contributor) => { return ( <ContributorProfile key={contributor.id} {...contributor} /> ); })} </div> )} </> </div> ); }
-
Add the
Contributors
component toApp.js
import "./App.css"; import Contributors from "./Contributors"; function App() { return ( <div className="App"> <Contributors /> </div> ); } export default App;
-
Create
__tests__
folder and addfetch-contributor.test.js
import "@testing-library/jest-dom"; import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved, } from "@testing-library/react"; import React from "react"; import Contributors from "../Contributors"; beforeAll(() => jest.spyOn(window, "fetch")); test("should render with mock fetch", async () => { render(<Contributors />); window.fetch.mockResolvedValueOnce({ ok: true, json: () => { return [ { total: 59, author: { login: "red", id: 1234, avatar_url: "https://avatars2.githubusercontent.com/u/1234?v=4", }, }, { total: 122, author: { login: "leo", id: 4567, avatar_url: "https://avatars2.githubusercontent.com/u/4567?v=4", }, }, ]; }, }); const fetchButton = screen.getByText("Fetch contributors"); fireEvent.click(fetchButton); await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i)); await waitFor(() => screen.getAllByTestId("contributor")); expect(fetch).toHaveBeenCalledTimes(1); const contributors = screen.getAllByTestId("contributor"); expect(contributors).toHaveLength(2); expect(contributors[0]).toHaveTextContent("red"); expect(contributors[0]).toHaveTextContent("59"); expect(contributors[1]).toHaveTextContent("leo"); expect(contributors[1]).toHaveTextContent("122"); });
- Add
jest.spyOn
that creates a mock and track calls on ourfetch
API on beforeAll (will trigger on before executing each test) - We added the
mockResolvedValueOnce
to mock our result once resolved - With the following test interaction, by clicking the
Fetch contributions
button to triggerfetch
- We use the
waitForElementToBeRemoved
to wait for the loading to be removed on the DOM, and then waiting forgetAllByTestId
since we addedcontributor
as test-id on the mapped result - Asserts on the expected results
- Add
-
Run
yarn test
to execute the test
Example 2: With Mock Service Worker(MSW)
Final Version: https://github.com/mharrvic/unit-test/tree/jest-msw
Mock by intercepting requests on the network level. Seamlessly reuse the same mock definition for testing, development, and debugging.
When to use MSW?
Kent C. Dodds published this blog about Stop mocking fetch, which he explains mocking things like fetch is that you end up re-implementing your entire backend, on every test.
Setup (We will going to continue on our progress on example 1)
-
Add
msw
dependencyyarn add msw
-
Create a
test
folder undersrc
and add thisimport { rest } from "msw"; import { setupServer } from "msw/node"; import { apiURL } from "../utils/api-client"; const handlers = [ rest.get(`${apiURL}/stats/contributors`, async (_, res, ctx) => { return res( ctx.json([ { total: 59, author: { login: "red", id: 1234, avatar_url: "https://avatars2.githubusercontent.com/u/1234?v=4", }, }, { total: 122, author: { login: "leo", id: 4567, avatar_url: "https://avatars2.githubusercontent.com/u/4567?v=4", }, }, ]) ); }), ]; const server = setupServer(...handlers); export * from "msw"; export { server };
-
Open the
setupTest.js
and add this underimport "@testing-library/jest-dom";
import { server } from "./test/test-server"; beforeAll(() => server.listen()); afterAll(() => server.close()); afterEach(() => server.resetHandlers());
This will be trigger on every test. The
MSW
needs tolisten()
on every before the test,close()
the connection on after all the test, andresetHandlers()
on every after each test. -
For us to test if the
MSW
setup is working fine, create amsw-server.test.js
under__tests__
folder and add thisimport { server, rest } from "../test/test-server"; import { client, apiURL } from "../utils/api-client"; test("makes GET requests to the given endpoint", async () => { const endpoint = "test-endpoint"; const mockResult = { mockValue: "VALUE" }; server.use( rest.get(`${apiURL}/${endpoint}`, async (_, res, ctx) => { return res(ctx.json(mockResult)); }) ); const result = await client(endpoint); expect(result).toEqual(mockResult); });
Run
yarn test
to see if it pass, you can playaround with the results -
Next we will going recreate the
fetch mocking
implementation on example 1 by using MSWCreate a
msw-contributor.test.js
under__tests__
folder and add thisimport "@testing-library/jest-dom"; import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved, } from "@testing-library/react"; import React from "react"; import Contributors from "../Contributors"; test("using msw", async () => { render(<Contributors />); const fetchButton = screen.getByText("Fetch contributors"); fireEvent.click(fetchButton); await waitForElementToBeRemoved(() => [ ...screen.queryAllByLabelText(/loading/i), ...screen.queryAllByText(/loading/i), ]); await waitFor(() => screen.getAllByTestId("contributor")); const contributors = screen.getAllByTestId("contributor"); expect(contributors).toHaveLength(2); expect(contributors[0]).toHaveTextContent("red"); expect(contributors[0]).toHaveTextContent("59"); expect(contributors[1]).toHaveTextContent("leo"); expect(contributors[1]).toHaveTextContent("122"); });
Looks clean right?
Under the hood, when the test triggers a click event, MSW intercepts the request and returned our defined output on our
test-server.js
with the/stats/contributors
endpoint.
Resources
https://pawelgrzybek.com/mocking-functions-and-modules-with-jest/
https://en.wikipedia.org/wiki/Mock_object
https://www.richardkotze.com/coding/react-testing-library-jest
https://kentcdodds.com/blog/stop-mocking-fetch
https://medium.com/@rickhanlonii/understanding-jest-mocks-f0046c68e53c