DEV Community

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

Posted on

9

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

SurveyJS custom survey software

Build Your Own Forms without Manual Coding

SurveyJS UI libraries let you build a JSON-based form management system that integrates with any backend, giving you full control over your data with no user limits. Includes support for custom question types, skip logic, an integrated CSS editor, PDF export, real-time analytics, and more.

Learn more

Top comments (0)

nextjs tutorial video

Youtube Tutorial Series 📺

So you built a Next.js app, but you need a clear view of the entire operation flow to be able to identify performance bottlenecks before you launch. But how do you get started? Get the essentials on tracing for Next.js from @nikolovlazar in this video series 👀

Watch the Youtube series