[React] Don’t give up on testing when using Material UI with React

Jay Kim
13 min readSep 28, 2023

Background

I would like to begin by expressing how fortunate it is to collaborate with a designer. At Tanzu Labs we work within a “balanced” team, which includes product managers, product designers and engineers. Therefore, I don’t need to worry too much about the user experience or how individual UI component should look like. While I can still share feedback if I come across anything odd, designers do their best job to create visually appealing designs.

However, I recently found myself involved in a project without a product designer. Initially, I had some concerns about how to approach the design and how to determine the appearance of each component.

Introduction to Material UI

I’ve heard of Material UI for a few years now but haven’t had the opportunity to use it in a real project. The visual design is nice since it offers production-ready components. However, my next concern was whether I can write tests when working with Material UI.

“Testability determines the success of a project”

In this article, let’s explore some of the Material UI components that I ended up using the most in my last project. For each component, let’s examine how we can write test code both with and without Material UI and examine necessary modifications to the test code.

Setting up Material UI

Add new dependencies to your React project. More details on installation can be found on the official document here.

npm install @mui/material @emotion/react @emotion/styled

Let’s move on to some of the components I used the most in this project.

Components

1. TextField

Implementation (Default)

Imagine a very simple text input with label.

const TextInput = () => {
const [value, setValue] = useState("");

return (
<div>
<h3>Default</h3>
<label htmlFor="name-input">Name</label>
<input
data-testid="name-input"
id="name-input"
type="text"
placeholder="Enter name"
value={value}
onChange={(event) => {
setValue(event.target.value);
}}
/>
</div>
);
};

export default TextInput;

This component looks like this:

TextInput component

Test (Default)

This component can be tested in various ways. It seems like different people have different preferences on how to query an element so feel free to use whichever one you like the most.

import { render, screen } from "@testing-library/react";
import TextInput from "./TextInput";
import userEvent from "@testing-library/user-event";

describe("TextInput Tests", () => {
it("test using placeholder text", async () => {
render(<TextInput />);

const input = await screen.findByPlaceholderText("Enter name");
await userEvent.type(input, "Jay");
expect(input).toHaveValue("Jay");
});

it("test using label", async () => {
render(<TextInput />);

const input = await screen.findByLabelText("Name");
await userEvent.type(input, "Jay");
expect(input).toHaveValue("Jay");
});

it("test using test id", async () => {
render(<TextInput />);

const input = await screen.findByTestId("name-input");
await userEvent.type(input, "Jay");
expect(input).toHaveValue("Jay");
});
});

Implementation (with Material UI)

Now, let’s convert using Material UI TextField. There are a few differences in terms of implementation. The label is one of the props inside TextField component. Other than that, if you want to write a test using data-testid, it must be passed in as inputProps prop.

import { TextField } from "@mui/material";
import { useState } from "react";

const MuiTextInput = () => {
const [value, setValue] = useState("");

return (
<div>
<h3>mui TextField</h3>
<TextField
inputProps={{ "data-testid": "name-input" }}
label="Name"
variant="outlined"
placeholder="Enter name"
value={value}
onChange={(event) => {
setValue(event.target.value);
}}
/>
</div>
);
};

export default MuiTextInput;
Label is displayed inside the input
Label moves up and placeholder text appears when focused

Test (with Material UI)

Nothing changes in the test code. Here is the full test code just for reference.

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import MuiTextInput from "./MuiTextInput";

describe("TextInput Tests", () => {
it("test using placeholder text", async () => {
render(<MuiTextInput />);

const input = await screen.findByPlaceholderText("Enter name");
await userEvent.type(input, "Jay");
expect(input).toHaveValue("Jay");
});

it("test using label", async () => {
render(<MuiTextInput />);

const input = await screen.findByLabelText("Name");
await userEvent.type(input, "Jay");
expect(input).toHaveValue("Jay");
});

it("test using test id", async () => {
render(<MuiTextInput />);

const input = await screen.findByTestId("name-input");
await userEvent.type(input, "Jay");
expect(input).toHaveValue("Jay");
});
});

2. TextField as Textarea

Implementation

Normally, <input /> is used for text input and <textarea /> is used for multiline input. Luckily, TextField component can turn into a textarea with very little efforts. In addition to that, the test code does not need any modifications.

The only change required is to add multiline prop. minRows can be added if you want to make the textarea take up more space by default.

import { TextField } from "@mui/material";
import { useState } from "react";

const MuiTextArea = () => {
const [value, setValue] = useState("");

return (
<div>
<h3>mui TextField as text area</h3>
<TextField
multiline={true}
minRows={3}
inputProps={{ "data-testid": "name-input" }}
label="Name"
variant="outlined"
placeholder="Enter name"
value={value}
onChange={(event) => {
setValue(event.target.value);
}}
/>
</div>
);
};

export default MuiTextArea;

Label and placeholder text behaviors are same as TextField.

focused

Test

There is no different from the TextField test code. Just for reference, test code is as follows:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import MuiTextArea from "./MuiTextArea";

describe("Textarea Tests", () => {
it("test using placeholder text", async () => {
render(<MuiTextArea />);

const input = await screen.findByPlaceholderText("Enter name");
await userEvent.type(input, "Jay");
expect(input).toHaveValue("Jay");
});

it("test using label", async () => {
render(<MuiTextArea />);

const input = await screen.findByLabelText("Name");
await userEvent.type(input, "Jay");
expect(input).toHaveValue("Jay");
});

it("test using test id", async () => {
render(<MuiTextArea />);

const input = await screen.findByTestId("name-input");
await userEvent.type(input, "Jay");
expect(input).toHaveValue("Jay");
});
});

3. Select with InputLabel

Implementation (Default)

Let’s create a drow-down list of members. The assumption is that I want to get the id of the selected member.

import { useState } from "react";

const MemberDropdown = () => {
const members = [
{ id: 1, name: "Jay" },
{ id: 2, name: "Su" },
];

const [name, setName] = useState("");

return (
<div>
<h3>Default</h3>
<label htmlFor="name-select">Select Member</label>
<select
id="name-select"
value={name}
onChange={(e) => {
setName(e.target.value);
}}
>
<option value=""></option>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</div>
);
};

export default MemberDropdown;

This component looks like this:

MemberDropdown component
When selecting a value

We can write tests using ARIA roles. MDN mentions that <select /> has the following ARIA role and in this case, it will be combobox and <option /> has option role.

MDN Technical Summary for Select element

Test (Default)

Let’s look at the test code.

import { render, screen } from "@testing-library/react";
import MemberDropdown from "./MemberDropdown";
import userEvent from "@testing-library/user-event";

describe("MemberDropdown Test", () => {
it("should display label", async () => {
render(<MemberDropdown />);

expect(await screen.findByLabelText("Select Member")).toBeInTheDocument();
});

it("should display dropdown", async () => {
render(<MemberDropdown />);

expect(await screen.findByRole("combobox")).toBeInTheDocument();
});

it("should display options", async () => {
render(<MemberDropdown />);

expect(await screen.findByRole("option", { name: "" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Jay" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Su" })).toBeInTheDocument();
});

it("should display selected value", async () => {
render(<MemberDropdown />);

const dropdown = await screen.findByRole("combobox");
expect(dropdown).toHaveValue("");
await userEvent.selectOptions(dropdown, "Jay");
expect(dropdown).toHaveValue("1");
});
});

Implementation (with Material UI)

Let’s convert to Material UI by using InputLabel and Select components.

import { FormControl, InputLabel, MenuItem, Select } from "@mui/material";
import { useState } from "react";

const MuiMemberDropdown = () => {
const members = [
{ id: 1, name: "Jay" },
{ id: 2, name: "Su" },
];

const [name, setName] = useState("");

return (
<div>
<h3>mui Select</h3>
<FormControl
fullWidth={true}
sx={{
width: "200px",
}}
>
<InputLabel id="name-select-label">Select Member</InputLabel>
<Select
data-testid="name-select"
labelId="name-select-label"
id="name-select"
label="Select Member"
value={name}
onChange={(e) => {
setName(e.target.value);
}}
>
{members.map((m) => {
return (
<MenuItem key={m.id} value={m.id}>
{m.name}
</MenuItem>
);
})}
</Select>
</FormControl>
</div>
);
};

export default MuiMemberDropdown;

The result of this looks like this when rendered:

MuiMemberDropdown component
When selecting a value

Test (with Material UI)

When we run the same test code as before, basically all tests fail...

We have to make some modifications to test code in order to test similar behaviors as before. Notice how the combobox role is no longer applicable. Interestingly, the dropdown has a button role.

Also, test id is used in the test code. While this might not be a preferred solution, this is used in case a page might contain multiple dropdowns. Then, test id can be used to select a specific dropdown. I wonder if there is a better way to target a specific dropdown. Let me know if you know the answers to this.

import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import MuiMemberDropdown from "./MuiMemberDropdown";

describe("MuiMemberDropdown Test", () => {
it("should display label", async () => {
render(<MuiMemberDropdown />);

expect(await screen.findByLabelText("Select Member")).toBeInTheDocument();
});

it("should display dropdown", async () => {
render(<MuiMemberDropdown />);

expect(
within(await screen.findByTestId("name-select")).getByRole("button"),
).toBeInTheDocument();
});

it("should display options", async () => {
render(<MuiMemberDropdown />);

const dropdown = within(await screen.findByTestId("name-select")).getByRole(
"button",
);
await userEvent.click(dropdown);
expect(
await screen.findByRole("option", { name: "Jay" }),
).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Su" })).toBeInTheDocument();
});

it("should display selected value", async () => {
render(<MuiMemberDropdown />);

const dropdown = within(await screen.findByTestId("name-select")).getByRole(
"button",
);
await userEvent.click(dropdown);
await userEvent.click(await screen.findByRole("option", { name: "Jay" }));
expect(screen.getByText("Jay")).toBeInTheDocument();
});
});

4. Button

Implementation (Default)

Buttons are one of the easier elements when applying Material UI. Let’s start with a simple button.

const SimpleButton = ({ onClick }) => {
return <button onClick={onClick}>Click Here</button>;
};

export default SimpleButton;
SimpleButton component

Test (Default)

The test code is pretty straight forward using ARIA roles again.

import { render, screen, waitFor } from "@testing-library/react";
import SimpleButton from "./SimpleButton";
import userEvent from "@testing-library/user-event";

describe("SimpleButton Tests", () => {
it("should display button with text", async () => {
render(<SimpleButton />);

expect(
await screen.findByRole("button", { name: "Click Here" }),
).toBeInTheDocument();
});

it("should execute click handler function when button is clicked", async () => {
const mockOnClickHandler = jest.fn();
render(<SimpleButton onClick={mockOnClickHandler} />);

const button = await screen.findByRole("button", { name: "Click Here" });
await userEvent.click(button);
await waitFor(() => {
expect(mockOnClickHandler).toHaveBeenCalled();
});
// a better test is to assert something happens on screen after the button click
});
});

Implementation (with Material UI)

Let’s convert to Material UI Button.

import { Button } from "@mui/material";

const MuiSimpleButton = ({ onClick }) => {
return (
<Button variant="outlined" color="success" onClick={onClick}>
Click Here
</Button>
);
};

export default MuiSimpleButton;

This looks like

MuiSimpleButton component

Test (with Material UI)

Same test code can be used as before. Just change the component being rendered in each test. Here is the test code just for reference.

import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import MuiSimpleButton from "./MuiSimpleButton";

describe("MuiSimpleButton Tests", () => {
it("should display button with text", async () => {
render(<MuiSimpleButton />);

expect(
await screen.findByRole("button", { name: "Click Here" }),
).toBeInTheDocument();
});

it("should execute click handler function when button is clicked", async () => {
const mockOnClickHandler = jest.fn();
render(<MuiSimpleButton onClick={mockOnClickHandler} />);

const button = await screen.findByRole("button", { name: "Click Here" });
await userEvent.click(button);
await waitFor(() => {
expect(mockOnClickHandler).toHaveBeenCalled();
});
// a better test is to assert something happens on screen after the button click
});
});

5. Snackbar & Alert

Implementation

Snackbar can be used to give feedback without interfering them too much from using the application. For this section, I will cover how to write tests. The snackbar I am going to implement will show some text to the user and auto hide after 5 seconds.

import { useState } from "react";
import { Button, Snackbar } from "@mui/material";

const MuiSnackbar = () => {
const [showSnackbar, setShowSnackbar] = useState(false);

const handleShowSnackbar = () => {
setShowSnackbar(true);
};

const handleCloseSnackbar = () => {
setShowSnackbar(false);
};

return (
<div>
<Button variant="outlined" onClick={handleShowSnackbar}>
Show Snackbar
</Button>
{showSnackbar && (
<Snackbar
open={showSnackbar}
anchorOrigin={{ vertical: "top", horizontal: "center" }}
autoHideDuration={5000}
onClose={handleCloseSnackbar}
message="Hello there"
/>
)}
</div>
);
};

export default MuiSnackbar;

When clicking Show Snackbar button, the snackbar will display and hide after some time.

MuiSnackbar component

Test

Let’s take a look at how to write test for this. Snackbar has ARIA role of alert. Notice how jest fake timer is used to test auto hide feature of the snackbar.

import { act, render, screen, within } from "@testing-library/react";
import MuiSnackbar from "./MuiSnackbar";
import userEvent from "@testing-library/user-event";

describe("MuiSnackbar Tests", () => {
afterEach(() => {
jest.useRealTimers();
});

it("should render snackbar when button is clicked", async () => {
render(<MuiSnackbar />);

const button = await screen.findByRole("button", { name: "Show Snackbar" });
await userEvent.click(button);

const snackbar = within(await screen.findByRole("alert"));
expect(snackbar.getByText("Hello there")).toBeInTheDocument();
});

it("should auto hide snackbar after 5s", async () => {
jest.useFakeTimers();
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });

render(<MuiSnackbar />);
const button = await screen.findByRole("button", { name: "Show Snackbar" });
await user.click(button);
expect(await screen.findByRole("alert")).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(5000);
});

expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});

The Snackbar component can be futher customized when used with Alert component. Refer to this link for more information.

6. Dialogs

Implementation

Next up is dialogs. Let’s create a modal which opens when button is clicked. For dialogs, I will explain how to go on about writing test code. Luckily, the test code will very similar if you were to implement a dialog by yourself.

import { useState } from "react";
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
} from "@mui/material";

export const MuiModal = () => {
const [showModal, setShowModal] = useState(false);

const handleShowModal = () => {
setShowModal(true);
};

const handleCloseModal = () => {
setShowModal(false);
};

return (
<div>
<Button variant="outlined" onClick={handleShowModal}>
Show Modal
</Button>
{showModal && (
<Dialog open={showModal} fullWidth={true}>
<DialogTitle>Welcome</DialogTitle>
<DialogContent>Hi there</DialogContent>
<DialogActions>
<Button
variant="outlined"
color="secondary"
onClick={handleCloseModal}
>
Close
</Button>
</DialogActions>
</Dialog>
)}
</div>
);
};

export default MuiModal;

Here is a demo:

MuiModal component

Test

First of all, dialog can be queried from screen by ARIA role of dialog. That means any content on the modal can be easily asserted as well.

import { render, screen, within } from "@testing-library/react";
import MuiModal from "./MuiModal";
import userEvent from "@testing-library/user-event";

describe("MuiModal Tests", () => {
it("should render dialog when button is clicked", async () => {
render(<MuiModal />);

const button = await screen.findByRole("button", { name: "Show Modal" });
await userEvent.click(button);

const modal = within(await screen.findByRole("dialog"));
expect(modal.getByText("Welcome")).toBeInTheDocument();
expect(modal.getByText("Hi there")).toBeInTheDocument();
expect(modal.getByRole("button", { name: "Close" })).toBeInTheDocument();
});

it("should close modal when close button is clicked", async () => {
render(<MuiModal />);

const button = await screen.findByRole("button", { name: "Show Modal" });
await userEvent.click(button);
const modal = within(await screen.findByRole("dialog"));
expect(modal.getByText("Welcome")).toBeInTheDocument();
expect(modal.getByText("Hi there")).toBeInTheDocument();
const closeButton = modal.getByRole("button", { name: "Close" });
await userEvent.click(closeButton);

expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});

7. Pagination

Implementation

Last is the Pagination component which allows traversing through list of data.

Let’s create a component which simulates fetching data from backend and renders a table with pagination control.

import { useCallback, useEffect, useState } from "react";
import {
Pagination,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from "@mui/material";
import { fetchFromBackend } from "../membersApi";

const MuiTable = () => {
const [members, setMembers] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const limit = 3;

const fetchMembers = useCallback(
async (page, limit) => {
const result = await fetchFromBackend(page, limit);
setMembers(result.content);
setTotal(result.totalElements);
},
[page, limit],
);

useEffect(() => {
fetchMembers(page, limit);
}, [page, limit]);

return (
<div>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Id</TableCell>
<TableCell>Name</TableCell>
</TableRow>
</TableHead>
<TableBody>
{members.map((m) => (
<TableRow key={m.id}>
<TableCell>{m.id}</TableCell>
<TableCell>{m.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>

<Pagination
count={Math.ceil(total / limit)}
onChange={(event, value) => {
setPage(value - 1);
}}
variant="outlined"
shape="rounded"
boundaryCount={2}
siblingCount={2}
showFirstButton={true}
showLastButton={true}
sx={{
marginTop: "12px",
display: "flex",
justifyContent: "center",
}}
/>
</div>
);
};

export default MuiTable;

Here is a demo:

MuiTable component

Test

Pagination component has ARIA role of navigation. So role can be used to query the pagination component. The test code for moving between pages was a little bit tricky because each page in the pagination component is a button with ARIA label of Go to page n. Let’s see it in action.

import { render, screen, waitFor } from "@testing-library/react";
import MuiTable from "./MuiTable";
import * as membersApi from "../membersApi";
import userEvent from "@testing-library/user-event";

jest.mock("../membersApi");

describe("MuiTable Tests", () => {
it("should display pagination control", async () => {
membersApi.fetchFromBackend.mockResolvedValue({
totalElements: 0,
content: [],
});

render(<MuiTable />);

expect(await screen.findByRole("navigation")).toBeInTheDocument();
});

it("should load next 3 and previous 3 members", async () => {
membersApi.fetchFromBackend.mockResolvedValue({
totalElements: 10,
content: [],
});

render(<MuiTable />);
await waitFor(() => {
expect(membersApi.fetchFromBackend).toHaveBeenNthCalledWith(1, 0, 3);
});

const secondPage = await screen.findByRole("button", {
name: "Go to page 2",
});
await userEvent.click(secondPage);
await waitFor(() => {
expect(membersApi.fetchFromBackend).toHaveBeenNthCalledWith(2, 1, 3);
});

const firstPage = await screen.findByRole("button", {
name: "Go to page 1",
});
await userEvent.click(firstPage);
await waitFor(() => {
expect(membersApi.fetchFromBackend).toHaveBeenNthCalledWith(3, 0, 3);
});
});
});

Conclusion

With Material UI, my team managed to successfully deliver a short-term project to our clients. Thanks to its production-ready visual components, we could concentrate our efforts on implementation and testing. Despite the absence of a product designer, the clients expressed overall satisfaction with the design, and our team maintained an impressive pace while executing numerous tests. In roughly a month and a half, we wrote approximately 250 tests for both the frontend and backend each. It took some time to figure out the testing process for certain components. So I hope that this article could help you enhance the quality of your software projects while maintaining high velocity. Good luck!

--

--