Testing a React Custom Hook
Manuel Artero Anguita ๐จ
Posted on February 13, 2023
Let's say you already have @testing-library
up & running โ
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.1.1",
"@testing-library/user-event": "^13.5.0",
Let's say you have already coded a cool custom hook. โ
Trying to escape the typical tutorial code, let's start with this production hook.
export function useCart() {
const [items, setItems] = React.useState([]);
const addItem = (item) => {
if (items.find(i => i.id === item.id)) {
return;
}
setItems([...items, item])
}
const removeItem = (id) => {
setItems(items.filter(i => i.id !== id));
}
const clear = () => {
setItems([]);
}
return {
cart: items,
total: items.reduce((acc, item) => acc + item.price, 0),
addItem,
removeItem,
clear,
}
}
We actually use this custom hook for managing the state of the cart ๐, preventing to add duplicate items to it... you get the idea:
function Cart(props) {
...
const { cart, total, addItem, removeItem, clear } = useCart()
...
return (
...
<SomeComponent
onItemClick={(item) => addItem(item)}
onRemove={(item) => removeItem(item.id)}
.../>
)
}
Next step, you want to cover with Unit testing this custom hook; use-cart.test.tsx
(or use-cart.test.jsx
)
IMO there are 2 options to face this
Option 1: act() + renderHook()
By using this tuple from @testing-library/react
we are accepting a bit of magic behind the curtain ๐ช
The idea is:
- render just your hook (wrapping the call into an anonymous function)
- wrap the change inside the callback of
act(() => { ... })
- check the state
import { act, renderHook } from "@testing-library/react";
import { useCart } from "./use-cart";
describe("useCart()", () => {
test("cart: initial state should be empty", () => {
const { result } = renderHook(() => useCart());
expect(result.current.cart).toEqual([]);
});
test("addItem(): should add an item to the cart", () => {
const { result } = renderHook(() => useCart());
act(() => {
result.current.addItem({ id: "1", name: "Test Item" });
});
act(() => {
result.current.addItem({ id: "2", name: "Test Item 2" });
});
expect(result.current.cart).toEqual([
{ id: "1", name: "Test Item" },
{ id: "2", name: "Test Item 2" },
]);
});
test("addItem(): should not add an item if it already exists", () => {
const { result } = renderHook(() => useCart());
act(() => {
result.current.addItem({ id: "1", name: "Test Item" });
});
act(() => {
result.current.addItem({ id: "1", name: "Test Item" });
});
expect(result.current.cart).toEqual([{ id: "1", name: "Test Item" }]);
});
test("removeItem(): should remove an item from the cart", () => {
const { result } = renderHook(() => useCart());
act(() => {
result.current.addItem({ id: "1", name: "Test Item" });
});
act(() => {
result.current.addItem({ id: "2", name: "Test Item 2" });
});
act(() => {
result.current.removeItem("1");
});
expect(result.current.cart).toEqual([{ id: "2", name: "Test Item 2" }]);
});
test("clear(): should clear the cart", () => {
const { result } = renderHook(() => useCart());
act(() => {
result.current.addItem({ id: "1", name: "Test Item" });
});
act(() => {
result.current.addItem({ id: "2", name: "Test Item 2" });
});
act(() => {
result.current.clear();
});
expect(result.current.cart).toEqual([]);
});
test("total: should return the correct total", () => {
const { result } = renderHook(() => useCart());
act(() => {
result.current.addItem({ id: "1", name: "Test Item", price: 10 });
});
act(() => {
result.current.addItem({ id: "2", name: "Test Item 2", price: 20 });
});
expect(result.current.total).toEqual(30);
});
});
This code is perfectly fine. Production ready.
...
...
๐ค
But there is an alternative that reduces the magic to zero.
Option 2: just regular render()
- A hook needs to be used inside a component.
- The internal state of the hook depends on the rendered component.
- Let's create a dummy component for testing our hook.
- Closer to real usage. Zero wrappers. More verbose.
function Component() {
const { cart, total, addItem, removeItem, clear } = useCart()
return (
<div>
<div data-testid="cart">
<ul>
{cart.map(item => (
<li key={item.id}>{item.id} - {item.price}</li>
))}
</ul>
</div>
<div data-testid="cart-total">{total}</div>
<button data-testid="add-item" onClick={() => addItem({ id: 1, price: 10 })} />
<button data-testid="remove-item" onClick={() => removeItem(1)} />
<button data-testid="clear" onClick={() => clear()} />
</div>
)
}
And just regular component unit testing:
import { useCart } from './use-cart'
import { render, fireEvent, screen } from '@testing-library/react'
function Component() {
...
}
describe('useCart()', () => {
test('addItem(): should add item', () => {
render(<Component />)
const cart = screen.getByTestId('cart')
const cartTotal = screen.getByTestId('cart-total')
const addItem = screen.getByTestId('add-item')
expect(cart).toHaveTextContent('0')
expect(cartTotal).toHaveTextContent('0')
fireEvent.click(addItem)
expect(cart).toHaveTextContent('1')
expect(cartTotal).toHaveTextContent('10')
})
test('addItem(): should not add same item twice', () => {
render(<Component />)
const cart = screen.getByTestId('cart')
const cartTotal = screen.getByTestId('cart-total')
const addItem = screen.getByTestId('add-item')
fireEvent.click(addItem)
fireEvent.click(addItem)
expect(cart).toHaveTextContent('1')
expect(cartTotal).toHaveTextContent('10')
})
test('removeItem(): should remove item', () => {
render(<Component />)
const cart = screen.getByTestId('cart')
const cartTotal = screen.getByTestId('cart-total')
const addItem = screen.getByTestId('add-item')
const removeItem = screen.getByTestId('remove-item')
fireEvent.click(addItem)
expect(cart).toHaveTextContent('1')
expect(cartTotal).toHaveTextContent('10')
fireEvent.click(removeItem)
expect(cart).toHaveTextContent('0')
expect(cartTotal).toHaveTextContent('0')
})
test('clear(): should clear cart', () => {
render(<Component />)
const cart = screen.getByTestId('cart')
const cartTotal = screen.getByTestId('cart-total')
const addItem = screen.getByTestId('add-item')
const clear = screen.getByTestId('clear')
fireEvent.click(addItem)
expect(cart).toHaveTextContent('1')
expect(cartTotal).toHaveTextContent('10')
fireEvent.click(clear)
expect(cart).toHaveTextContent('0')
expect(cartTotal).toHaveTextContent('0')
})
})
Both alternatives are perfectly valid; I have no hard preference since both alternatives have advantages:
Advantage โ | Drawback โ ๏ธ | |
---|---|---|
act() & renderHook()
|
Focused just on hook behavior | some level of "wrapper-magics" |
regular render()
|
Zero magic: Explicit render | more verbose (needs a "dummy-component") |
thanks for reading. ๐
cover image from undraw
Posted on February 13, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
September 16, 2024
October 16, 2024