[React] Don’t give up on testing when using Material UI with React
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:
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;
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
.
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:
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.
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:
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;
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
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.
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:
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:
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!
Full source can be found at https://github.com/jskim1991/react-material-ui-test