Testing Custom Hook With React testing library

10/01/2023 Wassim Nassour

For writing a custom hook, we will need React@>=16.8. We will write a test for our custom hook to make sure it does not break or get bugs in the future, so we can maintain confidence that as we make changes, it will not break or get bugs.
In this article, we will write a test for our useNotes hook, a simple hook where you can perform some actions like add, remove, and search for a note by title. We will use react-testing/library to test the app since the author of Enzyme said that Enzyme will not support React 18 https://dev.to/wojtekmaj/enzyme-is-dead-now-what-ekl.
To test this custom hook, we have two approaches: one is recommended by the author of the library we are using, which is to write the test as an integration test and test the hook with the component it's using. This will avoid abstraction and unnecessary tests for our hook.
The other approach is to test the hook in isolation, if our hook is more general and we want to write a unit test on it. For this approach, we will use the renderHook utlity from the library for test our custom hook. because we can't call our hook directly inside the test, it will break the rules of React hooks, "you can't call a hook outside of a React component."

pre-required to start

all packages we used in the tutorial have come with the boilerplate of create-react-app, but if you have an old version or you found the packages missing you can add them with this commend

yarn add @testing-library/react @testing-library/user-event

useNotes is hook hold notes and functions for updating the notes, so we have some use cases to test
  • initial data of the notes.
  • adding notes
  • removing note
  • serach for note by titile
  • useNote

    import React from 'react'
    
    type NoteShape = {
      id: string
      title: string
      note: string
    }
    
    export const useNotes = () => {
      const [notes, setNotes] = React.useState<NoteShape[]>([])
      const [searchResults, setSearchResults] = React.useState<NoteShape[] | undefined>()
    
      const AddNote = React.useCallback((note: NoteShape) => setNotes(prev => [...prev, note]), [])
    
      const removeNote = React.useCallback((n: NoteShape) => {
        const results = notes?.filter(note => note.id !== n.id)
        setNotes(results)
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [])
    
      const searchForNote = React.useCallback(
        (searchValue: string) => {
          const results = notes?.filter(note => note.title.includes(searchValue?.trim()))
          setSearchResults(results)
        },
        [notes]
      )
    
      return {
        notes,
        searchResults,
        searchForNote,
        removeNote,
        AddNote
      }
    }
    

    Component :

    import React, { FormEvent } from 'react'
    import { useNotes } from './hooks/useNotes'
    
    type NoteShape = {
      id: string
      title: string
      note: string
    }
    
    export const Notes = () => {
      const { searchQuery, setSearchQuery, notes, searchResults, AddNote, searchForNote, removeNote } =
        useNotes()
    
      const onSubmit = (e: FormEvent<HTMLFormElement>) => {
        e.preventDefault()
    
        const elements = e.currentTarget.elements as unknown as Record<
          'title' | 'note',
          { value: string }
        >
        const noteValue = elements?.note?.value
        const title = elements?.title?.value
    
        AddNote({ id: title, note: noteValue, title: title })
      }
    
      const searchFn = (e: React.ChangeEvent<HTMLInputElement>) => {
        setSearchQuery(e.target.value)
        searchForNote(e.target.value)
      }
    
      return (
        <div>
          <input placeholder="search for specific note by title" onChange={searchFn} role="search" />
          <form onSubmit={onSubmit}>
            <label htmlFor="title">Title</label>
    
            <input type="text" id="title" name="title" placeholder="Note Title" />
            <label htmlFor="note">Note</label>
            <input type="text" name="note" id="note" placeholder="Write your note here" />
            <button>Add Note</button>
          </form>
          <div style={{ display: 'flex', justifyContent: 'center' }}>
            {searchQuery && searchQuery.length >= 1 && searchResults?.length === 0 ? (
              <p>No Notes Founded</p>
            ) : searchResults && searchResults?.length >= 1 ? (
              searchResults?.map(note => (
                <Note removeNote={removeNote} note={note} key={note?.title + 'search_note'} />
              ))
            ) : (
              notes?.length >= 1 &&
              notes.map(note => <Note note={note} removeNote={removeNote} key={note?.title + 'note'} />)
            )}
          </div>
        </div>
      )
    }
    
    const Note = ({ note, removeNote }: { note: NoteShape; removeNote: (n: NoteShape) => void }) => {
      return (
        <div
          style={{
            border: '1px solid black ',
            marginTop: 10,
            width: 200
          }}
        >
          <h2>{note?.title}</h2>
          <p>{note?.note}</p>
          <button onClick={() => removeNote(note)}>delete</button>
        </div>
      )
    }
    

    Testing the hook in isolation :

    to write unit test for that hook , we will nee to use renderHook utility from React-testing/library
    import { renderHook } from '@testing-library/react'
    import { act } from 'react-dom/test-utils'
    import { useNotes } from '../hooks/useNotes'
    
    const noteForTest = { id: 'test', title: 'test', note: 'test' }
    
    describe('Testing useNotes hook in isolation', () => {
      // test to check if the intial value of the hook is []
      test('accept initial value', async () => {
        const { result } = renderHook(useNotes)
        expect(result.current?.notes).toStrictEqual([])
      })
    
      // 1) test to check is note we write added to the notes state after using
      // add note function
      test('add note', async () => {
        const { result } = renderHook(useNotes)
        act(() => result.current?.AddNote(noteForTest))
        act(() => expect(result.current?.notes).toStrictEqual([noteForTest]))
      })
    
      // 2) test to check is note we want to delete , is deleted after using
      // removeNote function
      test('delete note', async () => {
        const { result } = renderHook(useNotes)
        act(() => result.current.removeNote(noteForTest))
        act(() => expect(result.current?.notes).toStrictEqual([]))
      })
    
      // 3) test search  for specific note by title
      test('search  for specific note by title', async () => {
        const { result } = renderHook(useNotes)
        expect(result.current?.searchResults).toStrictEqual(undefined)
        act(() => result.current.AddNote(noteForTest))
        act(() => result.current.searchForNote(noteForTest.title))
        act(() => expect(result.current.searchResults).toStrictEqual([noteForTest]))
      })
    })
    

    Testing the hook with Component Integration Testing :

    this looks shoreter and test the component too , and we test the cases we want directly without going in detail
    import { renderHook } from '@testing-library/react'
    import { act } from 'react-dom/test-utils'
    import { useNotes } from '../hooks/useNotes'
    
    const noteForTest = { id: 'test', title: 'test', note: 'test' }
    
    test('useNotes inside a component', async () => {
      render(<Notes />)
    
      const submitFunction = await screen.findByText(/add note/i)
    
      // type title and note input
      await userEvent.type(screen.getByLabelText(/title/i), noteForTest?.title)
      await userEvent.type(screen.getByLabelText(/note/i), noteForTest.note)
    
      // click on the submit button
      userEvent.click(submitFunction)
    
      // check if note rendered in the dom
      await screen.findByText(noteForTest.title)
      await screen.findByText(noteForTest.note)
    
      // // search for specific  note ("Best Case found search is working")
      fireEvent.change(await screen.findByRole('search'), {
        target: { value: noteForTest.title }
      })
      await screen.findByText(noteForTest.title)
    
      //  Remove Note from document
      userEvent.click(screen.getByText(/delete/i))
    
      // search for specific  note ("bad Case not found anything ")
      fireEvent.change(await screen.findByRole('search'), {
        target: { value: 'Nothing should be founded' }
      })
      // check zero note
      await screen.findByText(/no notes founded/i)
    })
    

    Created by @Wassim built with @NextJs deployed in @Vercel