Testing
- Arrange-Act-Assert pattern works great with React
// pseudo tests using AAA, read it as prose
describe('wrap everything in a describe', () => {
/* 1 */ beforeEach(() => {
// setup needed for every test
// ARRANGE
})
describe('group main functionality in 1st level of describes', () => {
/* 2 */ beforeEach(() => {
// do something to change the state
// use a `beforeEach` even if there is only one `it`
// it will be easier to extend later
// ARRANGE & ACT
})
it('should respond to the change', () => {
// test the outcome
// ASSERT
})
describe('use nested describes to dive into branches', () => {
/* 3 */ beforeEach(() => {
// do something that changes the state further
// ARRANGE & ACT
})
it('should respond to the change', () => {
// assertion given that 1, 2, 3 executed
// ASSERT
})
it('should follow the structure of your code', () => {
// it should be easy to find where new tests need to go
// ASSERT
})
})
})
// #region events
describe('example events', () => {
describe('when the user types', () => {
beforeEach(() => {
// find input element
// ARRANGE
// fire change event so that it is invalid
// ACT
})
it('should validate input', () => {
// find error
// match text in error
// ASSERT
})
describe('when the input is valid', () => {
beforeEach(() => {
// fire a change event so that the input is valid
// ACT
})
it('should validate input', () => {
// find error
// expect that there are none
// ASSERT
})
describe('when the user submits', () => {
beforeEach(() => {
// find submit button
// click it
// ARRANGE & ACT
})
it('should call api', () => {
// expect mock to have been called with form data
// ASSERT
})
})
})
})
})
// #endregion
// #region a11y
describe('example a11y', () => {
describe('when the accordion is closed', () => {
beforeEach(() => {
// find the toggle button
// ARRANGE
})
it('should have aria-expanded=false', () => {
// expect the toggle button to have the attribute
// ASSERT
})
describe('when the accordion is open', () => {
beforeEach(() => {
// click the toggle
// ACT
})
it('should have aria-expanded=true', () => {
// expect the toggle button to have the attribute
// ASSERT
})
describe('when the accordion is closed', () => {
beforeEach(() => {
// click the toggle
// ACT
})
it('should have aria-expanded=false', () => {
// expect the toggle button to have the attribute
// ASSERT
})
})
})
})
})
// #endregion
// #region non-happy
describe('example non-happy paths', () => {
describe('when the api times out', () => {
beforeEach(() => {
// use mock timers
// set mock to return unresolved promise
// ARRANGE
// action that calls into api
// ACT
})
it('should show spinner', () => {
// find spinner
// expect that it exists
// ASSERT
})
describe('when request times out', () => {
beforeEach(() => {
// run timers
// ACT
})
it('should show error', () => {
// find error
// match text
// ASSERT
})
})
})
describe('when api errors', () => {
beforeEach(() => {
// set mock to return unresolved promise
// ARRANGE
// action that calls into api
// ACT
})
it('should show error', () => {
// find error
// match text
// ASSERT
})
})
})
// #endregion
})
export const dummy = null
GifFinder
- let’s test a component that uses state & effects to find gifs
import React, { FC, useEffect, useState } from 'react'
import classes from './example.module.css'
import { TenorAPI } from './tenor-api'
export interface GifFinderProps {
onFound?(url: string): void
}
export const GifFinder: FC<GifFinderProps> = ({ onFound }) => {
const [query, setQuery] = useState('')
const [results, setResults] = useState<string[] | null>(null)
useEffect(() => {
let shouldUpdate = true
if (query) {
TenorAPI.search(query).then((newResults) => {
if (shouldUpdate) setResults(newResults)
})
}
return () => {
shouldUpdate = false
}
}, [query])
return (
<section>
<label>
find a gif
<input
placeholder="query"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</label>
{results && (
<div className={classes.grid}>
{results.map((result) => (
<button
type="button"
key={result}
className={classes.button}
onClick={() => onFound && onFound(result)}
aria-label="gif"
>
<img className={classes.image} src={result} alt="gif" />
</button>
))}
</div>
)}
</section>
)
}
export const Example: FC = () => {
const [gif, setGif] = useState<string | null>(null)
return gif ? (
<div>
<img src={gif} alt="gif" />
<p>found gif</p>
<button type="button" onClick={() => setGif(null)}>
find new gif
</button>
</div>
) : (
<GifFinder onFound={setGif} />
)
}
export default <Example />
Testing GifFinder
The more your tests resemble the way your software is used, the more confidence they can give you.
create-react-app
comes with@testing-library
- works with angular, vue, plain DOM, etc…
- no selectors, forcing you to write accessible components
- alternatively, use enzyme
import {
act,
cleanup,
fireEvent,
render,
RenderResult,
} from '@testing-library/react'
import React from 'react'
import { GifFinder } from './example'
import { TenorAPI } from './tenor-api'
describe('GifFinder', () => {
let component: RenderResult
let onFound: jest.Mock
beforeEach(() => {
onFound = jest.fn()
component = render(<GifFinder onFound={onFound} />)
})
afterEach(() => {
cleanup()
})
it('should snapshot', () => {
expect(component.container).toMatchSnapshot()
})
describe('when searching', () => {
let queryInput: HTMLElement
let searchSpy: jest.SpyInstance
beforeEach(async () => {
const searchPromise = Promise.resolve(['gif1', 'gif2', 'gif3'])
searchSpy = jest.spyOn(TenorAPI, 'search').mockReturnValue(searchPromise)
queryInput = component.getByLabelText('find a gif')
fireEvent.input(queryInput, { target: { value: 'react' } })
await act(async () => {
await searchPromise
})
})
afterEach(() => {
searchSpy.mockRestore()
})
it('should start a search', () => {
expect(searchSpy).toHaveBeenCalledWith('react')
expect(searchSpy).toHaveBeenCalledTimes(1)
})
it('should render the gifs', () => {
expect(component.container).toMatchSnapshot()
})
describe('when clicking a gif', () => {
beforeEach(() => {
const [, gif2] = component.getAllByLabelText('gif')
fireEvent.click(gif2)
})
it('should call onFound', () => {
expect(onFound).toHaveBeenCalledWith('gif2')
expect(onFound).toHaveBeenCalledTimes(1)
})
})
})
})