Learn how to test drive your React code with BDD testing.
At Flatiron School, we’ve started using React to build almost all of our new client-side projects, as well as converting some of our older Backbone + Marionette apps to React. This has led to a concise and more performant code base on our front end and allowed us to write incredible apps like our in-browser Learn IDE (see “ How We Built the Learn IDE in Browser ”).
We are now 2+ years into using React in our codebase. I would like to say we’ve learned all there is to learn about React testing. However, what we’ve really seen is just a ton of tests around implementation details in our component specs. This is something Enzyme, our previous testing solution, almost encouraged with its API. There was also a fair amount of well-tested reducer specs for our Redux code. We were mostly testing implementation details (i.e. how store changes in Redux happened, etc.) versus true integration tests to see if our front end code actually functioned properly, which again, is considered a bit of an anti-pattern. We also tried our hand at Snapshot Testing, which still has its use cases, but can be limited (as all things should be that only require a dev to type “u” to update).
While investigating better testing tools, we found a new library called React Testing Library (one of the newer React testing libraries), courtesy ofKent C. Dodds . This library really allows us to focus on how our front end components are used by consumers of our application. It also ensures that we are testing functionality and not just implementation details. In the guiding principles section of its documentation it states,
The more your tests resemble the way your software is used, the more confidence they can give you.
— Kent C. Dodds
This was core to what our team was looking for in a testing solution. Let’s build a simple React App to show you what I’ve learned through using the React Testing Library.
Now! Who Wants To Build Something with Tests?
Let’s build a simple comment app with a list of comments, comment cards, and a new comment form to get a full scope of testing out for the whole feature. We will not be testing for beauty in this app, so please feel free to style as you like. This is an example mockup of the app we will be building:
We are going to use Create React App and Yarn as our node package manager to bootstrap a quick React project, then use the common testing pattern of Arrange -> Act -> Assert.
Bootstrapping the App
To get started, run these commands:
$ create-react-app comments-app
$ cd comments-app
Once app setup is complete, we need to add the React Testing Library for testing, Axios for making API requests, and PropTypes for prop validation.
$ yarn add axios prop-types
$ yarn add react-testing-library --dev
Our ./package.json file should now look something like this:
// package.json
{
// ... shortened for readability,
"dependencies": {
// versions may differ depending on when you read this,
// but this should work with any 16+ version of React
"axios": "^0.18.0",
"react": "^16.4.2",
"react-dom": "^16.4.2",
"react-scripts": "1.1.5"
},
"devDependencies": {
"react-testing-library": "^5.0.0"
},
// ...
}
Let’s trim out some files and then get started on building out some new code.
The new directory should look like this:
node\_modules/ \* don't touch anything in this folder \*
public/ \* don't touch anything in this folder \*
src/
index.js
.gitignore
package.json
README.md
yarn.lock
Now that we have our basics behind us, we can start planning our tests. Let’s take a look at how our app is going to look separated into components.
We have three core components that we will be building in this app.
- CommentCard
- CommentList
- CommentForm
I always like to start with the smallest component piece first and then build up, so let’s take that approach and write our first test for the CommentCard component.
CommentCard Component Test
Our CommentComponent looks like it has two parts: the comment and the author. Our test should verify that if given a comment and an author, it shows the right info. Let’s write our first test!
First, create some more folders and files to get ready.
$ mkdir src/components
$ mkdir src/components/__tests__
$ touch src/components/CommentCard.jsx
$ touch src/components/__tests__/CommentCard.test.jsx
Next, we need to arrange the data for a successful test. In this case, that means arranging the props that are passed to the component to render. We already know which two props our CommentCard needs (“Comment” & “Author”), so this should be a quick arrangement process.
// CommentCard.test.jsx v.1
import React from 'react'
describe('Comment Card', () => {
test('it renders the comment and the author', () => {
// Arrange
const props = {
comment: 'React Testing Library is great',
author: 'Luke Ghenco'
}
})
})
Now that our props are defined, we can begin the next stage of testing: Act. This will involve rendering the component with the props we previously implemented. We will need the render() function from the React Testing Library for this. Let’s add that to the component:
// CommentCard.test.jsx v.2
import React from 'react'
import { render } from 'react-testing-library'
import CommentCard from './CommentCard'
describe('Comment Card', () => {
test('it renders the comment and the author', () => {
// Arrange
const props = {
comment: 'React Testing Library is great',
author: 'Luke Ghenco'
}
render(<CommentCard {...props} />)
})
})
Now that we have our first test block partially finished, let’s start up the test runner. We should use the --watch flag to avoid constantly restarting it.
Run this command:
$ yarn test --watch
The terminal should now show the image below:
Oops! We know have a test running, but it looks like we need to actually create a component. Following the strategy of getting something working one piece at a time, let’s make a very simple CommentCard component.
// CommentCard.jsx v.1
import React from 'react'
const CommentCard = () => <div />
export default CommentCard
Now we have a passing test!
Well… that passes, but are we really testing anything useful? We now need to implement the final stage of testing: Assert. We should be asserting that we are actually rendering the comment and the author. To do this, we need to destructure using a helper function called getByText() that our render() function gives us through React Testing Library (if this is your first time learning about the magic of destructuring in JS, read more about it on MDN). With the getByText() method, we can assert that the comment and author props are correctly rendered inside of the component.
// CommentCard.test.3.jsx v.3
import React from 'react'
import { render } from 'react-testing-library'
import CommentCard from '../CommentCard'
describe('Coment Card', () => {
test('it renders the comment and the author', () => {
// Arrange
const props = {
comment: 'React Testing Library is great',
author: 'Luke Ghenco'
}
// Act
const { getByText } = render(<CommentCard {...props} />)
// Assert
const commentNode = getByText(props.comment)
const authorTagNode = getByText(`- ${props.author}`)
expect(commentNode).toBeDefined()
expect(authorTagNode).toBeDefined()
})
})
Take a look at the test runner, and it should show this:
Notice how it only shows the rendering of the empty <div/> in our CommentCard component? To pass this test, we need to add content to the component.
// CommentCard.jsx v.2
import React from 'react'
import PropTypes from 'prop-types'
const CommentCard = ({ comment, author }) => (
<div style={styles.card}>
<p>{comment}</p>
<p style={styles.authorTag}>- {author}</p>
</div>
)
CommentCard.propTypes = {
comment: PropTypes.string.isRequired,
author: PropTypes.string.isRequired
}
// FYI, I added some basic styling to make the
// comment cards look more like the mock.
const styles = {
card: {
margin: '24px',
padding: '2px 24px',
fontFamily: 'Palatino',
fontStyle: 'italic',
backgroundColor: '#f5f5f5',
height: '80px',
position: 'relative',
border: '1px solid #767676',
borderRadius: '8px'
},
authorTag: {
position: 'absolute',
bottom: '0',
right: '12px'
}
}
export default CommentCard
You should now see a passing test in the terminal:
Success! We created our first component test.
CommentList Component Test
Now that we’ve had some basic experience doing our first component test, let’s try our hand at a higher level integration test. This test will verify that our CommentList component renders a collection of CommentCard components.
We need to create two new files for this test: a component and component test.
$ touch src/components/CommentList.jsx
$ touch src/components/\_\_tests\_\_/CommentList.test.jsx
Just like the first test, we need to arrange our mock data. In this case, two comments:
// CommentList.test.jsx v.1
describe('Comment List', () => {
test('It renders a list of comment cards with their comment and author tag', () => {
// Arrange
const comment1 = {
id: 1,
comment: 'I do love writing tests',
author: 'The Notester'
}
const comment2 = {
id: 2,
comment: 'Nothing is better than a good comment app',
author: 'Comment Hater'
}
const props = {
comments: [comment1, comment2]
}
})
})
Our test runner should still be passing all tests. Now let’s import the component, React , and React Testing Library and call render on the CommentList component inside of our CommentList spec file.
// CommentList.test.jsx v.2
import React from 'react'
import { render } from 'react-testing-library'
import CommentList from '../CommentList'
describe('Comment List', () => {
test('It renders a list of comment cards with their comment and author tag', () => {
// Arrange
const comment1 = {
id: 1,
comment: 'I do love writing tests',
author: 'The Notester'
}
const comment2 = {
id: 2,
comment: 'Nothing is better than a good comment app',
author: 'Comment Hater'
}
const props = {
comments: [comment1, comment2]
}
// Act
render(<CommentList {...props} />)
})
})
We are now rendering the component, and if we take a look at our test runner, we should see a familiar angry red error message awaiting us - the same one we encountered in the first test file.
Just like with our first test, we need to make a simple component to pass the import and render the test.
{ /\* CommentList.jsx v.1 \*/ }
import React from 'react'
const CommentList = () =\> \<div /\>
export default CommentList
Take a look at the test runner once more and all tests should be passing. Let’s move onto stage three and assert that the CommentList is in fact rendering the correct information. Once again, we’ll use the getByText() function:
// CommentList.test.jsx v.3
import React from 'react'
import { render } from 'react-testing-library'
import CommentList from '../CommentList'
describe('Comment List', () => {
test('It renders a list of comment cards with their comment and author tag', () => {
// Arrange
const comment1 = {
id: 1,
comment: 'I do love writing tests',
author: 'The Notester'
}
const comment2 = {
id: 2,
comment: 'Nothing is better than a good comment app',
author: 'Comment Hater'
}
const props = {
comments: [comment1, comment2]
}
// Act
const { getByText } = render(<CommentList {...props} />)
// Assert
const firstCommentNode = getByText(comment1.comment)
const firstAuthorTagNode = getByText(`- ${comment1.author}`)
const secondCommentNode = getByText(comment2.comment)
const secondAuthorTagNode = getByText(`- ${comment2.author}`)
expect(firstCommentNode).toBeDefined()
expect(firstAuthorTagNode).toBeDefined()
expect(secondCommentNode).toBeDefined()
expect(secondAuthorTagNode).toBeDefined()
})
})
Taking a look at the test runner: it should show a failure searching for text that does not exist in the empty rendered div.
Let’s make this test pass by adjusting the CommentList component to render a CommentCard for each comment in the passed down props.
// CommentList.jsx v.2
import React from 'react'
import PropTypes from 'prop-types'
import CommentCard from './CommentCard'
const CommentList = ({ comments }) => (
<div>
{
comments.map(comment =>
<CommentCard key={comment.id} {...comment} />
)
}
</div>
)
CommentList.propTypes = {
comments: PropTypes.array.isRequired
}
export default CommentList
The test runner should now be passing. We’ve now got two tested components.
CommentForm Component Test
For this component, we are not going to run an entire integration test (i.e. adding a comment adds a comment to the comment list, etc.). What we want to test is that the “Add Comment” button is disabled until both the comment text field and the “Your Name” text field are both filled in.
We need to create two new files for this step.
$ touch src/components/CommentForm.jsx
$ touch src/components/\_\_tests\_\_/CommentForm.test.jsx
This time I’m going to write out the entire test and then add the code that passes it below:
// CommentForm.test.jsx
import React from 'react'
import { render, fireEvent } from 'react-testing-library'
import CommentForm from '../CommentForm'
describe('Comment Form', () => {
test('it has a disabled button until both comment textbox and "Your Name" field have a value', () => {
// Arrange
const comment = 'Never put off until tomorrow what can be done today.'
const author = 'Sensei Wu'
// Act
const { getByLabelText, getByPlaceholderText, getByText } = render(<CommentForm />)
// Assert
const submitButton = getByText('Add Comment')
expect(submitButton.disabled).toEqual(true)
const commentTextfieldNode = getByPlaceholderText('Write something...')
fireEvent.change(commentTextfieldNode, { target: { value: comment } })
expect(submitButton.disabled).toEqual(true)
const nameFieldNode = getByLabelText('Your Name')
fireEvent.change(nameFieldNode, { target: { value: author } })
expect(submitButton.disabled).toEqual(false)
})
})
Notice that the above form is using a new method called fireEvent(). This function gives us the ability to simulate DOM events, such as changing the text inside of an input or clicking on a button. We are also using two new text query methods, as well: getByLabelText(), which looks for input from matching label, and getByPlaceholderText() which we can use to find our comment textarea tag.
To get the tests passing for this, let’s add a simple form component.
// CommentForm.jsx v.1
import React, { Component } from 'react'
export default class CommentForm extends Component {
state = {
comment: '',
author: ''
}
handleOnChange = ({ target: { name, value } }) =>
this.setState(_prevState => ({
[name]: value
}))
hasInvalidFields = () => {
const { comment, author } = this.state
if (comment.trim() !== '' && author.trim() !== '') {
return false
}
return true
}
render () {
const { comment, author } = this.state
const isDisabled = this.hasInvalidFields() ? 'true' : null
return (
<form style={styles.form}>
<div>
<textarea
style={styles.commentBox}
onChange={this.handleOnChange}
placeholder="Write something..."
name="comment"
value={comment}
/>
</div>
<div>
<label htmlFor="author" aria-labelledby="author">Your Name</label>
<input
style={styles.inputField}
onChange={this.handleOnChange}
id="author"
type="text"
name="author"
value={author}
/>
</div>
<button style={styles.button} disabled={isDisabled}>
Add Comment
</button>
</form>
)
}
}
const styles = {
form: {
margin: 'auto',
padding: '0px',
width: '500px'
},
commentBox: {
width: '494px',
height: '80px',
marginBottom: '12px'
},
inputField: {
width: '360px',
float: 'right',
},
button: {
marginTop: '12px',
width: '500px',
color: '#ffffff',
backgroundColor: '#767676',
padding: '6px',
borderRadius: '8px'
}
}
Tests should now be passing!
Test the Whole Thing!
We now have the basis for some simple working components using integration tests, but we still need to verify that the whole process works together.
These are things we want to test:
- On app load, it fetches and renders the preexisting comments
- It successfully creates a new comment, renders it, and then resets the form data
Testing On App Load it Fetches and Renders the Preexisting Comments
For this test, we need to create a new component called Comments that will live in a /screens directory. Its role will be to coordinate the rendering and state between all of the components we have built thus far.
Create the files and directories below:
$ mkdir src/screens
$ mkdir src/screens/__tests__
$ touch src/screen/Comments.jsx
$ touch src/screens/__tests__/Comments.test.jsx
For the first test, we will do some arrangements of data, mock the get request using Axios and Jest , and try to render the component. Also, since we are working with asynchronous code, we’ll import the wait() function from React Test Library and use async/await in the test blocks.
// Comments.test.jsx v.1
import React from 'react'
import { render, fireEvent, wait, cleanup } from 'react-testing-library'
import axios from 'axios'
import Comments from '../Comments'
const comment1 = {
id: 1,
comment: 'I do love writing tests',
author: 'The Notester'
}
const comment2 = {
id: 2,
comment: 'Nothing is better than a good comment app',
author: 'Comment Hater'
}
const comments = [comment1, comment2]
describe('Comments Screen', () => {
afterEach(cleanup)
beforeEach(() => {
axios.get = jest.fn(() => Promise.resolve(comments))
})
test('It fetches comments and renders them to the page', async () => {
render(<Comments />)
})
})
The above is creating two new comments and then placing them in an array, which, we then will pass to our Jest function mock when axios.get is called in the test. Since our Comments component does not require props, we don’t need to create a props object. We do need to create a basic Comments component to pass this first failing test, though.
// Comments.jsx v.1
import React, { Component } from 'react'
export default class Comments extends Component {
render () {
return <div />
}
}
This passes our basic test, but, we need to finish writing out the full test.
// Comments.test.jsx v.2
// ... previous code
test('It fetches comments and renders them to the page', async () => {
const { getByText } = render(<Comments />)
await wait(() => getByText(comment1.comment))
const firstCommentNode = getByText(comment1.comment)
const firstAuthorTagNode = getByText(`- ${comment1.author}`)
const secondCommentNode = getByText(comment2.comment)
const secondAuthorTagNode = getByText(`- ${comment2.author}`)
expect(firstCommentNode).toBeDefined()
expect(firstAuthorTagNode).toBeDefined()
expect(secondCommentNode).toBeDefined()
expect(secondAuthorTagNode).toBeDefined()
})
Now we have the assertions, but our tests are failing due to this code await wait(() => getByText(comment1.comment)). With this code, we are telling the test to stop running the code inside of the test block and wait for the component to render the first comment returned from our API request. Once it renders that comment, it will continue down the list of assertions, but it will fail the test if the comment isn’t rendered before the timeout (five second default).
This is the code we need to pass this test:
// Comments.jsx v.2
import React, { Component } from 'react'
import axios from 'axios'
import CommentList from '../components/CommentList'
export default class Comments extends Component {
state = {
comments: null
}
componentDidMount () {
this.fetchComments()
}
fetchComments () {
axios.get('/api/comments')
.then(comments => this.setState({ comments }))
.catch(console.error)
}
render () {
const { comments } = this.state
return (
<div>
{
comments && comments.length
? <CommentList comments={comments} />
: null
}
</div>
)
}
}
Check your test runner and you should see all green:
It Successfully Creates a New Comment, Renders it, and Then Resets the Form Data
For the final test, we will test to make sure that it successfully creates a new comment, renders it, and then resets the form data.
Let’s add the following mock data and hijack the axios.post method.
// Comments.test.jsx v.3
// ... previous code
const newComment = {
id: 3,
comment: 'Brave new world of testing',
author: 'Spongebob'
}
describe('Comments Screen', () => {
afterEach(cleanup)
beforeEach(() => {
axios.get = jest.fn(() => Promise.resolve(comments))
axios.post = jest.fn(() => Promise.resolve(newComment))
})
// ...
})
Next, add a new test block with assertions on the desired spec of our feature.
// Comments.test.jsx v.4
// ... previous code
test('it creates a new comment, renders it and clears out form upon submission', async () => {
const { getByLabelText, getByPlaceholderText, getByText } = render(<Comments />)
await wait(() => getByText(comment1.comment))
const submitButton = getByText('Add Comment')
const commentTextfieldNode = getByPlaceholderText('Write something...')
const nameFieldNode = getByLabelText('Your Name')
fireEvent.change(commentTextfieldNode, { target: { value: newComment.comment } })
fireEvent.change(nameFieldNode, { target: { value: newComment.author } })
fireEvent.click(submitButton)
await wait(() => getByText(`- ${newComment.author}`))
expect(commentTextfieldNode.value).toEqual('')
expect(nameFieldNode.value).toEqual('')
})
// ...
We should now have our new mock data and new test block to test against our code. The test runner should not display the following error:
We need to do two things. First, to fix this error, we need to render the CommentForm component inside of the Comments component. We then need to pass a callback function to the CommentForm that will then notify the Comments component when a new comment should be added to the Comments page.
To build this, let’s change the code in the Comments component to now have an addComment() function that will then be used as a callback in the CommentForm component upon form submission:
// Comments.jsx v.3
// ... previous code
export default class Comments extends Component {
// ...
addComment = comment => this.setState(prevState => ({
comments: prevState.comments.concat(comment)
}))
render () {
const { comments } = this.state
return (
<div>
<CommentForm addComment={this.addComment} />
{/* ... previous code */}
</div>
)
}
}
Now we should change the code in the CommentForm component to handle the new addComment() function prop being passed down from the Comments component. We will also add a clearForm() function that will clear out the form data after successfully creating a new comment and calling this.props.addComment() . The code update should look like this:
// CommentForm.jsx v.2
import React, { Component } from 'react'
import axios from 'axios'
export default class CommentForm extends Component {
initialState = {
comment: '',
author: ''
}
state = this.initialState
// ... previous code
handleOnSubmit = event => {
event.preventDefault()
const newComment = this.state
this.createComment(newComment)
}
createComment = newComment => {
axios.post('/api/comments', { newComment })
.then(comment => {
this.props.addComment(comment)
this.clearForm()
})
.catch(console.error)
}
clearForm = () =>
this.setState(_prevState => (this.initialState))
// ... previous code
render () {
const { comment, author } = this.state
const isDisabled = this.hasInvalidFields() ? 'true' : null
return (
<form onSubmit={this.handleOnSubmit} style={styles.form}>
{/* ... previous code */}
</form>
)
}
}
// ...
We now have five passing tests and a nearly 100% fully-tested application.
Conclusion
Looking back at our progress, we’ve built a small but functional React app with full test coverage. We also focused all our testing strategies around how the components react to user interactions instead of testing implementation details.
One thing you may have noticed is that we never actually looked at the code in the browser. This was intentional. I wanted to show you how to write tests as if you were the user interacting with the page. I think you will find that everything works perfectly (other than CSS styling).
Feel free to extend this app and add more tests around the current features if you like, or start using these methods in your own app. You will need to first connect it to a functioning comments API and mount it in the /src/index.js.
I hope you found this useful. I would love to hear from you with comments or questions.
Resources
P.S. Would you like to work on a mission-driven team that loves ice cream and programming in JS and React? We’re hiring !
To learn more about Flatiron School, visit the website , follow us on Facebook and Twitter , and visit us at upcoming events near you.
Flatiron School is a proud member of the WeWork family. Check out our sister technology blogs WeWork Technology and Making Meetup .
Top comments (0)