I'm writing tests for a React component Order
that renders a user's order and allows the user to view items on the order by clicking on a "View items" button, which triggers an API call.
import { useState, useEffect } from "react";
import Skeleton from "react-loading-skeleton";
import { Customer } from "../../../api/Server";
import capitalise from "../../../util/capitalise";
import renderOrderTime from "../../../util/renderOrderTime";
const Order = props => {
// Destructure props and details
const { details, windowWidth, iconHeight, cancelOrder } = props;
const { id, createdAt, status } = details;
// Define server
const Server = Customer.orders;
// Define order cancel icon
const OrderCancel = (
<svg className="iconOrderCancel" width={iconHeight} height={iconHeight} viewBox="0 0 24 24">
<path className="pathOrderCancel" style={{ fill:"#ffffff" }} d="M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10-10-4.486-10-10 4.486-10 10-10zm0-2c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm5 15.538l-3.592-3.548 3.546-3.587-1.416-1.403-3.545 3.589-3.588-3.543-1.405 1.405 3.593 3.552-3.547 3.592 1.405 1.405 3.555-3.596 3.591 3.55 1.403-1.416z"/>
</svg>
)
// Set state and define fetch function
const [items, setItems] = useState([]);
const [fetchItems, setFetchItems] = useState(false);
const [isLoadingItems, setIsLoadingItems] = useState(false);
const [error, setError] = useState(null);
const fetchOrderItems = async() => {
setIsLoadingItems(true);
try {
let order = await Server.getOrders(id);
setItems(order.items);
setIsLoadingItems(false);
} catch (err) {
setError(true);
console.log(err);
}
}
useEffect(() => {
if (items.length === 0 && fetchItems) fetchOrderItems();
// eslint-disable-next-line
}, [fetchItems]);
// Define function to view order items
const viewItems = e => {
e.preventDefault();
setFetchItems(fetchItems ? false : true);
const items = document.getElementById(`items-${id}`);
items.classList.toggle("show");
}
// RENDERING
// Order items
const renderItems = () => {
// Return error message if error
if (error) return <p className="error">An error occurred loading order items. Kindly refresh the page and try again.</p>;
// Return skeleton if loading items
if (isLoadingItems) return <Skeleton containerTestId="order-items-loading" />;
// Get total cost of order items
let total = items.map(({ totalCost }) => totalCost).reduce((a, b) => a + b, 0);
// Get order items
let list = items.map(({ productId, name, quantity, totalCost}, i) => {
return (
<div key={i} className="item" id={`order-${id}-item-${productId}`}>
<p className="name">
<span>{name}</span><span className="times">×</span><span className="quantity">{quantity}</span>
</p>
<p className="price">
<span className="currency">Ksh</span><span>{totalCost}</span>
</p>
</div>
)
});
// Return order items
return (
<div id={`items-${id}`} className={`items${items.length === 0 ? null : " show"}`} data-testid="order-items">
{list}
{
items.length === 0 ? null : (
<div className="item total" id={`order-${id}-total`}>
<p className="name">Total</p>
<p className="price">
<span className="currency">Ksh</span><span>{total}</span>
</p>
</div>
)
}
</div>
)
};
// Component
return (
<>
<div className="order-body">
<div className="info">
<p className="id">#{id}</p>
<p className="time">{renderOrderTime(createdAt)}</p>
<button className="view-items" onClick={viewItems}>{ fetchItems ? "Hide items" : "View items"}</button>
{renderItems()}
</div>
</div>
<div className="order-footer">
<p className="status" id={`order-${id}-status`}>{capitalise(status)}</p>
{status === "pending" ? <button className="cancel-order" onClick={cancelOrder}>{windowWidth > 991 ? "Cancel order" : OrderCancel}</button> : null}
</div>
</>
)
}
export default Order;
Below are the tests I'm writing for Order
. I've run into some trouble writing tests on the API call.
import { render, fireEvent, screen } from "@testing-library/react";
import { act } from "react-dom/test-utils";
import Order from "../../../components/Primary/Orders/Order";
import { Customer } from "../../../api/Server";
import { orders } from "../../util/dataMock";
// Define server
const Server = Customer.orders;
// Define tests
describe("Order", () => {
describe("View items button", () => {
const mockGetOrders = jest.spyOn(Server, "getOrders");
beforeEach(() => {
const { getAllByRole } = render(<Order details={orders[2]} />);
let button = getAllByRole("button")[0];
fireEvent.click(button);
});
test("triggers API call when clicked", () => {
expect(mockGetOrders).toBeCalled();
});
test("renders loading skeleton during API call", () => {
let skeleton = screen.getByTestId("order-items-loading");
expect(skeleton).toBeInTheDocument();
});
///--- PROBLEMATIC TEST ---///
test("renders error message if API call fails", async() => {
await act(async() => {
mockGetOrders.mockRejectedValue("Error: An unknown error occurred. Kindly try again.");
});
// let error = await screen.findByText("An error occurred loading order items. Kindly refresh the page and try again.");
// expect(error).toBeInTheDocument();
});
});
test("calls cancelOrder when button is clicked", () => {
const clickMock = jest.fn();
const { getAllByRole } = render(<Order details={orders[2]} cancelOrder={clickMock} />);
let button = getAllByRole("button")[1];
fireEvent.click(button);
expect(clickMock).toBeCalled();
});
});
On the test that I've marked as problematic, I'm expecting the mocked rejected value Error: An unknown error occurred. Kindly try again.
to be logged to the console, but instead I'm getting a TypeError: Cannot read properties of undefined (reading 'items')
. This indicates that my mock API call isn't working properly and the component is attempting to act on an array of items it's supposed to receive from the API. How should I fix my test(s) to get the desired result?
I was experimenting with a simplified example of your case and it seems the issue is with the use of the beforeEach
hook in which you render the component and click the button before each test. This is not a good idea when it comes to RTL, in fact, there's an Eslint rule for this.
In order to avoid repeating yourself on each test, you can use a setup
function to perform these actions on each one of the tests:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Order from "../../../components/Primary/Orders/Order";
import { Customer } from "../../../api/Server";
import { orders } from "../../util/dataMock";
// Define server
const Server = Customer.orders;
// Setup function
const setup = () => {
render(<Order details={orders[2]} />);
const button = screen.getAllByRole("button")[0];
userEvent.click(button);
}
// Define tests
describe("Order", () => {
describe("View items button", () => {
const mockGetOrders = jest.spyOn(Server, "getOrders");
test("triggers API call when clicked", () => {
setup()
expect(mockGetOrders).toBeCalled();
});
test("renders loading skeleton during API call", async () => {
setup()
const skeleton = await screen.findByTestId("order-items-loading");
expect(skeleton).toBeInTheDocument();
});
test("renders error message if API call fails", async () => {
mockGetOrders.mockRejectedValue(new Error("An unknown error occurred. Kindly try again."));
setup()
const error = await screen.findByText("An error occurred loading order items. Kindly refresh the page and try again.");
expect(error).toBeInTheDocument();
});
});
test("calls cancelOrder when button is clicked", () => {
const clickMock = jest.fn();
render(<Order details={orders[2]} cancelOrder={clickMock} />);
const button = screen.getAllByRole("button")[1];
userEvent.click(button);
expect(clickMock).toBeCalled();
});
});
I also made some minor changes like: using userEvent
instead of fireEvent
, using screen
instead of destructuring the query functions from render
's result, etc. These changes are all recommended by the creator of the library.
Also, in the fetchOrderItems
function, you should set the loading flag to false
in case an exception is thrown.
Note: The act
warning might be coming from tests #1 and #2. This is probably because the test cases are ending before all component's state updates have finished (e.g. the loading state toggling after clicking the fetch button). It's not recommended to use act
on RTL, instead you can modify these tests and use the waitForElementToBeRemoved
utility from RTL. This will still satisfy test #2, in which you check for the loading skeleton presence since it will throw anyways if it can't find this loading UI:
test("triggers API call when clicked", async () => {
setup()
await waitForElementToBeRemoved(() => screen.queryByTestId("order-items-loading"))
expect(mockGetOrders).toBeCalled();
});
test("renders loading skeleton during API call", async () => {
setup()
await waitForElementToBeRemoved(() => screen.queryByTestId("order-items-loading"))
});
Let me know if this works for you.