EpicReact - Testing React Apps
- Basic concepts
- React Testing Library
- Improve asserts with
@testing-library/jest-dom
- Avoid implementation details
- Form Testing
- Mocking HTTP responses
- testing with context
- testing custom hooks
- Tips
When we think about how things are used, we need to consider who the users are:
- The end user that’s interacting with our code (clicking buttons/etc)
- The developer user that’s actually using our code (rendering it, calling our functions, etc.)
You’ll be using assertions from jest: https://jestjs.io/docs/en/expect
Basic concepts
const div = document.createElement('div');
document.body.append(div);
// option 1
ReactDOM.render(<Counter />, div)
// option 2
const root = createRoot(div)
act(() => root.render(<Counter />))
// if we do console.log(document.body.innerHTML), it prints the element inside a <div>
After each test we should remove the div from the body.
div.remove() // at the end of the test
// but what if the test fails? Better do it in a beforeEach test
beforeEach(() => {
document.body.innerHTML = ''
})
if you wanted to fire an event that doesn’t have a dedicated method (like mouseover) -> using button.dispatchEvent
:
// define the event
const incrementClickEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
button: 0,
})
// then dispatch it
const [button] = div.querySelector('button')
button.dispatchEvent(incrementClickEvent)
React Testing Library
It simplifies the process of testing React components. It provides light utility functions on top of the DOM Testing Library, in a way that encourages better testing practices.
import {render, fireEvent, screen} from '@testing-library/react'
test('it works', () => {
const {container} = render(<Example />)
// container is the div that your component has been mounted onto.
const input = container.querySelector('input')
fireEvent.mouseEnter(input) // fires a mouseEnter event on the input
screen.debug() // logs the current state of the DOM (with syntax highlighting!)
})
Pros:
- lack of
cleaning
functionality. It has an auto-cleanup feature 🎉 - handling of React’s
act
function - the
fireEvent
is wrapped inact
so we don’t need to do it ourselves.- There are multiple actions in the
fireEvent
API, likeclick
,change
,submit
, etc.
- There are multiple actions in the
Learn more about the act:
The act
warning from React is there to tell us that something happened to our component when we weren’t expecting anything to happen. So you’re supposed to wrap every interaction you make with your component in act
to let React know that we expect our component to perform some updates.
Improve asserts with @testing-library/jest-dom
There are more matchers that you can use to improve your assertions. For example, you can use toBeVisible
to assert that an element is visible.
See https://github.com/testing-library/jest-dom for more info.
// expect(message.textContent).toBe('Current count: 1')
expect(message).toHaveTextContent('Current count: 1')
There are two ways of adding it:
- add it in the
setupFilesAfterEnv
in the jest.config.js file - add it in the
setupTest,js
file
Avoid implementation details
“Implementation details” is a term referring to how an abstraction accomplishes a certain outcome.
Read more about it:
screen
- Testing Implementation Details
- Avoid the Test User
- The queries built-into React Testing Library.
https://testing-playground.com/ to help you how to get the DOM queries right.
fireEvent
vs userEvent
When a user clicks a button, they first have to move their mouse over the button which will fire some mouse events. They’ll also mouse down and mouse up on the input and focus it! Lots of events! That’s done with the userEvent
.
fireEvent
just fires whatever events you tell it to fire, but userEvent
fires many other events.
Form Testing
test('submitting the form calls onSubmit with username and password', async () => {
let submittedData
const handleSubmit = data => (submittedData = data)
render(<Login onSubmit={handleSubmit} />)
const username = 'chucknorris'
const password = 'i need no password'
await userEvent.type(screen.getByLabelText(/username/i), username)
await userEvent.type(screen.getByLabelText(/password/i), password)
await userEvent.click(screen.getByRole('button', {name: /submit/i}))
expect(submittedData).toEqual({
username,
password,
})
})
Rather than that, we want to use a mock function to test that the onSubmit
function is called with the right data.
const handleSubmit = jest.fn()
render(<Login onSubmit={handleSubmit} />)
const username = 'chucknorris'
const password = 'i need no password'
await userEvent.type(screen.getByLabelText(/username/i), username)
await userEvent.type(screen.getByLabelText(/password/i), password)
await userEvent.click(screen.getByRole('button', {name: /submit/i}))
expect(handleSubmit).toHaveBeenCalledWith({
username,
password,
})
expect(handleSubmit).toHaveBeenCalledTimes(1)
We can always use faker to generate the data for our forms.
function buildLoginForm(overrides) {
return {
username: faker.internet.userName(),
password: faker.internet.password(),
...overrides,
}
}
Even better, we could use @jackfranklin/test-data-bot
const buildLoginForm = build({
fields: {
username: fake(f => f.internet.userName()),
password: fake(f => f.internet.password()),
},
})
Mocking HTTP responses
Use msw to mock HTTP responses.
Mocking Modules
Sometimes, a module is doing something you don’t want to actually do in tests. Jest makes it relatively simple to mock a module:
// math.js
export const add = (a, b) => a + b
export const subtract = (a, b) => a - b
// __tests__/some-test.js
import {add, subtract} from '../math'
jest.mock('../math')
// now all the function exports from the "math.js" module are jest mock functions
// so we can call .mockImplementation(...) on them
// and make assertions like .toHaveBeenCalledTimes(...)
Additionally, if you’d like to mock only parts of a module, you can provide your own “mock module getter” function:
jest.mock('../math', () => {
const actualMath = jest.requireActual('../math')
return {
...actualMath,
subtract: jest.fn(),
}
})
// now the `add` export is the normal function,
// but the `subtract` export is a mock function.
To learn a bit about how this works, take a look at my repo how-jest-mocking-works. It’s pretty fascinating.
testing with context
You can provide a wrapper
option and that will ensure that rerenders are wrapped as well:
function Wrapper({children}) {
return <ContextProvider>{children}</ContextProvider>
}
const {rerender} = render(<ComponentToTest />, {wrapper: Wrapper})
rerender(<ComponentToTest newProp={true} />)
📜 https://testing-library.com/docs/react-testing-library/api#wrapper
If we create a function for the Wrapper, we can pass params to it:
import {render, screen} from '@testing-library/react'
import {ThemeProvider} from '../../components/theme'
import EasyButton from '../../components/easy-button'
function renderWithProviders({theme = 'light'} = {}) {
const Wrapper = ({children}) => (
<ThemeProvider initialTheme={theme}>{children}</ThemeProvider>
)
return render({wrapper: Wrapper})
}
// Doing the wrapper more general:
// function renderWithProviders(ui, {theme = 'light', ...options} = {}) {
// const Wrapper = ({children}) => (
// <ThemeProvider initialTheme={theme}>{children}</ThemeProvider>
// )
// return render(ui, {wrapper: Wrapper, ...options})
// }
test('renders with the light styles for the light theme', () => {
renderWithProviders(<EasyButton>Easy</EasyButton>)
const button = screen.getByRole('button', {name: /easy/i})
expect(button).toHaveStyle(`
background-color: white;
color: black;
`)
})
test('renders with the dark styles for the dark theme', () => {
renderWithProviders(<EasyButton>Easy</EasyButton>, {
theme: 'dark',
})
const button = screen.getByRole('button', {name: /easy/i})
expect(button).toHaveStyle(`
background-color: black;
color: white;
`)
})
testing custom hooks
The easiest and most straightforward way to test a custom hook is to create a component that uses it and then test that component instead.
When the custom hook is complex and it’s hard to create a component for testing it, we can create a TestComponent
that contains the hook and then test the TestComponent
.
// Custom hook
function useCounter({initialCount = 0, step = 1} = {}) {
const [count, setCount] = React.useState(initialCount)
const increment = () => setCount(c => c + step)
const decrement = () => setCount(c => c - step)
return {count, increment, decrement}
}
test('exposes the count and increment/decrement functions', () => {
// here we create the TestComponent that contains the hook
let result
function TestComponent() {
result = useCounter()
return null
}
// we renders it
render(<TestComponent />)
expect(result.count).toBe(0)
// we interact with the hook
// we need to wrap it in act as the component will be re-rendered
act(() => result.increment())
expect(result.count).toBe(1)
act(() => result.decrement())
expect(result.count).toBe(0)
})
Learn more about this approach in: How to test custom React hooks
The best option is to use the renderHook
function from @testing-library/react
// custom hook
function useCounter({initialCount = 0, step = 1} = {}) {
const [count, setCount] = React.useState(initialCount)
const increment = () => setCount(c => c + step)
const decrement = () => setCount(c => c - step)
return {count, increment, decrement}
}
test('allows customization of the initial count', () => {
const {result} = renderHook(useCounter, {initialProps: {initialCount: 3}})
// Important!! the result is an object with a `current` property
expect(result.current.count).toBe(3)
})
// another test that use rerender
test('the step can be changed', () => {
const {result, rerender} = renderHook(useCounter, {
initialProps: {step: 3},
})
expect(result.current.count).toBe(0)
act(() => result.current.increment())
expect(result.current.count).toBe(3)
rerender({step: 2})
act(() => result.current.decrement())
expect(result.current.count).toBe(1)
})
Tips
screen.debug()
is a great way to see what’s going on in your test.- use
toMatchInlineSnapshot
to make sure that the snapshot is up to date.
expect(screen.getByRole('alert').textContent).toMatchInlineSnapshot(
`"password required"`,
)