Test React Components With Jest and React Testing Library
Test React Components With Jest and React Testing Library
and react-testing-library
Description
If you want to ship your applications with confidence̶and of
course you do̶you need an excellent suite of automated tests to
make absolutely sure that when changes reach your users,
nothing gets broken. To get this confidence, your tests need to
realistically mimic how users actually use your React components.
Otherwise, tests could pass when the application is broken in the
real world.
favorite-number.js
handleChange = event => {
this.setState({numberEntered: true, number:
Number(event.target.value)})
}
render() {
const {number, numberEntered} = this.state
const {min, max} = this.props
const isValid = !numberEntered || (number >=
min && number <= max)
return (
<div>
<label htmlFor="favorite-number">Favorite
Number</label>
<input
id="favorite-number"
type="number"
value={number}
onChange={this.handleChange}
/>
{isValid ? null : (
<div data-testid="error-message">The
number is invalid</div>
)}
</div>
)
}
[00:28] Let's go ahead and write a basic test for this in react-
dom.js. I'll just say
test('renders a number input with a label "Favorite
Number"') I'm going to need to import {FavoriteNumber}
from '../favorite-number' and then we'll render that.
Because we're rendering it using JSX, we're going to need to
import React from 'react'.
[00:51] We're going to want to use ReactDOM.render to render
this to a <div>. Let's go ahead and import import ReactDOM
from 'react-dom' and then we'll need to create that <div>.
react-dom.js
Console Output
<div><label for="favorite-number">Favorite
Number</label>
<input id="favorite-number" type="number"
value="0"></div>
[01:19] Cool, so let's go ahead and add a couple of assertions
here. We'll
expect(div.querySelector('input').type).toBe('number')
and
expect(div.querySelector('label').textContent).toBe('F
avorite Number').
react-dom.js
expect(div.querySelector('input').type).toBe('nu
mber')
expect(div.querySelector('label').textContent).t
oBe('Favorite Number')
})
[01:38] That gets our test passing. Let's just go ahead and make
sure that our test can fail. It fails stupendously! So we know our
assertions are running.
jest-dom.js
expect(div.querySelector('input').type).toBe('nu
mber')
expect(div.querySelector('label').textContent).t
oBe('Favorite Number')
})
[00:08] If we were to make a mistake here and type-o the "i" out
of expect(div.querySelector('input'), we're going to get an
error that says, TypeError, cannot read property 'type' of null.
[00:16] Now, that's not exactly the most helpful error message at
all when you have to inspect things to figure out what exactly is
wrong. It would be nice if we could get an assertion that could be
more helpful when something goes wrong.
[00:26] There's a library called jest-dom that we can use to
extend expect so we can add some assertions that are specific to
DOM nodes.
expect.extend({toHaveAttribute})
expect(div.querySelector('input')).toHaveAttribu
te('number')
expect(div.querySelector('label').textContent).t
oBe('Favorite Number')
})
[00:55] Now if I save this, the error message will be a little bit
more helpful. It says, received value must be an HTMLElement or
an SVGElement. Received: null.
expect.extend({toHaveAttribute,
toHaveTextContent})
expect(div.querySelector('input')).toHaveAttribu
te('number')
expect(div.querySelector('label')).toHaveTextCon
tent('Favorite Number')
})
// REMOVED expect.extend({toHaveAttribute,
toHaveTextContent})
expect(div.querySelector('input')).toHaveAttribu
te('number')
expect(div.querySelector('label')).toHaveTextCon
tent('Favorite Number')
})
dom-testing-library.js
test('renders a number input with a label
"Favorite Number"', () => {
const div = document.createElement('div')
ReactDOM.render(<FavoriteNumber />, div)
expect(div.querySelector('input')).toHaveAttribu
te('number')
expect(div.querySelector('label')).toHaveTextCon
tent('Favorite Number')
})
favorite-number.js
return (
<div>
<label htmlFor="favorie-number">Favorite
Number </label> <!-- typo in htmlFor -->
<input
id="favorite-number"
type="number"
value={number}
onChange={this.handleChange}
/>
{isValid ? null : <div>The number is
invalid</div>}
</div>
)
dom-testing-library.js
expect(div.querySelector('input')).toHaveAttribu
te('number')
expect(div.querySelector('label')).toHaveTextCon
tent('Favorite Number')
})
[02:44] That got us the input that's associated with the label
text, and we can make assertions on that input. Let's go ahead
and make a couple refactors here. First of all, we're mostly
concerned about the user being able to interact with our
component and the user doesn't actually care about the casing.
[03:09] Let's go ahead and make this a little bit more resilient.
We'll use a Regex instead. We'll say the case doesn't matter.
Then, we can just lower case everything. As the casing changes,
our test continue to pass. This is all the user really cares about
anyway.
test('renders a number input with a label
"Favorite Number"', () => {
const div = document.createElement('div')
ReactDOM.render(<FavoriteNumber />, div)
const input = queries.getByLabelText(div,
/favorite number/i)
expect(input).toHaveAttribute('number')
})
react-testing-library.js
function render(ui) {
[00:12] Then we're going to create our div and render and get
our getQueriesForElement inside of this render function. I'm
going to go ahead and change this.
[00:20] We'll call this container just to make that a little bit
more specific. We're going to return an object called container.
Then we actually want all of the queries. We'll spread the
queries across here.
react-testing-library.js
function render(ui) {
const container =
document.createElement('div')
ReactDOM.render(<FavoriteNumber />, container)
const queries =
getQueriesForElement(container)
return {
container,
...queries,
}
}
react-testing-library.js
function render(ui) {
const container =
document.createElement('div')
ReactDOM.render(ui, container) // replaced
<FavoriteNumber/> with ui
...
}
[01:02] Now I can use this render method for all the tests that
are trying to render a React component. It makes my tests really
nice and slim.
[01:21] Here, we can get rid of render entirely. Get rid of react-
dom. We don't need that any more,
and dom-testing-library. That's all handled by react-
testing-library with render. Then I'll hit save. I pop open my
tests. They continue to pass.
react-testing-library.js
import 'jest-dom/extend-expect'
import React from 'react'
import {render} from 'react-testing-library'
import {FavoriteNumber} from '../favorite-
number'
[00:09] This ensures that React's event system will work properly.
If I go ahead and console.log(document.body.outerHTML) and
open up my tests, I'm going to see body is rendered and it has a
div inside. That's our container.
Console Output
console.log src/__tests__/react-testing-
library.js:8
<body><div><div><label for="favorite-
number">FavoriteNumber</label><input
id="favorite-number" type="number" value="0"/>
</div></div></body>
react-testing-library.js
test('renders a number input with a label
"Favorite Number"', () => {
const {getByLabelText, unmount} =
render(<FavoriteNumber />)
console.log(document.body.outerHTML)
const input = getByLabelText(/favorite
number/i)
expect(input).toHaveAttribute('type',
'number')
unmount()
console.log(document.body.outerHTML)
})
Console Output
console.log src/__tests__/react-testing-
library.js:8
<body><div><div><label for="favorite-
number">FavoriteNumber</label><input
id="favorite-number" type="number" value="0"/>
</div></div></body>
console.log src/__tests__/react-testing-
library.js:12
<body><div></div></body>
[00:57] Now, that would be really annoying to have to do all over
the place, so react-testing-library exposes a cleanup
function that we can use. We no longer need unmount here and
we can replace this with cleanup.
react-testing-library.js
[01:16] We'll cleanup after each one of our tests. Then we can
get rid of this console.log intest. We'll add aconsole.logup
inafterEach` as well. That's working just fine.
import 'react-testing-library/cleanup-after-
each'
import {render} from 'react-testing-library'
react-testing-library.js
Console Output
console.log node_modules/react-testing-library-
dist/index.js:57
<body>
<div>
<div>
<label for="favorite-number">
Favorite Number
</label>
<input
id="favorite-number"
type="number"
value="0"
>
</div>
</div>
</body>
react-testing-library.js
test('renders a number input with a label
"Favorite Number"', () => {
const {getByLabelText, debug} =
render(<FavoriteNumber />)
const input = getByLabelText(/favorite
number/i)
expect(input).toHaveAttribute('type',
'number')
debug()
})
react-testing-library.js
favorite-number.js
render() {
const {number, numberEntered} = this.state
const {min, max} = this.props
const isValid = !numberEntered || (number >=
min && number <= max)
return (
<div>
<label htmlFor="favorite-number">Favorite
Number</label>
<input
id="favorite-number"
type="number"
value={number}
onChange={this.handleChange}
/>
{isValid ? null : (
<div data-testid="error-message">The
number is invalid</div>
)}
</div>
)
}
favorite-number.js
[00:53] We could provide a min and a max here, but I'll go ahead
and rely on the defaults. That's part of the API of our component
anyway.
state.js
import 'jest-dom/extend-expect'
import 'react-testing-library/cleanup-after-
each'
import React from 'react'
import {render} from 'react-testing-library'
import {FavoriteNumber} from '../favorite-
number'
state.js
import {render, fireEvent} from 'react-testing-
library'
[01:24] Our change handler takes the event and gets the
target.value, so we need to set the target.value to a number
that's outside of this min-max range. Let's go ahead. We'll set
target: {value: 10}, a number outside of the min-max range.
state.js
[01:40] Then let's go ahead and take a look at what the DOM
looks like. I'm going to pull out debug. We'll call debug right
before we do anything and debug right after we do things.
state.js
[01:49] Pop open our terminal. We'll see our label and input
our render here. We get our label and input. The number is
invalid because our number is outside of the range.
Console Output
<body>
<div>
<div>
<label for="favorite-number">
Favorite Number
</label>
<input
id="favorite-number"
type="number"
value="10"
>
<div>
The number is invalid
</div>
</div>
</div>
</body>
state.js
state.js
favorite-number.js
<div>
<label for="favorite-number">Favorite
Number</label>
<input
id="favorite-number"
type="number"
value={number}
onChange={this.handleChange}
/>
{isValid ? null : <div>The number is
invalid</div>}
</div>
state.js
test('entering an invalid value shows an error
message', () => {
const {getByLabelText, debug, getByTestId} =
render(<FavoriteNumber />)
const input = getByLabelText(/favorite
number/i)
fireEvent.change(input, {target: {value: 10}})
expect(getByTestId('error-
message')).toHaveTextContent(
/the number is invalid/i,
)
})
[01:56] Those are the various ways you can find text that's
rendered in your component. Whether you use
toHaveTextContent on your entire container or specifically with
a getByTestId to target a specific element or if you try to use
getByText, each of them comes with their own tradeoffs.
Console Output
<body>
<div>
<div>
<label for="favorite-number">
Favorite Number
</label>
<input
id="favorite-number"
type="number"
value="10"
>
<div>
The number is invalid
</div>
</div>
</div>
</body>
state.js
favorite-number.js
static defaultProps = {min: 1, max: 10} // max
changed to 10
...
render() {
...
const isValid = !numberEntered || (number >=
min && number <= max)
return (
<div>
<label htmlFor="favorite-number">Favorite
Number</label>
<input
id="favorite-number"
type="number"
value={number}
onChange={this.handleChange}
/>
{isValid ? null : (
<div data-testid="error-message">The
number is invalid</div>
)} <!-- no longer renders -->
</div>
)
}
[00:46] We can pass any different props that we want. When you
say max={10}, and then I will put a debug() after that.
prop-updates.js
[00:53] Here we have a before where the value is 10, and we see
the number is invalid. Then we have the after where the value is
still 10, so the error message goes away. Now we can make an
assertion that that error message has gone away.
Output Before
<body>
<div>
<div>
<label for="favorite-number">
Favorite Number
</label>
<input
id="favorite-number"
type="number"
value="10"
>
<div>
The number is invalid
</div>
</div>
</div>
</body>
Output After
<body>
<div>
<div>
<label for="favorite-number">
Favorite Number
</label>
<input
id="favorite-number"
type="number"
value="10"
>
</div>
</div>
</body>
state.js
test('entering an invalid value shows an error
message', () => {
const {getByLabelText, debug, getByTestId,
rerender} = render(
<FavoriteNumber />
)
const input = getByLabelText(/favorite
number/i)
fireEvent.change(input, {target: {value: 10}})
expect(getByTestId('error-
message')).toHaveTextContent(
/the number is invalid/i,
)
debug()
rerender(<FavoriteNumber max={10} />)
debug()
})
[00:40] The get queries will throw an error when it can't find
whatever it's looking for. That applies to getByLabel, getByText,
all of the get queries. In our case, we want to find something that
we know is not there and make sure that it's not there.
a11y.js
import 'jest-dom/extend-expect'
import 'react-testing-library/cleanup-after-
each'
import React from 'react'
import {render} from 'react-testing-library'
function Form() {
return (
<form>
<input placeholder="username"
name="username" />
</form>
)
}
[01:02] I'll need to turn this test into an async test. Now if I
console.log(results), I'm going to get a bunch of violations
here and a lot of information that I can't really make a whole lot
of sense of in my terminal here.
expect.extend(toHaveNoViolations)
...
[01:54] It tells me exactly what the node was that is causing that
violation. It gives me some helpful information to go look into to
find out why I'm experiencing that violation.
Console Output
Expected the HTML found at $('input') to have no
violations:
Received:
a11y.js
function Form() {
return (
<form>
<label htmlFor="username">Username</label>
<input id="username"
placeholder="username" name="username" />
</form>
)
}
[02:24] One thing I can do to clean this up is along with these
two imports -- which I should be putting in a setup file -- I can
also import 'jest-axe/extend-expect'. Then I don't need to
import this toHaveNoViolations into every file or call
expect.extend.
import 'jest-dom/extend-expect'
import 'react-testing-library/cleanup-after-
each'
import 'jest-axe/extend-expect'
greeting-loader-01-mocking.js
import React, {Component} from 'react'
import {loadGreeting} from './api'
[00:18] Let's go ahead and write a test for this. We're going to
import React from 'react'. We'll import {render} from
'react-testing-library'. We'll import {GreetingLoader}
from '../greeting-loader-01-mocking'.
http-jest-mock.js
import 'jest-dom/extend-expect'
import 'react-testing-library/cleanup-after-
each'
[00:49] We'll also need to get this greeting div by its data-
testid. With all of those, we need to getByLabelText,
getByText, and getByTestId. We can get our nameInput. That's
getByLabelText(/name/i).
http-jest-mock.js
import {render, fireEvent} from 'react-testing-
library'
greeting-loader-01-mocking.js
[02:24] We'll return just the stuff that we need, so just this
loadGreeting. That's going to be a jest.fn so we can keep
track of how it's called.
jest.mock('../api', () => {
return {
loadGreeting: jest.fn()
}
})
jest.mock('../api', () => {
return {
loadGreeting: jest.fn(subject =>
Promise.resolve({data: {greeting: `Hi
${subject}`}}),
),
}
})
expect(mockLoadGreeting).toHaveBeenCalledTimes(1
)
expect(mockLoadGreeting).toHaveBeenCalledWith('M
ary')
})
[03:23] We open up our test here. We've got a passing test. Let's
make sure that this test can fail. Maybe these assertions aren't
running or something.
[03:29] I'll just say, .not before calling
.toHaveBeenCalledTimes(1) Great. Our test can fail. Our
assertions are running.
jest.mock('../api', () => {
return {
loadGreeting: jest.fn(subject =>
Promise.resolve({data: {greeting: `Hi
${subject}`}}),
),
}
})
expect(mockLoadGreeting).toHaveBeenCalledTimes(1
)
expect(mockLoadGreeting).toHaveBeenCalledWith('M
ary')
await wait(() =>
expect(getByTestId('greeting')).toHaveTextConten
t())
})
dependency-injection.js
jest.mock('../api', () => {
return {
loadGreeting: jest.fn(subject =>
Promise.resolve({data: {greeting: `Hi
${subject}`}}),
),
}
})
[00:24] We're going to remove that and I'll get rid of the
jest.mock entirely. Then we'll get rid of the import from
'../api', and instead, we'll put a mockLoadGreeting right here.
// REMOVED import {loadGreeting as
mockLoadGreeting} from '../api'
// REMOVED jest.mock(...)
expect(mockLoadGreeting).toHaveBeenCalledTimes(1
)
expect(mockLoadGreeting).toHaveBeenCalledWith('M
ary')
await wait(() =>
expect(getByTestId('greeting')).toHaveTextConten
t())
})
greeting-loader-01-dependency-injection.js
import {loadGreeting} from './api'
greeting-loader-01-dependency-injection.js
class GreetingLoader extends Component {
static defaultProps = {loadGreeting}
inputRef = React.createRef()
state = {greeting: ''}
loadGreetingForInput = async e => {
e.preventDefault()
const {data} = await
this.props.loadGreeting(this.inputRef.current.va
lue)
this.setState({greeting: data.greeting})
}
...
}
http-jest-mock.js
test('loads greetings on click', () => {
const mockLoadGreeting = jest.fn(subject =>
Promise.resolve({data: {greeting: `Hi
${subject}`}})
)
const {getByLabelText, getByText, getByTestId}
= render(
<GreetingLoader loadGreeting=
{mockLoadGreeting} />
)
...
})
hidden-message.js
import {CSSTransition} from 'react-transition-
group'
mock-component.js
})
expect(getByText(myMessage)).toBeInTheDocument()
})
[01:31] If we save that, and then pop open our test here, we've
got a failing test. The reason is, if we come up here, it's going to
tell us it cannot find the getByText(myMessage). Let's go ahead
and we'll mock out our CSSTransition here so that the Fade will
render the children immediately, rather than having to wait for
the CSSTransition.
[02:07] The way that this works is, we have a Fade here. That
takes an in prop. Then we forward that prop along to
CSSTransition. CSSTransition takes an in prop to know
whether or not the children should be rendered.
hidden-message.js
import {CSSTransition} from 'react-transition-
group'
[02:20] That's exactly what our mock is going to do. We'll say
props, and if props.in, then we'll render props.children,
otherwise, we'll render null. Now, if we save that, our test is
passing.
mock-component.js
jest.mock('react-transition-group', () => {
return {
CSSTransition: props => (props.in ?
props.children : null),
}
})
expect(getByText(myMessage)).not.toBeInTheDocume
nt()
fireEvent.click(toggleButton)
expect(getByText(myMessage)).toBeInTheDocument()
})
expect(queryByText(myMessage)).not.toBeInTheDocu
ment()
fireEvent.click(toggleButton)
expect(getByText(myMessage)).toBeInTheDocument()
fireEvent.click(toggleButton)
expect(queryByText(myMessage)).not.toBeInTheDocu
ment()
})
error-boundary.js
import {reportError} from './api'
src/tests/error-boundary.js
jest.mock('../api', () => {
return {
reportError: jest.fn(() =>
Promise.resolve({success: true}))
}
})
[00:46] Next, let's go ahead and make our test and we'll say
calls reportError and renders that there was a
problem. We're going to want to render this. We'll import
{render} from 'react-testing-library'. We're going to
want to import {ErrorBoundary} from '../error-
boundary'.
...
function Bomb({shouldthrow}) {
if (shouldThrow) {
throw new Error(' ')
} else {
return null
}
}
[01:46] We'll copy all this. Paste in here and we'll say that the
<Bomb shouldThrow={true} />. We'll go ahead and save that.
Looks like I forgot to import React. Let's go ahead and do that for
sure.
import React from 'react'
...
rerender(
<ErrorBoundary>
<Bomb shouldThrow={true}/>
</ErrorBoundary>
)
})
rerender(
<ErrorBoundary>
<Bomb shouldThrow={true}/>
</ErrorBoundary>
)
expect(container).toHaveTextContent('There was
a problem.')
})
Noisy Output
[02:43] Let's go ahead and get rid of this noisy output. This is
happening from JS DOM's virtual console. When an error is
thrown, even if it's caught by an ErrorBoundary, React will log to
the console, which makes a lot of sense, but in our tests it's really
annoying.
[02:58] Let's go ahead and mock out the console. I'll say
beforeEach. We'll say jest.spyOn(console, 'error'). We'll
mockImplementation to do nothing. We'll cleanup after ourselves
with afterEach and we'll say console.error.mockRestore().
beforeEach(() => {
jest.spyOn(console,
'error').mockImplementation(() => {})
})
afterEach(() => {
console.error.mockRestore()
})
[03:18] If we save that, now we should be free of all those
console warnings, but we want to make sure that the
console.error is called the right number of times, because now,
we don't have any insight into console.error.
rerender(
<ErrorBoundary>
<Bomb shouldThrow={true}/>
</ErrorBoundary>
)
expect(container).toHaveTextContent('There was
a problem.')
expect(console.error).toHaveBeenCalledTimes(2)
})
[04:18] It's an array of calls. There should only be one. I'll just
grab that first one with
console.log(mockReportError.mock.calls[0]) and we have
the Error: , and so on and so forth. It's just a lot of stuff
here. Let's go ahead and get the .length here on this log.
Error
rerender(
<ErrorBoundary>
<Bomb shouldThrow={true}/>
</ErrorBoundary>
)
expect(mockReportError).toHaveBeenCalledTimes(1)
const error = expect.any(Error)
const info = {componentStack:
expect.stringContaining('Bomb')}
expect(mockReportError).toHaveBeenCalledWith(err
or, info)
expect(container).toHaveTextContent('There was
a problem.')
expect(console.error).toHaveBeenCalledTimes(2)
})
[05:15] Perfect. All right, we've got a pretty good feel of this
ErrorBoundary component, but there is one last thing that we
don't have covered in ErrorBoundary component. We could write
a new test for this, but we've already set up ourselves with some
pretty good state in this test.
[05:40] Let's go ahead and we're going to clear out our state.
We'll say console.error.mockClear(). We'll say
mockReportError.mockClear(). With that we can rerender this
whole thing. Except this time, we'll say that it should not throw.
test(`calls reportError and renders that there
was a problem`, () => {
...
expect(mockReportError).toHaveBeenCalledTimes(1)
const error = expect.any(Error)
const info = {componentStack:
expect.stringContaining('Bomb')}
expect(mockReportError).toHaveBeenCalledWith(err
or, info)
expect(container).toHaveTextContent('There was
a problem.')
expect(console.error).toHaveBeenCalledTimes(2)
console.error.mockClear()
mockReportError.mockClear()
rerender(
<ErrorBoundary>
<Bomb />
</ErrorBoundary>
)
})
...
...
fireEvent.click(getByText(/try again/i))
})
console.error.mockClear()
mockReportError.mockClear()
rerender(
<ErrorBoundary>
<Bomb />
</ErrorBoundary>
)
fireEvent.click(getByText(/try again/i))
expect(mockReportError).not.toHaveBeenCalled()
expect(container).not.toHaveTextContent('There
was a problem.')
expect(console.error).not.toHaveBeenCalled()
})
function Bomb({shouldthrow}) {
if (shouldThrow) {
throw new Error(' ')
} else {
return null
}
}
beforeEach(() => {
jest.spyOn(console,
'error').mockImplementation(() => {})
})
afterEach(() => {
console.error.mockRestore()
})
rerender(
<ErrorBoundary>
<Bomb shouldThrow={true}/>
</ErrorBoundary>
)
expect(mockReportError).toHaveBeenCalledTimes(1)
const error = expect.any(Error)
const info = {componentStack:
expect.stringContaining('Bomb')}
expect(mockReportError).toHaveBeenCalledWith(err
or, info)
expect(container).toHaveTextContent('There was
a problem.')
expect(console.error).toHaveBeenCalledTimes(2)
...
})
[07:44] We cleared out our console.error and
mockReportError. We've rrendered with the Bomb that does not
explode and we clicked on that tryAgain to reset the state of the
ErrorBoundary, so it will rerender its children.
console.error.mockClear()
mockReportError.mockClear()
rerender(
<ErrorBoundary>
<Bomb />
</ErrorBoundary>
)
fireEvent.click(getByText(/try again/i))
expect(mockReportError).not.toHaveBeenCalled()
expect(container).not.toHaveTextContent('There
was a problem.')
expect(console.error).not.toHaveBeenCalled()
})
tdd-01-markup.js
tdd-01-markup.js
test('renders a form with title, content, tags,
and a submit button', () => {
const {getByLabelText, getByText} =
render(<Editor />)
getByLabelText(/title/i)
getByLabelText(/content/i)
getByLabelText(/tags/i)
getByText(/submit/i)
})
post-editor-01-markup.js
export {Editor}
Great. Now, we're onto our next error message, "Unable to find
label with the text of: /title/i"
post-editor-01-markup.js
[01:53] Unable to find a label with the text of: /content/i. We can
go ahead, and we'll just copy this. We'll change title to content.
We'll capitalize this inner text. Instead of an input, we want this
to be a textarea. Cool.
post-editor-01-markup.js
class Editor extends React.Component {
render() {
return (
<form>
<label htmlFor="title-
input">Title</label>
<input id="title-input"/>
<label htmlFor="content-
input">Content</label>
<textarea id="content-input"/>
</form>
)
}
}
[02:08] Now, we have tags to deal with. Let's go ahead, and we'll
copy this. We'll change this to tags, make this inner text capital.
Now, it's Unable find an element with the text of: /submit/i. Let's
go ahead, and we'll make a <button
type='submit'>Submit</button>. That gets our test passing.
post-editor-01-markup.js
class Editor extends React.Component {
render() {
return (
<form>
<label htmlFor="title-
input">Title</label>
<input id="title-input"/>
<label htmlFor="content-
input">Content</label>
<textarea id="content-input"/>
<label htmlFor="tags-input">Tags</label>
<input id="tags-input"/>
<button type='submit'>Submit</button>
</form>
)
}
}
[02:28] That gets our test passing. This is the red-green refactor
cycle of test-driven development.
First, you write your test for the thing that you're going to be
implementing. That makes your test red, because you haven't
implemented the thing that you're building yet.
tdd-02-markup.js
fireEvent.click(submitButton)
expect(submitButton).toBeDisabled()
})
post-editor-02-markup.js
class Editor extends React.Component {
state = {isSaving: false}
handleSubmit = e => {
e.preventDefault()
this.setState({isSaving: true})
}
render() {
return (
<form onSubmit={this.handleSubmit}>
<label htmlFor="title-
input">Title</label>
<input id="title-input"/>
<label htmlFor="content-
input">Content</label>
<textarea id="content-input"/>
<label htmlFor="tags-input">Tags</label>
<input id="tags-input"/>
tdd-02-markup.js
fireEvent.click(submitButton)
expect(submitButton).toBeDisabled()
})
[00:15] We're going to mock that in our test. To start off in our
test, I'm going to use jest.mock and we'll direct ourselves to the
path of that '../api' module. Now, we can mock out that entire
'../api' module with whatever it is that we want to.
[00:29] It's specifically what we want to mock out is the
savePost method that we're going to be using. I'll have a
savePost, and here, we'll simply do jest.fn(). We'll have our
implementation be an arrow function that returns a
Promise.resolve().
tdd-03-api.js
jest.mock('../api', () => {
return {
savePost: jest.fn(() => Promise.resolve)
}
})
[00:43] Right now, we don't really care with the resolves too just
that it returns a Promise that is resolved. Now if we want to get a
hold of this function, we need to import it. I'm going to import
savePost, and we're going to alias it to mockSavePost just so
that in our test we know that the mock version.
[01:00] We'll get that from the same path to that '../api'.
Now, we'll go down here and say,
expect(mockSavePost).toHaveBeenCalledTimes(1) I'll save
my file and that gets our test failing.
tdd-03-api.js
import {savePost as mockSavePost} from '../api'
...
fireEvent.click(submitButton)
expect(submitButton).toBeDisabled()
expect(mockSavePost).toHaveBeenCalledTimes(1)
})
Let's go ahead and make this test pass by calling savePost in this
handleSubmit.
post-editor-03-api.js
[01:31] In our test, we need to set the value of each one of these
fields, so that when the submit button is clicked, our submit
handler can get those values, and save the post. I'll go ahead and
set the .value of /title/i to 'Test Title' and the value of
/content/i to 'Test content'.
tdd-03-api.js
test('renders a form with title, content, tags,
and a submit button', () => {
const {getByLabelText, getByText} =
render(<Editor />)
getByLabelText(/title/i).value = 'Test Title'
getByLabelText(/content/i).value = 'Test
content'
getByLabelText(/tags/i).value = 'tag1,tag2'
const submitButton = getByText(/submit/i)
fireEvent.click(submitButton)
expect(submitButton).toBeDisabled()
expect(mockSavePost).toHaveBeenCalledTimes(1)
expect(mockSavePost).toHaveBeenCalledWith({
title: 'Test Title',
content: 'Test content',
tags: ['tag1', 'tag2']
})
})
[02:13] Now, we can save that. We're getting our test failure.
Let's go ahead and implement this. I need to get the value, so
that I can save this in the post. We have the title, content, and
tags. Where am I going to get those values?
post-editor-03-markup.js
class Editor extends React.Component {
state = {isSaving: false}
handleSubmit = e => {
e.preventDefault()
this.setState({isSaving: true})
savePost({
title,
content,
tags,
})
}
render() {
return (
<form onSubmit={this.handleSubmit}>
<label htmlFor="title-
input">Title</label>
<input id="title-input" name="title"/>
<label htmlFor="content-
input">Content</label>
<textarea id="content-input"
name="content"/>
<label htmlFor="tags-input">Tags</label>
<input id="tags-input" name="tags"/>
post-editor-03-api.js
tdd-03-api.js
expect(mockSavePost).toHaveBeenCalledWith({
title: 'Test Title', // duplicate strings
content: 'Test content', // duplicate
strings
tags: ['tag1', 'tag2'] // duplicate strings
})
})
fireEvent.click(submitButton)
expect(submitButton).toBeDisabled()
expect(mockSavePost).toHaveBeenCalledTimes(1)
expect(mockSavePost).toHaveBeenCalledWith(fakePo
st)
})
[04:16] If we save that, our refactor was good. Our test is still
green. There is another feature we need to implement here. That
is that a post needs to have a user that created the post needs to
have an author. I'm going to add to this assertion that this is
going to be all the property's config post as well as an authorId.
post-editor-03-markup.js
class Editor extends React.Component {
state = {isSaving: false}
handleSubmit = e => {
e.preventDefault()
this.setState({isSaving: true})
savePost({
title: title.value,
content: content.value,
tags: tags.value.split(',').map(t =>
t.trim()),
authorId: this.props.user.id
})
}
...
tdd-03-api.js
jest.mock('../api', () => {
return {
savePost: jest.fn(() => Promise.resolve)
}
})
afterEach(() => {
mockSavePost.mockClear()
})
[05:30] This keeps our test isolated and we can avoid some
confusion in the future, if we add more tests to this file. In review,
to make all of this work, we created a mock for our '../api' that
we're going to be using to save the post using the jest.mock API.
fireEvent.click(submitButton)
expect(submitButton).toBeDisabled()
expect(mockSavePost).toHaveBeenCalledTimes(1)
expect(mockSavePost).toHaveBeenCalledWith({
...fakePost,
authorId: fakeUser.id
})
})
post-editor-03-markup.js
<label htmlFor="content-
input">Content</label>
<textarea id="content-input"
name="content"/>
<label htmlFor="tags-input">Tags</label>
<input id="tags-input" name="tags"/>
There is one last refactoring I'm going to do here really and that is
to take out this object and create a newPost.
post-editor-03-markup.js
post-editor-04-markup.js
tdd-04-router-redirect.js
jest.mock('react-router', () => {
return {
Redirect: jest.fn(() => null)
}
})
tdd-04-router-redirect.js
expect(MockRedirect).toHaveBeenCalledTimes(1)
})
[00:55] Let's go ahead and make this pass. I'm going to add
some state here, redirect: false, and then down here in my
render method, I'll say if(this.state.redirect) { return
<Redirect to="/" /> }, to send the user home.
post-editor-04-markup.js
class Editor extends React.Component {
state = {isSaving: false, redirect: false}
...
render() {
if (this.state.redirect) {
return <Redirect to="/" />
}
...
post-editor-04-markup.js
tdd-04-router-redirect.js
...
tdd-04-router-redirect.js
[02:31] Like I said, wait is going to timeout after four and a half
seconds. If I make a typo here and expect it to have been called
two times, then our test is going to take a little while before it
reports that as an error, which is why it's better to limit your wait
calls to have fewer assertions in them, because if this is working,
then my test works quickly.
tdd-04-router-redirect.js
test('renders a form with title, content, tags,
and a submit button', async () => {
...
expect(MockRedirect).toHaveBeenCalledTimes(1))
expect(MockRedirect).toHaveBeenCalledWith({to:
'/'}, {})
}
})
[03:02] It's a good idea to limit what you have in your wait
callback so you get notified of breakages sooner. Let's fix that up.
tdd-04-router-redirect.js
tdd-04-router-redirect.js
afterEach(() => {
MockRedirect.mockClear()
mockSavePost.mockClear()
})
post-editor-04-markup.js
jest.mock('react-router', () => {
return {
Redirect: jest.fn(() => null)
}
})
tdd-04-router-redirect.js
[00:10] Let's go ahead and add some tests to verify that the date
was added to our and post and sent to the savePost API. The
first thing I'm going to do is, in here, I'm going to say date: new
Date().toISOString(). We get that failure here, because we
have a date right there.
tdd-05-dates.js
...
})
post-editor-05-dates.js
class Editor extends React.Component {
state = {isSaving: false, redirect: false}
handleSubmit = e => {
e.preventDefault()
const {title, content, tags} =
e.target.elements
const newPost = {
title: title.value,
content: content.value,
tags: tags.value.split(',').map(t =>
t.trim()),
date: new Date().toISOString(),
authorId: this.props.user.id,
}
this.setState({isSaving: true})
savePost(newPost).then(() =>
this.setState({redirect: true}))
}
[00:48] Our source code is fine, but our test is having a little bit of
trouble getting the date right. We need to update our assertions
so that they can be more accurate with our dates. There are some
libraries out there that help you fake out dates in your tests, but
there's actually a pretty simple way to verify this behavior without
having to do a bunch of weird things with your dates.
[01:24] Then after the user clicks save, we're going to create a
const postDate = Date.now().
tdd-05-dates.js
...
const preDate = Date.now()
getByLabelText(/title/i).value = fakePost.title
getByLabelText(/content/i).value =
fakePost.content
getByLabelText(/tags/i).value =
fakePost.tags.join(', ')
const submitButton = getByText(/submit/i)
fireEvent.click(submitButton)
expect(submitButton).toBeDisabled()
expect(mockSavePost).toHaveBeenCalledTimes(1)
expect(mockSavePost).toHaveBeenCalledWith({
...fakePost,
date: new Date().toISOString(),
authorId: fakeUser.id,
})
If the date that the post is created with is between the preDate
and the postDate, then that's good enough for me. Instead of the
setting the date to a new Date() here, we're going to go ahead
and we'll say expect any string.
expect(mockSavePost).toHaveBeenCalledWith({
...fakePost,
date: expect.any(String),
authorId: fakeUser.id,
})
[02:00] It has a mock property. .calls These are the times that
it was called and this is an array of its calls. [0] This is the first
call. [0] This is the first argument of that call, and .date this is
the date property of that object it was called with.
[02:13] I'm going to call that our date, and we're going to take
that ISOString, and create a new Date() object out of that. We
can call getTime(). That's going to give us a number, and then
we can expect(date).toBeGreaterThanOrEqual(preDate).
Now, we can save this, and our test is passing fine. In review,
what we did to our implementation is we needed to add the date
here to the handleSubmit. We added it as an ISOString, so then
it could be saved to the server.
post-editor-05-dates.js
handleSubmit = e => {
e.preventDefault()
const {title, content, tags} =
e.target.elements
const newPost = {
title: title.value,
content: content.value,
tags: tags.value.split(',').map(t =>
t.trim()),
date: new Date().toISOString(),
authorId: this.props.user.id,
}
[02:47] Then in our test, we created a date range, so before we
created that new Date() and our after we created the new
Date(). Then we verified that the date our mockSavePost was
called with is between the preDate and the postDate range.
That's good enough for us to verify that the date was created with
the value it's supposed to have.
tdd-05-dates.js
...
const preDate = Date.now()
getByLabelText(/title/i).value = fakePost.title
getByLabelText(/content/i).value =
fakePost.content
getByLabelText(/tags/i).value =
fakePost.tags.join(', ')
const submitButton = getByText(/submit/i)
fireEvent.click(submitButton)
expect(submitButton).toBeDisabled()
expect(mockSavePost).toHaveBeenCalledTimes(1)
expect(mockSavePost).toHaveBeenCalledWith({
...fakePost,
date: expect.any(String),
authorId: fakeUser.id,
})
tdd-06-generate-data.js
tdd-06-generate-data.js
import {build, fake, sequence} from 'test-data-
bot'
...
const postBuilder = build('Post').fields({
title: fake(f => f.lorem.words()),
})
tdd-06-generate-data.js
tdd-06-generate-data.js
const userBuilder = build('User').fields({
id: sequence(s => `user-${s}`)
})
[01:40] We'll say user-${s}. Great. Now here, our fakeUser can
be a userBuilder. We can build a user. Our fakePost can be a
postBuilder.
tdd-06-generate-data.js
tdd-06-generate-data.js
const postBuilder = build('Post').fields({
title: fake(f => f.lorem.words()),
content: fake(f =>
f.lorem.paragraphs().replace(/\r/g, '')),
tags: fake(f => [f.lorem.word(),
f.lorem.word(), f.lorem.word()]),
})
We save our file, and now, our test is passing. Now, if we wanted
to get the values there, we can console.log our fakeUser and
our fakePost.
tdd-06-generate-data.js
[02:44] Now, the ID is foo. Great. With that, we'll get rid of this id
of foo, and get rid of those console.logs.
Passing Test
Now, our test is communicating that the user is not important.
The post data is not important. It just needs to look something
like this for our component to work properly.
tdd-06-generate-data.js
[00:16] Let's add another test here at the end that says
'renders an error message from the server'. Now we
know this is going to be an async test, so we'll just make that
async right off the bet. Then we're going to be doing a lot of the
same things that we did up here.
tdd-07-error-state.js
})
[00:30] I'm going to go ahead and copy a bunch of this stuff all
the way through the click, and we can copy pretty much all of this
stuff. It's just down here with the MockRedirect that things are
different.
test('renders an error message from the server',
async () => {
const fakeUser = userBuilder()
const {getByLabelText, getByText} =
render(<Editor user={fakeUser} />)
const fakePost = postBuilder()
const preDate = Date.now()
getByLabelText(/title/i).value =
fakePost.title
getByLabelText(/content/i).value =
fakePost.content
getByLabelText(/tags/i).value =
fakePost.tags.join(', ')
const submitButton = getByText(/submit/i)
fireEvent.click(submitButton)
expect(submitButton).toBeDisabled()
expect(mockSavePost).toHaveBeenCalledTimes(1)
expect(mockSavePost).toHaveBeenCalledWith({
...fakePost,
date: expect.any(String),
authorId: fakeUser.id,
})
})
getByLabelText(/title/i).value =
fakePost.title
getByLabelText(/content/i).value =
fakePost.content
getByLabelText(/tags/i).value =
fakePost.tags.join(', ')
const submitButton = getByText(/submit/i)
fireEvent.click(submitButton)
})
[02:22] Let's go ahead and make this test pass by changing the
implementation slightly. We need to add an error callback, and
here's our failure. We're going to get a response, and we'll call
this.setState({error: response.data.error}).
[02:40] Then we'll want to add some state here for error, we'll
set that to null by default. We'll scroll down here and we'll add
an error right here, so we'll say this.state.error. If there is
an error, then we're going to render <div>{this.state.error}
</div>, so the message that came from the server.
post-editor-07-error-state.js
class Editor extends React.Component {
state = {isSaving: false, redirect: false,
error: null} // added error to state
handleSubmit = e => {
e.preventDefault()
const {title, content, tags} =
e.target.elements
const newPost = {
title: title.value,
content: content.value,
tags: tags.value.split(',').map(t =>
t.trim()),
date: new Date().toISOString(),
authorId: this.props.user.id,
}
this.setState({isSaving: true})
savePost(newPost).then(
() => this.setState({redirect: true}),
response => this.setState({error:
response.data.error}) // added an error response
)
}
render() {
if (this.state.redirect) {
return <Redirect to="/" />
}
return (
<form onSubmit={this.handleSubmit}>
<label htmlFor="title-
input">Title</label>
<input id="title-input" name="title" />
<label htmlFor="content-
input">Content</label>
<textarea id="content-input"
name="content" />
<label htmlFor="tags-input">Tags</label>
<input id="tags-input" name="tags" />
That gets our test passing. Now let's go ahead and see if there's
anything we'd like to refactor here about our test or our
implementation.
tdd-07-error-state.js
test('renders an error message from the server',
async () => {
mockSavePost.mockRejectedValueOnce({data:
{error: 'test error'}})
...
expect(postError).toHaveTextContent('test
error')
})
Now there's one other assertion that I think I want to put in here,
and that is if there's an error, then I want the submitButton to
no longer be disabled.
post-editor-07-error-state.js
savePost(newPost).then(
() => this.setState({redirect: true}),
response => this.setState({error:
response.data.error}) // added an error response
)
post-editor-07-error-state.js
test('renders an error message from the server',
async () => {
const testError = 'test error'
mockSavePost.mockRejectedValueOnce({data:
{error: testError}})
const fakeUser = userBuilder()
const {getByLabelText, getByText, getByTestId}
= render(
<Editor user={fakeUser} />,
)
const fakePost = postBuilder()
getByLabelText(/title/i).value =
fakePost.title
getByLabelText(/content/i).value =
fakePost.content
getByLabelText(/tags/i).value =
fakePost.tags.join(', ')
const submitButton = getByText(/submit/i)
fireEvent.click(submitButton)
post-editor-07-error-state.js
[00:12] It would be nice if we could get rid of that and shove it off
to the side, so that people who come in to maintain these tests
will be able to quickly identify what are the differences between
test one and test two?
tdd-08-custom-render.js
getByLabelText(/title/i).value =
fakePost.title
getByLabelText(/content/i).value =
fakePost.content
getByLabelText(/tags/i).value =
fakePost.tags.join(', ')
const submitButton = getByText(/submit/i)
fireEvent.click(submitButton)
expect(submitButton).toBeDisabled()
expect(mockSavePost).toHaveBeenCalledTimes(1)
expect(mockSavePost).toHaveBeenCalledWith({
...fakePost,
date: expect.any(String),
authorId: fakeUser.id,
})
expect(MockRedirect).toHaveBeenCalledWith({to:
'/'}, {})
})
getByLabelText(/title/i).value =
fakePost.title
getByLabelText(/content/i).value =
fakePost.content
getByLabelText(/tags/i).value =
fakePost.tags.join(', ')
const submitButton = getByText(/submit/i)
fireEvent.click(submitButton)
[00:22] We're going to go ahead and take lots of this, and put it
into a setup function. I'm going to call it renderEditor. It's not
going to take any arguments, and we're going to just move a
whole bunch of this stuff up here, all of this setup for our editor.
function renderEditor() {
const fakeUser = userBuilder()
const {getByLabelText, getByText} =
render(<Editor user={fakeUser} />)
const fakePost = postBuilder()
getByLabelText(/title/i).value =
fakePost.title
getByLabelText(/content/i).value =
fakePost.content
getByLabelText(/tags/i).value =
fakePost.tags.join(', ')
const submitButton = getByText(/submit/i)
}
function renderEditor() {
const fakeUser = userBuilder()
const utils = render(<Editor user={fakeUser}
/>)
const fakePost = postBuilder()
utils.getByLabelText(/title/i).value =
fakePost.title
utils.getByLabelText(/content/i).value =
fakePost.content
utils.getByLabelText(/tags/i).value =
fakePost.tags.join(', ')
const submitButton =
utils.getByText(/submit/i)
return {
...utils,
submitButton,
fakeUser,
fakePost
}
}
...
})
[01:21] We can save this, and our test is still passing. Perfect. It
was a good refactor. Now, we can do the same thing here. We'll
get rid of this duplication. Instead, we'll get the submitButton
from renderEditor. We'll also want the getByTestId query.
fireEvent.click(submitButton)
[01:55] Whereas before, they had to wade through all of this stuff
that may or may not be relevant for this specific test. The same
goes for this test of the happy path. One other thing I'd like to
mention here is that you could have multiple renders.
[02:07] If you have many tests for a specific component, you can
have different forms of renders. You could also take arguments
here in the renderEditor, pass on some parameters, and take
those for how a component should be rendered. The sky's the
limit for you on how you want to do this.
main.js
import {Switch, Route, Link} from 'react-router-
dom'
function Main() {
return (
<div>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Switch>
<Route exact path="/" component={Home}
/>
<Route path="/about" component={About}
/>
<Route component={NoMatch} />
</Switch>
</div>
)
}
react-router.js
import 'jest-dom/extend-expect'
import 'react-testing-library/cleanup-after-
each'
})
[00:57] I should be rendering the Home page right from the start.
I'm starting on the path of / so it should render my Home. I'm
going to add an assertion to expect(getByTestId('home-
screen').toBeInTheDocument().
react-router.js
import 'jest-dom/extend-expect'
import 'react-testing-library/cleanup-after-
each'
[01:32] I could also verify that the Home screen has been
removed, so .not.toBeInTheDocument. If we're going to do that,
then we need to get the queryByTestId. For good measure, I'll
do the same here, queryByTestId .not.toBeInTheDocument.
test('main renders about and home and I can
navigate to those pages', () => {
const {getByTestId, queryByTextId, getByText}
= render(<Main />)
expect(getByTestId('home-
screen').toBeInTheDocument()
expect(queryByTestId('about-
screen').not.toBeInTheDocument()
fireEvent.click(getByText(/about/i))
expect(queryByTestId('home-
screen').not.toBeInTheDocument()
expect(getByTestId('about-
screen').toBeInTheDocument()
})
[01:51] We've got a pretty good test here. Let's go ahead and
open up our test. Wow, we've got a bunch of errors. Here's the
problem. When you render a component that uses Link, Switch,
or Route from react-router-dom, these components are going
to be looking for a Router in the tree. We don't have a Router in
the tree.
[03:02] Let's make sure that they can break. I'll remove this not
here, and perfect. Our assertions are running.
react-router-02.js
test('main renders about and home and I can
navigate to those pages', () => {
const history =
createMemoryHistory({initialEntries: ['/']})
const {getByTestId, queryByTextId, getByText}
= render(
<Router history={history}>
<Main />
</Router>,
)
expect(getByTestId('home-
screen').toBeInTheDocument()
expect(queryByTestId('about-
screen').not.toBeInTheDocument()
fireEvent.click(getByText(/about/i))
expect(queryByTestId('home-
screen').not.toBeInTheDocument()
expect(getByTestId('about-
screen').toBeInTheDocument()
})
main.js
react-router-02.js
Let's verify that it's actually running our assertion, so we'll say
.not.
expect(getByTestId('no-match-
screen').not.toBeInTheDocument()
react-router-03.js
[00:46] So far this doesn't actually make any difference. Now I'm
going to go down here and we'll take this history, and we're
going to allow this route to be configured. We'll say
options.route, otherwise we'll default to a slash if the option
isn't provided.
[01:12] Now for this first test, we can remove the Router and
just render Main, but for this second test, it's going to be a little
bit different because our Router needs a special history that has
some initial entries for something that does not match. I'll cut
that, we'll get rid of the history, we'll get rid of the Router and
the Main, and here in the render function we provided that
options.route.
We'll save that, and open up our test and our tests are still
passing. Let's go ahead and clean this up just a little bit, and add
a couple of features that might be useful for other people who will
be using this render method in the future.
[01:52] I'm going to cut rtlRender, I'll return an object and I'll
spread that value across. I'll also return history, that way people
can make assertions on the history object we created for them if
they need. Then I'm going to destructure the route so that we're
not passing the route onto the options for rtlRender.
[02:10] We'll destructure this, we'll take all the options, then
we'll specify the route and default that to a slash. Then we can
just provide the route and not worry about the or here.
redux-app.js
decrement = () => {
this.props.dispatch({type: 'DECREMENT'})
}
...
}
[00:55] To get started, I'm going to add this test that says it 'can
render with redux with defaults'. Then, I'm going to use
the render method from react-testing-library. I'll import
{render} from 'react-testing-library'.
redux-01.js
redux-01.js
redux-app.js
const ConnectedCounter = connect(state =>
({count: state.count}))(Counter)
[02:56] The really nice thing about this is, well, it's not testing in
isolation, it's testing the integration which is a great thing,
because now we know that we're connecting this component
properly and that the logic in our reducer is wired up properly for
the logic in our render method.
[00:12] We'll initialize that with {count: 3}, and then instead of
incrementing we'll decrement and verify that the count value
goes from 3 to 2. We'll save this, pull up our test, and our test is
passing.
redux-02.js
[00:39] But the reason that I'm doing this here is to show you
that you can initialize the store with any state, and that can help
you get started with your test really quickly to test a specific edge
case. So we'll leave this here.
redux-03.js
[00:34] So far, we're doing exactly the same thing as it was doing
before, but now I'm going to move this createStore logic up into
this new render function. I'm going to move this Provider into
the render function too. Instead of just rendering the
connectedCounter, we'll render the ui that were given. We
don't need to render the Provider here, or here.
function render(ui, options) {
const store = createStore(reducer)
return rtlRender(<Provider store={store}>
{ui}
</Provider>, options)
}
[00:53] We'll rerun our tests and our test is actually broken.
That's because we expected to be able to have some
initialState in this one, that {count: 3}.
[01:26] Let's go head and clean this up just a little bit. I don't
want to pass this initialState to the rtlRender. I'm going to
destructure that initialState off. We'll take those options and
we'll pass initialState directly to our createStore call here.
We'll save, and our tests are still passing.
function render(ui, {initialState, ...options} =
{}) {
const store = createStore(reducer,
initialState)
return rtlRender(<Provider store={store}>{ui}
</Provider>, options)
}
function render(
ui,
{initialState, store = createStore(reducer,
initialState), ...options} = {}
) {
return rtlRender(<Provider store={store}>{ui}
</Provider>, options)
}
[02:36] In review, the reason that we're doing this, isn't just a
save a couple lines of code in these two tests.
It's because much of our test base is probably going to need to
render within a Redux Provider.
toggle.js
import React from 'react'
export {Toggle}
[00:18] Let's see how we could test this in a way that's simple
and comprehensive. I'm going to add a test that it 'renders
with on state and toggle function'. Then we're going to
need to import React from 'react', because we'll be
rendering the Toggle component.
function setup() {
const childrenArg = {}
const children = arg => {
Object.assign(childrenArg, arg)
return null
}
render(<Toggle>{children}</Toggle>)
return {
childrenArg
}
}
[02:03] Then I can get childrenArg from calling setup. In
review, the way that this works is I create a reference to an object
that I'm calling childrenArg. Then whenever Toggle renders, it's
going to call this children function.
function setup() {
const childrenArg = {}
const children = arg => {
Object.assign(childrenArg, arg)
return null
}
render(<Toggle>{children}</Toggle>)
return {
childrenArg
}
}
[02:16] Then I'll assign all the properties from the argument that
Toggle is passing to my children function onto that
childrenArg object. Because I have a reference to that, I can
check what the properties of that childrenArg are, and verify
that those properties are correct.
[02:31] This is the API that my render prop component exposes,
so this is what I'm testing. Then I can even make calls to those
functions, which should result in a rerender, and then make
additional assertions based off of what should have happened
when I called that function.
countdown.js
class Countdown extends React.Component {
state = {remainingTime: 10000}
componentDidMount() {
const end = Date.now() +
this.state.remainingTime
this.interval = setInterval(() => {
const remainingTime = end - Date.now()
if (remainingTime <= 0) {
clearInterval(this.interval)
this.setState({remainingTime: 0})
} else {
this.setState({
remainingTime,
})
}
})
}
componentWillUnmount() {
clearInterval(this.interval)
}
render() {
return this.state.remainingTime
}
}
unmounting.js
beforeEach(() => {
jest.spyOn(console,
'error').mockImplementation(() => {})
})
afterEach(() => {
console.error.mockRestore()
})
[01:52] Let's go ahead and make sure that this is doing what we
think it is by removing this clearInterval in our unmount. We'll
comment this out. We'll save and our test is still passing...
componentWillUnmount() {
// clearInterval(this.interval)
}
[02:00] What's happening is this test finishes. The program exits
so quickly that our setState call never happens. We need to
make sure that our test doesn't exit before our first setInterval
happens.
jest.useFakeTimers()
beforeEach(() => {
jest.spyOn(console,
'error').mockImplementation(() => {})
})
afterEach(() => {
console.error.mockRestore()
})
[02:29] There we get our error. We're asserting that we're not
going to call console.error, but because our interval is now
running and setState is being called, we're getting a warning
that we can't call setState or forceUpdate on an unmounted
component. It indicates a memory leak in your application.
[02:45] Let's go back. We'll restore the clearInterval. Our
component will unmount. Now our test is passing because we're
properly cleaning up after ourselves.
countdown.js
componentWillUnmount() {
clearInterval(this.interval)
}