HTML / CSS

How to test react hooks

Testing custom hooks is one of those things that feels hard until you see the pattern. Then it's straightforward.

12 Apr 2024

How to test react hooks

Testing custom hooks is one of those things that feels hard until you see the pattern. Then it's straightforward.

The challenge: hooks can't run outside of a React component. You can't just call useMyHook() in a test and assert on the result. You need a wrapper.

The Tool: renderHook

React Testing Library provides renderHook — a utility that wraps your hook in a test component so you can call it and inspect the results.

Here's a real example. I had a custom hook that fetches and validates product data using Zod schemas:

Text
import IProduct, { productSchema } from "../../prisma/types/IProduct"
import { useEffect, useState } from "react"

function useProduct(id: string) {
  const [product, setProduct] = useState<IProduct | null>(null)
  const [error, setError] = useState<string | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    async function fetchProduct() {
      try {
        const response = await fetch(`/api/products/${id}`)
        const data = await response.json()
        const validated = productSchema.parse(data)
        setProduct(validated)
      } catch (e) {
        setError(e instanceof Error ? e.message : "Unknown error")
      } finally {
        setLoading(false)
      }
    }
    fetchProduct()
  }, [id])

  return { product, error, loading }
}

Testing It

Text
import { renderHook, waitFor } from "@testing-library/react"
import { rest } from "msw"
import { setupServer } from "msw/node"
import useProduct from "./useProduct"

const server = setupServer(
  rest.get("/api/products/:id", (req, res, ctx) => {
    return res(ctx.json({ id: "1", name: "Widget", price: 9.99 }))
  })
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

test("fetches and returns product data", async () => {
  const { result } = renderHook(() => useProduct("1"))

  expect(result.current.loading).toBe(true)

  await waitFor(() => {
    expect(result.current.loading).toBe(false)
  })

  expect(result.current.product).toEqual({
    id: "1",
    name: "Widget",
    price: 9.99,
  })
  expect(result.current.error).toBeNull()
})

test("handles fetch errors", async () => {
  server.use(
    rest.get("/api/products/:id", (req, res, ctx) => {
      return res(ctx.status(500))
    })
  )

  const { result } = renderHook(() => useProduct("1"))

  await waitFor(() => {
    expect(result.current.loading).toBe(false)
  })

  expect(result.current.error).toBeTruthy()
  expect(result.current.product).toBeNull()
})

The Pattern

  1. Use renderHook to run the hook in an isolated component.
  2. Use MSW to mock API calls at the network level — no mocking fetch directly.
  3. Use waitFor to wait for async state updates.
  4. Assert on result.current to inspect the hook's return value.

The Trade-offs

Benefits: Tests run fast. They test the hook's behavior, not its implementation. MSW mocks are realistic — they intercept actual network requests.

Costs: Setup overhead for MSW and test infrastructure. Async tests need careful handling of timing. If your hook has complex dependencies (context providers, routers), you'll need to wrap renderHook with those providers.

When to Test Hooks Directly

Test a hook directly when it contains business logic worth testing in isolation — data fetching, complex state management, validation.

Don't test a hook directly when it's just a thin wrapper around useState. Test the component that uses it instead. The hook is an implementation detail in that case.