Testing
Mindset
User behavior and A11Y:
- Rather than tests focusing on the implementation (props and state) (Enzyme), tests are more focused on user behavior (react-testing-library).
- React testing library enforce to
use
placeholder,aria,test-idsto access elements, benefiting for a11y components (write tests > build accessible components > tests pass).
But sometimes may need to test the internals of the component when just testing the DOM from user’s perspective may not make sense.
So depending on the use cases, we can choose between these two libraries or just install them all for individual use cases.
Enzyme for Internal API, React testing library for user behavior.
Installation
pnpm add -D @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event
Events
FireEvent
fireEventtrigger DOM event:fireEvent(node, event).fireEvent.*helpers for default event types:- click fireEvent.click(node).
- See all supported events.
import { fireEvent, render, wait } from '@testing-library/react'
import { api } from './api'
import { App } from './App'
// Normally you can mock entire module using jest.mock('./api);
const mockCreateItem = (api.createItem = jest.fn())
test('allows users to add items to their list', async () => {
const todoText = 'Learn spanish'
mockCreateItem.mockResolvedValueOnce({ id: 123, text: todoText })
const { getByText, getByLabelText } = render(<App />)
const input = getByLabelText('What needs to be done?')
const button = getByText('Add #1')
fireEvent.change(input, { target: { value: todoText } })
fireEvent.click(button)
await wait(() => getByText(todoText))
expect(mockCreateItem).toBeCalledTimes(1)
expect(mockCreateItem).toBeCalledWith('/items', expect.objectContaining({ text: todoText }))
})
UserEvent
User Event
provides more advanced simulation of browser interactions
than the built-in fireEvent method.
pnpm add -D @testing-library/user-event @testing-library/dom
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
test('click', () => {
render(
<div>
<label htmlFor="checkbox">Check</label>
<input id="checkbox" type="checkbox" />
</div>,
)
userEvent.click(screen.getByText('Check'))
expect(screen.getByLabelText('Check')).toBeChecked()
})
/**
* render: render the component
* screen: finding elements along with user
*/
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Checkbox, Welcome } from './'
describe('Welcome should', () => {
test('has correct welcome message', () => {
render(<Welcome firstName="John" lastName="Doe" />)
expect(screen.getByRole('heading')).toHaveTextContent('Welcome, John Doe')
})
test('has correct input value', () => {
render(<Welcome firstName="John" lastName="Doe" />)
expect(screen.getByRole('form')).toHaveFormValues({
firstName: 'John',
lastName: 'Doe',
})
})
test('handles click correctly', () => {
render(<Checkbox />)
userEvent.click(screen.getByText('Check'))
expect(screen.getByLabelText('Check')).toBeChecked()
})
})
Hooks
import { useCallback, useState } from 'react'
export default function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const increment = useCallback(() => setCount(x => x + 1), [])
const reset = useCallback(() => setCount(initialValue), [initialValue])
return { count, increment, reset }
}
import { act, renderHook } from '@testing-library/react-hooks'
import useCounter from './useCounter'
test('should reset counter to updated initial value', () => {
const { result, rerender } = renderHook(({ initialValue }) => useCounter(initialValue), {
initialProps: { initialValue: 0 },
})
rerender({ initialValue: 10 })
act(() => {
result.current.reset()
})
expect(result.current.count).toBe(10)
})
Async
import { use, useCallback, useState } from 'react'
export default function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const step = use(CounterStepContext)
const increment = useCallback(() => setCount(x => x + step), [step])
const incrementAsync = useCallback(() => setTimeout(increment, 100), [increment])
const reset = useCallback(() => setCount(initialValue), [initialValue])
return { count, increment, incrementAsync, reset }
}
import { renderHook } from '@testing-library/react-hooks'
import useCounter from './useCounter'
test('should increment counter after delay', async () => {
const { result, waitForNextUpdate } = renderHook(() => useCounter())
result.current.incrementAsync()
await waitForNextUpdate()
expect(result.current.count).toBe(1)
})
Error
import { use, useCallback, useState } from 'react'
export default function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const step = use(CounterStepContext)
const increment = useCallback(() => setCount(x => x + step), [step])
const incrementAsync = useCallback(() => setTimeout(increment, 100), [increment])
const reset = useCallback(() => setCount(initialValue), [initialValue])
if (count > 9000)
throw new Error('It\'s over 9000!')
return { count, increment, incrementAsync, reset }
}
import { act, renderHook } from '@testing-library/react-hooks'
import { useCounter } from './useCounter'
it('should throw when over 9000', () => {
const { result } = renderHook(() => useCounter(9000))
act(() => {
result.current.increment()
})
expect(result.error).toEqual(new Error('It\'s over 9000!'))
})
APIs
getByXXXqueries: common use case.queryByXXXqueries: not throw error when nothing match.findByXXXqueries:getByqueries +waitFor.
| API | No Match | 1 Match | 1+ Match | Await |
|---|---|---|---|---|
| getBy | throw | return | throw | No |
| queryBy | null | return | throw | No |
| findBy | throw | return | throw | Yes |
| getAllBy | throw | array | array | No |
| queryAllBy | [] | array | array | No |
| findAllBy | throw | array | array | Yes |
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import TransactionCreateStepTwo from './TransactionCreateStepTwo'
test('if amount and note is entered, pay button becomes enabled', async () => {
render(<TransactionCreateStepTwo sender={{ id: '5' }} receiver={{ id: '5' }} />)
expect(await screen.findByRole('button', { name: /pay/i })).toBeDisabled()
userEvent.type(screen.getByPlaceholderText(/amount/i), '50')
userEvent.type(screen.getByPlaceholderText(/add a note/i), 'dinner')
expect(await screen.findByRole('button', { name: /pay/i })).toBeEnabled()
})
waitFor
findBy handles DOM waiting automatically,
waitFor is more suitable for non-DOM things,
e.g. function/spy was called or resolved:
// DOM waiting
expect(await screen.findByText('Data loaded')).toBeInTheDocument()
// Non-DOM waiting
await waitFor(() => expect(window.fetch).toHaveBeenCalled())
Best Practices
- Good frontend tests guide.
References
- Custom Jest DOM matchers.
- React testing library cheat sheet.
- UI testing with GitHub Actions.
- React testing tutorial.
- Cypress testing recipes.