Introduction
Jest is a JavaScript framework aimed at giving developers a simple and fast way to set up tests. Maintained by Meta, the framework's flexibility is what makes it popular. In fact, this framework easily adapts to various other frameworks such as:
- ReactJS
- VueJS
- Angular
- NodeJS
- SvelteJS
- and many more.
In this article, we will be testing a ReactJS application using Jest. Check out the documentation to learn more about Jest. I also invite you to clone the project located in the sidebar to follow this tutorial.
Testing an Application with Jest
To test the application, we need to launch it using the command: npm run dev
. You should see the application launch as shown in the following image:
The application allows you to:
- Add an item to your cart
- Remove an item from your cart
- Pay for your items through a form
The first thing we need to do is install the Jest extension in our Visual Studio Code: Jest Plugin. You should see the extension available in the left sidebar. Clicking on it will show you all your tests:
If any of your tests fail, you should see the test appear in red, and an explanation will appear when you hover over it.
Let's test!
Firstly, let's examine the content of our src/index.tsx file:
import React from "react"
import ReactDOM from "react-dom"
import { BrowserRouter } from "react-router-dom"
import { App } from "./App"
import { CartProvider } from "./CartContext"
import "./index.css"
ReactDOM.render(
<React.StrictMode>
<CartProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</CartProvider>
</React.StrictMode>,
document.getElementById("root")
)
ProductsProvider retrieves information from the backend and contains it. CartProvider manages the state of your shopping cart and stores it in your localstorage. BrowserRouter facilitates routing. This presentation aims to present the basic structure of our application. We will now dive deeper into our App.tsx component, which is our entry functional component.
import React from "react"
import { Switch, Route } from "react-router-dom"
import { Checkout } from "./Checkout"
import { Home } from "./Home"
import { Cart } from "./Cart"
import { Header } from "./shared/Header"
import { OrderSummary } from "./OrderSummary"
export const App = () => {
return (
<>
<Header />
<div className="container">
<Switch>
<Route exact path="/">
<Home />
</Route>
<Route path="/checkout">
<Checkout />
</Route>
<Route path="/cart">
<Cart />
</Route>
<Route path="/order">
<OrderSummary />
</Route>
<Route>Page not found</Route>
</Switch>
</div>
</>
)
}
The App.tsx component does not accept any props and does not contain any business logic. Its only responsibility is to display the layout of the application. We will now create a test for this class that will verify if our layout is correctly displayed. For this, we will create the src/App.spec.tsx file:
import React from "react"
import { App } from "./App"
import { render } from "@testing-library/react"
describe("App", () => { it("renders successfully", () => { const { container } = render(<App/>) expect(container.innerHTML).toMatch("Goblin Store") })
})
Some explanations:
describe
allows to group a series of tests. Often, multiple ones are used to precisely break down the components of a class.it
contains an individual test, identified by the shortest possible name.render
is a function provided by @testing-library that simulates the rendering function for tests.
When you run this test, it will fail. Why? Our component has a child Switch
, which allows to display different pages depending on our URL. It must be used in a routing context, but in our test, we are rendering App without using the router. To fix this, we need to add App to a test router. However, be careful: we are using a simulated DOM from jsdom, which means that we are not using the browser and therefore some features may be missing (such as the history API). To remedy this, we need to install history:
npm install --save-dev history
Now we can fix the test by adding the History API:
import React from "react"
import { App } from "./App"
import { render } from "@testing-library/react"
//Router
import { createMemoryHistory } from "history"
import { Router } from "react-router-dom"
describe("App", () => {
it("renders successfully", () => {
//Create history
const history = createMemoryHistory()
const { container } = render(
<Router history={history}>
<App/>
</Router>
)
expect(container.innerHTML).toMatch("Goblin Store")
})
it("renders Home component on root route", () => {
//Create history
const history = createMemoryHistory()
history.push("/")
const { container } = render(
<Router history={history}>
<App/>
</Router>
)
expect(container.innerHTML).toMatch("Home")
})
})
-
We create a
history
object and pass it to theRouter
component. -
We call the rendering method to extract the instance from the container. This container represents the container that contains the DOM.
-
We expect that this container contains the text "Goblin Store". Our layout still displays the
header
component that contains this text.
Mocking dependencies.
During the execution of our tests, we saw that the last test did not work. The problem is that our Home
component depends on data from ProductsProvider
to display the list of products (and many other dependencies).
One approach would be to mock the dependencies of this component and to another test the task of testing the features in depth (we call it isolation). Let's mock with jest.mock
which allows us to mock most modules.
import React from "react"
import { App } from "./App"
import { render } from "@testing-library/react"
//Router
import { createMemoryHistory } from "history"
import { Router } from "react-router-dom"
//Mock
jest.mock("./Home", () => ({Home: () => <div>Home</div>}))
We have learned how to mock a module. Our test now passes successfully.
Let's test our routing.
So this may seem trivial, but it's a test that we often forget. Very often, the testing strategy consists of:
- Test the features
- Test the logic
Routing can be part of the future errors that we may encounter. In our case, our application uses the Switch
component to display the routes:
type RenderWithRouter = (
renderComponent: () => React.ReactNode,
route?: string
) => RenderResult & { history: MemoryHistory }
declare global {
namespace NodeJS {
interface Global {
renderWithRouter: RenderWithRouter
}
}
namespace globalThis {
const renderWithRouter: RenderWithRouter
}
}
global.renderWithRouter = (renderComponent, route) => {
const history = createMemoryHistory()
if (route) {
history.push(route)
}
return {
...render(
<Router history={history}>{renderComponent()}</Router>
),
history
}
}
- We create a
history
object and add the route through an argument. - We call the
Render
method of thetesting-library/react
library and return all the fields we have from thehistory
object. - The method is global for the namespace.
- We define a type that includes our function, this will allow us to type it.
- We declare this namespace as a global state, this will allow us to inform NodeJs of the existence of the namespace during imports.
Let's import our helper and define our mocks:
import React from "react"
import { App } from "./App"
import { render } from "@testing-library/react"
//Helper
import "./testHelpers"
//Router
import { createMemoryHistory } from "history"
import { Router } from "react-router-dom"
//Mock
jest.mock("./Home", () => ({Home: () => <div>Home</div>}))
jest.mock("./Cart", () => ({Cart: () => <div>Cart</div>}))
jest.mock("./Checkout", () => ({Checkout: () => <div>Checkout</div>}))
jest.mock("./OrderSummary", () => ({OrderSummary: () => <div>Order Summary</div>}))
We will now implement the test for each of these routes:
it("renders checkout page on '/cart'", () => {
const {
container
} = renderWithRouter(
() => < App / > ,
"/cart"
)
expect(container.innerHTML).toMatch("Cart")
})
it("renders checkout page on '/Checkout'", () => {
const {
container
} = renderWithRouter(
() => < App / > ,
"/checkout"
)
expect(container.innerHTML).toMatch("Checkout")
})
it("renders checkout page on '/order'", () => {
const {
container
} = renderWithRouter(
() => < App / > ,
"/order"
)
expect(container.innerHTML).toMatch("Order Summary")
})
})
Our App.tsx
is tested, before going further we can test the shared components:
Header.spec.tsx
:
import React from "react"
import { Header } from "./Header"
import { fireEvent } from "@testing-library/react"
jest.mock("./CartWidget", () => ({
CartWidget: () => <div>Cart widget</div>
}))
describe("Header", () => {
it("renders correctly", () => {
const { container } = renderWithRouter(() => <Header />)
expect(container.innerHTML).toMatch("Goblin Store")
expect(container.innerHTML).toMatch("Cart widget")
})
it("navigates to / on header title click", () => {
const { getByText, history } = renderWithRouter(() => <Header />)
fireEvent.click(getByText("Goblin Store"))
expect(history.location.pathname).toEqual("/")
})
})
When we click on the element that has the text "Goblin Store", we expect to go back to the root path.
Loader.spec.tsx
:
import React from "react"
import { Loader } from "./Loader"
import { render } from "@testing-library/react"
describe("Loader", () => {
it("renders correctly", () => {
const { container } = render(<Loader />)
expect(container.innerHTML).toMatch("Loading")
})
})
CartWidget.spec.tsx
:
import React from "react"
import { CartWidget } from "./CartWidget"
import { fireEvent } from "@testing-library/react"
describe("CartWidget", () => {
it("shows the amount of products in the cart", () => {
const stubCartHook = () => ({
products: [
{
name: "Product foo",
price: 0,
image: "image.png"
}
],
})
const { container } = renderWithRouter(() => (
<CartWidget useCartHook={stubCartHook} />
))
expect(container.innerHTML).toMatch("1")
})
it("navigates to cart summary page on click", () => {
const { getByRole, history } = renderWithRouter(() => (
<CartWidget />
))
fireEvent.click(getByRole("link"))
expect(history.location.pathname).toEqual("/cart")
})
})
This component allows us to display the number of products in our shopping cart. The component is represented as a link. If we click on it, we are redirected to the summary of our cart.
First, we will create a test to verify that the component returns the number of items in the cart.
Secondly, we will ensure the correct redirection when clicked.
getByRole
: allows us to use the aria-role
attribute to search for an element. Here, we are looking for the aria-role
of the element. We can then test the click interaction to test our redirection.
We pass the (mocked) data via the component prop to test the reaction.
Well, we have tested all our Shared components!
Home page
Open the Home
folder to see the 3 files:
index.tsx
: allows to give visibility to the module. It exports the Home
component and not Product.tsx
.
Home.tsx
: receives data via props, which we will not mock (I made it easy). Our component is divided into 3 phases: the loader is launched when we load the data, if we have an error we display the error message, we display the list if we have the data.
We can write the test:
jest.mock("./ProductCard", () => ({
ProductCard: ({ datum }: ProductCardProps) => {
const { name, price, image } = datum
return (
<div>
{name} {price} {image}
</div>
)
}
}))
describe("Home", () => {
describe("while loading", () => {
it("renders loader", () => {
const mockUseProducts = () => ({
categories: [],
isLoading: true,
error: false
})
const { container } = render(
<Home useProductsHook={mockUseProducts} />
)
expect(container.innerHTML).toMatch("Loading")
})
})
describe("with data", () => {
const category: Category = {
name: "Category Foo",
items: [
{
name: "Product foo",
price: 55,
image: "/test.jpg"
}
]
}
it("renders categories with products", () => {
const mockUseProducts = () => ({
categories: [category],
isLoading: false,
error: false
})
const { container } = render(
<Home useProductsHook={mockUseProducts} />
)
expect(container.innerHTML).toMatch("Category Foo")
expect(container.innerHTML).toMatch(
"Product foo 55 /test.jpg"
)
})
})
describe("with error", () => {
it("renders error message", () => {
const mockUseProducts = () => ({
categories: [],
isLoading: false,
error: true
})
const { container } = render(
<Home useProductsHook={mockUseProducts} />
)
expect(container.innerHTML).toMatch("Error")
})
})
})
We mocked the ProductCard
component to avoid adding a dependency to the test. We wrote the following tests:
- We use mocks about products to display different states.
- If we have no data and
isLoading
is true, then the loader should be displayed. - If we have no data but we have an error, then we expect an error.
- If we have data, then we display the products.
In ProductCard.tsx
:
- The image should have the correct
alt
andsrc
. - The price.
- The product name.
We need to add the "+ Add to cart" button which can have two states:
- Disabled if the product is added with the text "Added to cart".
- Enabled if it is not added with the text "Add to cart".
We will create the corresponding test.
import React from "react"
import { render, fireEvent } from "@testing-library/react"
import { ProductCard } from "./ProductCard"
import { Product } from "../shared/types"
describe("ProductCard", () => {
const product: Product = {
name: "Product foo",
price: 55,
image: "/test.jpg"
}
it("renders correctly", () => {
const { container, getByRole } = render(
<ProductCard datum={product} />
)
expect(container.innerHTML).toMatch("Product foo")
expect(container.innerHTML).toMatch("55 Zm")
expect(getByRole("img")).toHaveAttribute(
"src",
"/test.jpg"
)
})
describe("when product is in the cart", () => {
it("the 'Add to cart' button is disabled", () => {
const mockUseCartHook = () => ({
addToCart: () => {},
products: [product]
})
const { getByRole } = render(
<ProductCard
datum={product}
useCartHook={mockUseCartHook as any}
/>
)
expect(getByRole("button")).toBeDisabled()
})
})
describe("when product is not in the cart", () => {
describe("on 'Add to cart' click", () => {
it("calls 'addToCart' function", () => {
const addToCart = jest.fn()
const mockUseCartHook = () => ({
addToCart,
products: []
})
const { getByText } = render(
<ProductCard
datum={product}
useCartHook={mockUseCartHook}
/>
)
fireEvent.click(getByText("Add to cart"))
expect(addToCart).toHaveBeenCalledWith(product)
})
})
})
})
-
We mocked our product.
-
We tested that the information is correctly added.
-
We tested the button states.
jest.fn()
allows to mock the function. Here, we want to make sure the function is called.
The logic we have set up for this page is often the same for others. So I leave this as an exercise for you and allow you to check your results with git.
React Hooks
To test React hooks, we need to install:
npm install --save-dev @testing-library/react-hooks
The hooks in the Home
component allow us to load the data via the useProducts
hook:
- It retrieves the data on mount.
- While the data is loading, we set
isLoading
totrue
. - If we have an error, we set
isLoading
tofalse
. - When the data is returned, we set
isLoading
tofalse
and store the data in the state.
Here is the testing strategy:
import { renderHook } from "@testing-library/react-hooks"
import { useProducts } from "./useProducts"
describe("useProducts", () => {
it.todo("fetches products on mount")
describe("while waiting API response", () => {
it.todo("returns correct loading state data")
})
describe("with error response", () => {
it.todo("returns error state data")
})
describe("with successful response", () => {
it.todo("returns successful state data")
})
})
Make sure the data is loaded on mount
it("fetches products on mount", async () => {
const mockApiGetProducts = jest.fn()
<span style="color:rgb(86, 156, 214); font-weight:400;">await</span> <span class="hljs-title function_">act</span>(<span style="color:rgb(86, 156, 214); font-weight:400;">async</span> () => {
<span class="hljs-title function_">renderHook</span>(<span style="color:rgb(220, 220, 220); font-weight:400;">() =></span> <span class="hljs-title function_">useProducts</span>(mockApiGetProducts))
})
<span class="hljs-title function_">expect</span>(mockApiGetProducts).<span class="hljs-title function_">toHaveBeenCalled</span>()
})
renderHook
: this is a method that allows us to use our hook and mock the results through mockApiGetProducts
.
Test the state when the data is returned.
describe("while waiting API response", () => {
it("returns correct loading state data", () => {
const mockApiGetProducts = jest.fn(
() => new Promise(() => {})
)
const { result } = renderHook(() =>
useProducts(mockApiGetProducts)
)
expect(result.current.isLoading).toEqual(true)
expect(result.current.error).toEqual(false)
expect(result.current.categories).toEqual([])
})
})
Here, we mock the backend call to ensure that in case of success, the data is correctly mocked. This will allow us for the next test to test the error case.
Test the state when the call creates an error.
describe("with error response", () => {
it("returns error state data", async () => {
const mockApiGetProducts = jest.fn(
() =>
new Promise((resolve, reject) => {
reject("Error")
})
)
const { result, waitForNextUpdate } = renderHook(() =>
useProducts(mockApiGetProducts)
)
await act(() => waitForNextUpdate())
expect(result.current.isLoading).toEqual(false)
expect(result.current.error).toEqual("Error")
expect(result.current.categories).toEqual([])
})
})
Here, by simulating a promise failure, we can easily generate an error.
Returns mocked results.
describe("with successful response", () => {
it("returns successful state data", async () => {
const mockApiGetProducts = jest.fn(
() =>
new Promise((resolve, reject) => {
resolve({
categories: [{ name: "Category", items: [] }]
})
})
)
const { result, waitForNextUpdate } = renderHook(() =>
useProducts(mockApiGetProducts)
)
await act(() => waitForNextUpdate())
expect(result.current.isLoading).toEqual(false)
expect(result.current.error).toEqual(false)
expect(result.current.categories).toEqual([
{
name: "Category",
items: []
}
])
})
})
The use of await act(() => waitForNextUpdate())
allows us to wait for the next update before running our test. This is necessary because the data is sent via an asynchronous function and it is therefore important to wait for the process to complete.
Then, we display the results through the display function.
We have covered several concepts in this tutorial. I invite you to finish testing the application and then compare your results!