Published: 01 September 2020
Scenario
The most common scenario we face is making an API call, showing a loading spinner while the process is going on and once its finished, we show success result or handle errors.
Let's create a component for this scenario:
//apiComponent.js
import React, { useState } from "react"
const ApiComponent = () => {
const [user, setUser] = useState(null)
const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false)
const getUser = async () => {
setIsLoading(true)
try {
const resp = await fetch("https://jsonplaceholder.typicode.com/users/1")
const userData = await resp.json()
setUser(userData)
setIsLoading(true)
} catch (err) {
setIsLoading(true)
setError(err)
}
}
return (
<div>
{user && (
<p>
{user.name} - {user.email}
</p>
)}
{error && <p>{error}</p>}
{isLoading && <h1>LOADING...</h1>}
<button type="button" onClick={getUser}>
Fetch User
</button>
</div>
)
}
export default ApiComponent
Assuming we have imported the 4 major things as discussed in the last post on RTL, let's start writing some tests for this.
describe("Api Component", () => {
it("renders correct UI initially", () => {
const { getByText } = render(<ApiComp />)
expect(getByText("Fetch User")).toBeInTheDocument()
})
})
The above test checks to make sure that our button is rendering correctly.
Before we fire a click event on this button, we need to stub out our API call for few reasons:
(1) API calls are usually expensive operations (time wise)
(2) Hard to predict if API results will change in the future or not (could fail our tests)
(3) Backend server could be down
(4) As a frontend test writer, our job is to test how our own code works and not worry about API testing
// for successful calls
// here we are returning a promise which resolves to give a json prop
// that json prop in turn returns a promise
// this is how fetch operates as well
fetch = jest.fn(() =>
Promise.resolve({ json: () => Promise.resolve(fakeData) })
)
// for unsuccessful calls, simple rejecting the call
fetch = jest.fn(() => Promise.reject("failed, try again later"))
Let's write successful case first where we see the loading...
text first and then our API call results in some fake data like so:
it("successfully calls the API", () => {
// create fake data
const fakeData = { name: "Homer Simpson", email: "homer.simpson@gmail.com" }
fetch = jest.fn(() =>
Promise.resolve({ json: () => Promise.resolve(fakeData) })
)
// render the component and trigger a click on fetch user button
const { getByText } = render(<ApiComp />)
fireEvent.click(getByText("Fetch User"))
expect(getByText(/loading/i)).toBeInTheDocument()
// assert that p element with user's email and name is shown
expect(getByText(`${fakeData.name} - ${fakeData.email}`)).toBeInTheDocument()
})
The above test will fail with the following warning:
Warning: An update to MyComponent inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state */
});
/* assert on the output */
This error indicates - there is some state change going on inside the component which hasn't been taken care of in our tests and so we need to fix it before continuing.
In our case, we haven't waited for the promise to resolve before making our assertions which has caused this error to appear. To fix it, RTL provides a waitFor
method which can be used like so:
import { render, fireEvent, waitFor } from "@testing-library/react";
...
...
it("successfully calls the API", async () => {
const fakeData = { name: "Homer Simpson", email: "homer.simpson@gmail.com" };
fetch = jest.fn(() => Promise.resolve({ json: () => Promise.resolve(fakeData) }));
const { getByText } = render(<ApiComp />);
fireEvent.click(getByText("Fetch User"));
// make sure loader appears immediately after the button click
expect(getByText(/loading/i)).toBeInTheDocument()
// waitFor is an async operation, so we await it and inside it provide our assertions
await waitFor(() => {
expect(getByText(`${fakeData.name} - ${fakeData.email}`)).toBeInTheDocument();
// make sure loader is shown no more
expect(getByText(/loading/i)).not.toBeInTheDocument()
});
});
..
..
The above test will pass because now we have sufficiently waited for the promise to resolve and right at the end we can make our assertions.
The error scenario will also look very similar:
it("handles the failed API call with an error message", async () => {
fetch = jest.fn(() => Promise.reject("failed, try again later"))
const { getByText } = render(<ApiComp />)
fireEvent.click(getByText("Fetch User"))
// make sure loader appears immediately after the button click
expect(getByText(/loading/i)).toBeInTheDocument()
await waitFor(() => {
expect(getByText("failed, try again later")).toBeInTheDocument()
// make sure loader is shown no more
expect(getByText(/loading/i)).not.toBeInTheDocument()
})
})
Snapshot Testing
Another useful way to test is via snapshot testing which is helpful in ensuring your UI hasn't changed accidentally.
In this type of testing, a __snapshots__
folder is created with your test file and it contains a picture of your component's DOM structure.
If you want to intentionally change your component's UI, you can update your existing snapshot by telling jest to make the latest changes to be your preferred markup.
it("captures snapshot correctly", () => {
const { asFragment } = render(<ApiComp />)
expect(asFragment()).toMatchSnapshot()
})
Now if you change your markup, jest will throw an error. You can run jest --updateSnapshot
to update all snapshots. Alternatively, if you are using jest's watch
mode, you can choose the option u
to update all snapshots at once or choose i
to let jest walk you through each snapshot one by one.
Working with React Router
When working with react router, the setup changes a little bit.
Consider the following simple component:
//app.js
const App = () => {
return (
<Router>
<div className="App">
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/contact">Contact</Link>
<Switch>
<Route exact path="/" component={Home}></Route>
<Route path="/about" component={About}></Route>
<Route path="/contact" component={Contact}></Route>
</Switch>
</div>
</Router>
)
}
The most common scenario we would want to test for is clicking a link actually takes us the page we want to go to.
For this we will wrap our component with a MemoryRouter
.
//app.test.js
import { MemoryRouter } from "react-router-dom"
import App from "./App"
it("renders correctly", () => {
// render the component and wrap it with MemoryRouter
const { getByText } = render(<App />, { wrapper: MemoryRouter })
// make sure we are on the right page
expect(getByText("Home Page")).toBeInTheDocument()
// now get the text in the link and click the link
fireEvent.click(getByText("About"))
// make sure we end up on the right page
expect(getByText("About Page")).toBeInTheDocument()
})
MemoryRouter is great for navigating from one route to another or when we need is to render a certain route on the initial render itself like so:
it("renders correctly by navigating to different route first", () => {
// first setup a different route other than home page
window.history.pushState({}, "about", "/about")
// render the component so it lands on that route
const { getByText, queryByText } = render(<App />, { wrapper: MemoryRouter })
// ensure home page is not rendered
expect(queryByText("Home Page")).not.toBeInTheDocument()
// ensure the desired page is rendered
expect(getByText("About Page")).toBeInTheDocument()
// click on the desired element and navigate to another page
fireEvent.click(getByText("Contact"))
expect(getByText("Contact Page")).toBeInTheDocument()
})
NOTE: For changing things with history object itself, we should use BrowserRouter
.