Testing a React Custom Hook
Tags: webdev, javascript, react, typescript
Published: 2023-02-13 15:03:00

Let's say you already have `@testing-library` up & running ✅

```json
  "@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](https://beta.reactjs.org/learn/reusing-logic-with-custom-hooks#extracting-your-own-custom-hook-from-a-component). ✅

> Trying to escape the typical tutorial code, let's start with this **production hook**.

```jsx
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:

```jsx
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: 

1. render just your hook (wrapping the call into an anonymous function) 
2. wrap the change inside the callback of `act(() => { ... })`
3. check the state

```jsx
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()`

1. A hook needs to be used **inside a component**.
2. The internal state of the hook depends on the rendered component.
3. Let's create a dummy component for testing our hook.
4. Closer to real usage. Zero wrappers. More verbose. 

```tsx
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: 

```tsx
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](https://undraw.co/search)