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)
})