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)