Testing a React Custom Hook

manuartero

Manuel Artero Anguita ๐ŸŸจ

Posted on February 13, 2023

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

๐Ÿ’– ๐Ÿ’ช ๐Ÿ™… ๐Ÿšฉ
manuartero
Manuel Artero Anguita ๐ŸŸจ

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

ยฉ TheLazy.dev

About