Jest Mocking — Part 4: React Component

In this article series, we will take a look at how to mock with Jest.

Enes Başpınar
Trendyol Tech
10 min readJan 17, 2023

--

Jest Mocking — Part 1: Function
Jest Mocking — Part 2: Module
Jest Mocking — Part 3: Timer
Jest Mocking — Part 4: React Component

You can find the codes in the article on Github.

Source

In our mock adventure, we’ve reached React. Most of us are using the React framework and testing its components. So, how does mocking work with React components?

Introduction

When frameworks come into play, special types of functions called component enter our lives and its main purpose is to produce DOM elements and render them to the DOM tree.

Let’s say we have a Registration component that manages the content of the form based on whether the user is registered or not.

// File: Registration.tsx
import React from "react";
import LoginForm from "./LoginForm";
import RegisterForm from "./RegisterForm";

const Registration: React.FC<RegistrationProps> = ({ isRegistered }) => {
return isRegistered ? <LoginForm /> : <RegisterForm />;
};

export interface RegistrationProps {
isRegistered: boolean;
}

export default Registration;
// File: LoginForm.tsx
import React, { useState } from "react";

const LoginForm = () => {
const [formData, setFormData] = useState({
email: "",
password: "",
});

// ...

return (
<form onSubmit={handleLoginSubmit}>
...
</form>
);
};

export default LoginForm;
// File: RegisterForm.tsx
import React, { useState } from "react";

const RegisterForm = () => {
const [formData, setFormData] = useState({
name: "",
email: "",
password: "",
});

// ...

return (
<form onSubmit={handleRegisterSubmit}>
...
</form>
);
};

export default RegisterForm;

As we can see, components are used nested and in the entry file of the application they are rendered to the DOM tree using a specific method.

// File: App.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";

const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

When the file is run in the browser, the DOM tree is accessible. However, we are doing our tests in a NodeJS environment and there is no DOM tree. Special test libraries create a mock DOM tree to make it possible to test components. In this article, we will use Jest along with React Testing Library, the most popular testing library for React.

By using the methods from previous articles, we can manipulate the content that the component will return to the DOM. We usually return a div and specify what it is with the data-testid attribute.

// File: Registration.test.tsx
import { render } from "@testing-library/react";
import Registration from "./Registration";

jest.mock("./RegisterForm", () => {
return {
default: jest.fn().mockReturnValue(<div data-testid="register-form" />),
};
});

test("playground", () => {
const { debug } = render(<Registration isRegistered={false} />);
console.log("DOM:");
// prints the HTML of the fake DOM tree at that stage of the test
debug();
});

/* OUTPUT:
DOM:
<body>
<div>
<div data-testid="register-form" />
</div>
</body>
*/

render method returns an object that contains methods for DOM tree. We can use the debug method to see if the mock content is successfully rendered. When we look at the output, we can see that the desired value has been rendered.

Mocking Component

We previously said that the principle of unit test is that the functions other than the function in focus should not be run. In React, we want to avoid rendering the child components.

When testing React components, we should focus on what the specific responsibility of the component is and avoid testing aspects that are not its direct responsibility. In the case of the Registration component, its responsibility is to decide which form to display based on the value of the isRegistered props, and we should not test the details of the input fields or the submission process. These should be tested in the component that renders the form, and we can check that the correct form is rendered by checking the value of the data-testid attribute.

Let’s write its tests.

// File: Registration.test.tsx
import { render, screen } from "@testing-library/react";
import LoginForm from "./LoginForm";
import RegisterForm from "./RegisterForm";
import Registration from "./Registration";

jest.mock("./LoginForm", () => {
return {
default: jest.fn().mockReturnValue(<div data-testid="login-form" />),
};
});

jest.mock("./RegisterForm", () => {
return {
default: jest.fn().mockReturnValue(<div data-testid="register-form" />),
};
});

describe("<Registration /> tests", () => {
afterEach(() => {
jest.clearAllMocks();
});

it("should render the register form when isRegistered is false", () => {
render(<Registration isRegistered={false} />);

expect(RegisterForm).toHaveBeenCalled();
expect(LoginForm).not.toHaveBeenCalled();
expect(screen.getByTestId("register-form")).toBeInTheDocument();
});

it("should render the login form when isRegistered is true", () => {
render(<Registration isRegistered={true} />);

expect(LoginForm).toHaveBeenCalled();
expect(RegisterForm).not.toHaveBeenCalled();
expect(screen.getByTestId("login-form")).toBeInTheDocument();
});
});

/* OUTPUT:
PASS Registration.test.tsx
<Registration /> tests
✓ should render the register form when isRegistered is false (23 ms)
✓ should render the login form when isRegistered is true (2 ms)
*/

By the way, we also know that we use jest.spyOn() while mocking.

// File: Registration.test.tsx
import * as LoginFormModule from "./LoginForm";
import * as RegisterFormModule from "./RegisterForm";

jest.spyOn(RegisterFormModule, "default").mockReturnValue(
<div data-testid="register-form" />
);

jest.spyOn(LoginFormModule, "default").mockReturnValue(
<div data-testid="login-form" />
);

Passing Props To Mock Component

We can dynamically change the value returned by the mock component using props. Let’s say we have a TodoList component.

// File: TodoList.tsx
const TodoList: React.FC<TodoListProps> = ({ todos }) => {
return (
<div>
<p>Todos:</p>
{todos.map((todo) => (
<TodoItem
title={todo.title}
isCompleted={todo.isCompleted}
/>
))}
</div>
);
};
// File: TodoItem.tsx
const TodoItem: React.FC<TodoItemProps> = ({ title, isCompleted }) => {
if (isCompleted) {
return null;
}

return <div>{title}</div>;
};

Let’s write the test that the completed items are not displayed on the screen.

// File: TodoList.test.tsx
import { render, screen } from "@testing-library/react";
import TodoList from "./TodoList";

jest.mock("./TodoItem", () => jest.fn().mockReturnValue(
<div data-testid="todo-item" />
));

describe("<TodoList> tests", () => {
test("should render only incomplete todo items", () => {
jest.spyOn(TodoItemModule, "default").mockReturnValue(
<div data-testid="todo-item" />
);

const mockTodos = [
{
title: "Complete the fatality task.",
isCompleted: false,
},
{
title: "Pretend to exercise.",
isCompleted: false,
},
{
title: "Complete all side quests of the Witcher.",
isCompleted: true,
},
{
title: "Kill time on social media.",
isCompleted: true,
},
];

const { debug } = render(<TodoList todos={mockTodos} />);

console.log("DOM:");
debug();

expect(screen.getAllByTestId("todo-item")).toHaveLength(2);
expect(screen.getByText("Complete the fatality task.")).toBeInTheDocument();
expect(screen.getByText("Pretend to exercise.")).toBeInTheDocument();
});
});

/* OUTPUT:
DOM:
<body>
<div>
<div>
<p>Todos:</p>
<div data-testid="todo-item" />
<div data-testid="todo-item" />
<div data-testid="todo-item" />
<div data-testid="todo-item" />
</div>
</div>
</body>

FAIL TodoList.test.tsx
<TodoList> tests
✕ should render only incomplete todo items (83 ms)
Expected length: 2
Received length: 4
*/

The component has been rendered to the DOM, but it seems like we have a problem. We can’t understand which items are being displayed. When we mock the component, rendering condition is also lost. We can manipulate the value returned by the mock function based on the isCompleted props.

So our new spyOn callback would be as follows.

jest.spyOn(TodoItemModule, "default")
.mockImplementation(({ isCompleted, title }: TodoItemProps) => {
return !isCompleted ? <div data-testid="todo-item">{title}</div> : null;
});

Mocking Child Components that Manipulate States

Sometimes we want the state of a component to be updated by its child components, causing it to re-render. For example, imagine when a button is clicked, a list of people is fetched and displayed in the parent component.

// File: PersonList.tsx
const PersonList: React.FC = () => {
const [persons, setPersons] = useState<any>([]);

return (
<div>
<FetchButton setPersons={setPersons} />
{persons.map((person: any) => (
<Person
key={person.email}
firstName={person.firstName}
lastName={person.lastName}
email={person.email}
/>
))}
</div>
);
};
// File: Person.tsx
const Person: React.FC<PersonProps> = ({ firstName, lastName, email }) => {
return (
<div>
<p>{firstName}</p>
<p>{lastName}</p>
<p>{email}</p>
</div>
);
};
// File: FetchButton.test.tsx
const FetchButton: React.FC<FetchButtonProps> = ({ setPersons }) => {
const fetchPersons = async () => {
const response = await fetch("https://dummyjson.com/users?limit=5");
const result = await response.json();
setPersons(result.users);
};

return <button onClick={fetchPersons}>Kullanıcı Listesini Getir</button>;
};

When we mock the FetchButton component, the click event is no longer being listened to and we can no longer test it. We will manually call the onClick event by defining it on a mock div element.

// File: PersonList.test.tsx
import { fireEvent, render, screen } from "@testing-library/react";
import { PersonProps } from "./Person";
import * as FetchButtonModule from "./FetchButton";
import PersonList from "./PersonList";

jest.mock("./Person", () =>
jest.fn(({ firstName, lastName, email }: PersonProps) => (
<div data-testid="person">
{firstName} - {lastName} - {email}
</div>
))
);

describe("<PersonList /> tests", () => {
test("should get persons when click fetch button", () => {
const mockPersons = [
{
firstName: "Terry",
lastName: "Medhurst",
email: "atuny0@sohu.com",
},
{
firstName: "Sheldon",
lastName: "Quigley",
email: "hbingley1@plala.or.jp",
},
];

jest.spyOn(FetchButtonModule, "default")
.mockImplementation(({ setPersons }: FetchButtonModule.FetchButtonProps) => (
<button
data-testid="fetch-button"
onClick={() => setPersons(mockPersons)}
/>
));

render(<PersonList />);

expect(screen.queryAllByTestId("person")).toHaveLength(0);

const fetchButton = screen.getByTestId("fetch-button");
fireEvent.click(fetchButton);

expect(screen.getAllByTestId("person")).toHaveLength(2);

screen.getAllByTestId("person").forEach((personEl, index) => {
const { firstName, lastName, email } = mockPersons[index];

expect(personEl).toHaveTextContent(
`${firstName} - ${lastName} - ${email}`
);
});
});
});

PersonList component’s responsibility is to render people by state value. So it doesn’t care where and how pulls.

Mocking Library Components

As the content of the pages becomes more complex, the loading time increases. In such cases, we use lazy loading to prevent the component from being rendered until it is close to being visible on the screen.

The gallery is an example of this situation.

// File: Photos.tsx
const Photos: React.FC = () => {
const [photos, setPhotos] = useState([]);

useEffect(() => {
axios.get("https://jsonplaceholder.typicode.com/photos").then((result: any) => {
setPhotos(result);
});
}, []);

return (
<div>
{photos.map((photo: any) => (
<LazyLoad
key={photo.id}
height={600}
>
<img
data-testid="photo"
alt={photo.title}
src={photo.url}
/>
</LazyLoad>
))}
</div>
);
};

In the NodeJS environment where we perform tests, there is no screen and components will never be rendered, and we cannot perform tests as we expect. Let’s write the test with what we know.

// File: Photos.test.tsx
import { render, screen } from "@testing-library/react";
import axios from "axios";
import * as ReactLazyLoadModule from "react-lazyload";
import Photos from "./Photos";

describe("<Photos /> tests", () => {
let mockPhotos: Array<{
id: number;
title: string;
url: string;
}>;

beforeAll(() => {
mockPhotos = [
{
id: 1,
title: "accusamus beatae ad facilis cum similique qui sunt",
url: "https://via.placeholder.com/600/92c952",
},
{
id: 2,
title: "reprehenderit est deserunt velit ipsam",
url: "https://via.placeholder.com/600/771796",
},
{
id: 3,
title: "officia porro iure quia iusto qui ipsa ut modi",
url: "https://via.placeholder.com/600/24f355",
},
];

jest.spyOn(axios, "get").mockImplementation((url: string): any => {
if (url === "https://jsonplaceholder.typicode.com/photos") {
return Promise.resolve(mockPhotos);
}

return Promise.reject();
});
});

test("should render greeting text without name", async () => {
const { debug } = render(<Photos />);

// rerender triggered after data is fetched, but during testing
// we need to wait for it to render. therefore, we wait for
// the element with 'photo' testid to be rendered.
const photoElements = await screen.findAllByTestId("photo");
debug();

photoElements.forEach((photoEl, index) => {
expect(photoEl).toHaveAttribute("alt", mockPhotos[index].title);
expect(photoEl).toHaveAttribute("src", mockPhotos[index].url);
});
});
});

/* OUTPUT:
<body>
<div>
<div>
<div class="lazyload-wrapper">
<div
class="lazyload-placeholder"
style="height: 600px;"
/>
</div>
<div class="lazyload-wrapper">
<div
class="lazyload-placeholder"
style="height: 600px;"
/>
</div>
<div class="lazyload-wrapper">
<div
class="lazyload-placeholder"
style="height: 600px;"
/>
</div>
</div>
</div>
</body>

FAIL src/Photos.test.tsx
<Photos /> tests
✕ should render greeting text without name (1083 ms)
wUnable to find an element by: [data-testid="photo"]
*/

Instead of seeing an img element, we encounter a placeholder. Since there is no intersection observer in NodeJS, the image element will never be rendered to the DOM.

As a solution, let’s mock the LazyLoad component. So we can deal with its content.

jest.spyOn(ReactLazyLoadModule, "default")
.mockImplementation(({ children }: any) => children);
/* OUTPUT:
<body>
<div>
<div>
<img
alt="accusamus beatae ad facilis cum similique qui sunt"
data-testid="photo"
src="https://via.placeholder.com/600/92c952"
/>
<img
alt="reprehenderit est deserunt velit ipsam"
data-testid="photo"
src="https://via.placeholder.com/600/771796"
/>
<img
alt="officia porro iure quia iusto qui ipsa ut modi"
data-testid="photo"
src="https://via.placeholder.com/600/24f355"
/>
</div>
</div>
</body>

PASS src/components/lazyload/Photos.test.tsx
<Photos /> tests
✓ should render greeting text without name (55 ms)
*/

Practice

As we come to the end of the article, let’s finish with an example that combines all the topics in the series and will be a challenge for you. You can find the code for the tiny React project below. You can benefit from the output when extracting test cases.

You can find the completed tests at Github repo, but I recommend trying to write yourself. The times when I learn a lot are often the times I find it most difficult.

// File: RemainingTimer.tsx
import React, { useEffect, useState } from "react";
import TimeBox from "./TimeBox";
import { calculateDeltaBetweenDates, convertDeltaToDaysHoursMinutes } from "./utils/date";

const timerEndDate = new Date(2023, 5, 18, 17);

const RemainingTimer: React.FC = () => {
const [
remainingTime,
setRemainingTime
] = useState(calculateDeltaBetweenDates(timerEndDate));
let remainingTimerInterval: any;

useEffect(() => {
remainingTimerInterval = setInterval(() => {
setRemainingTime(calculateDeltaBetweenDates(timerEndDate));
}, 1000);

return () => {
clearInterval(remainingTimerInterval);
};
}, []);

if (remainingTime === 0) {
clearInterval(remainingTimerInterval);

return (
<div data-testid="timer-ended-text">Timer finished!</div>
);
}

const { remainingDays, remainingHours, remainingMinutes, remainingSeconds } =
convertDeltaToDaysHoursMinutes(remainingTime);

return (
<div data-testid="time-box-container">
<TimeBox type="day" value={remainingDays} />
<TimeBox type="hour" value={remainingHours} />
<TimeBox type="min" value={remainingMinutes} />
<TimeBox type="sec" value={remainingSeconds} />
</div>
);
};

export default RemainingTimer;
// File: TimeBox.tsx
import React from "react";

const TimeBox: React.FC<TimeBoxProps> = ({ type, value }) => {
const helperTextsByType = {
day: "day",
hour: "hour",
min: "minute",
sec: "second",
};

return (
<div className="time-box">
<p>{value}</p>
<p>{helperTextsByType[type]}</p>
</div>
);
};

export interface TimeBoxProps {
value: number;
type: "day" | "hour" | "min" | "sec";
}

export default TimeBox;
// File: utils/date.ts
function calculateDeltaBetweenDates(futureDate: Date, currDate: Date = new Date()) {
let delta = futureDate.valueOf() - currDate.valueOf();

if (delta < 0) {
delta = 0;
}

return delta;
}

function convertDeltaToDaysHoursMinutes(delta: number) {
const deltaAsSeconds = Math.floor(delta / 1000);

return {
remainingDays: Math.floor(deltaAsSeconds / (60 * 60 * 24)),
remainingHours: Math.floor((deltaAsSeconds / (60 * 60)) % 24),
remainingMinutes: Math.floor((deltaAsSeconds / 60) % 60),
remainingSeconds: Math.floor(deltaAsSeconds % 60),
};
}

export { calculateDeltaBetweenDates, convertDeltaToDaysHoursMinutes };

The series was completed with this article where we mentioned React components. I would be happy and motivated to hear your feedback on the series.

Resources

--

--