DEV Community

Cover image for Testing a React Custom Hook
Manuel Artero Anguita 🟨
Manuel Artero Anguita 🟨

Posted on

Testing a React Custom Hook

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",
Enter fullscreen mode Exit fullscreen mode

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,
  }
}
Enter fullscreen mode Exit fullscreen mode

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)} 
      .../>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
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);
  });
});

Enter fullscreen mode Exit fullscreen mode

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.
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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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')
  })
})
Enter fullscreen mode Exit fullscreen mode

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)