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
Top comments (0)