Build React Apps with TDD and the React Testing Library
“A couple of pineapples side by side on the beach.” by Pineapple Supply Co. on Unsplash
One thing I struggled with when I started learning React was testing my web apps in a way that is both useful and intuitive. I basically used Enzyme with Jest to shallow render a component every time I wanted to test it, absolutely abusing the snapshot feature.
Well, at least I actually wrote a test right?
You might have heard somewhere that writing unit and integration tests will improve the quality of the software you write. Having bad tests, on the other hand, breeds false confidence.
Recently, I attended a workshop with @kentcdodds where he taught us how to properly write integration tests for React applications. He also tricked us into using his new testing library, in favor of its emphasis on testing the application in the same way that a user would encounter it.
In this post, we will create build a comment feed built with React.
Getting Started
We're going to start off by running create-react-app
and installing the dependencies. My assumption is that if you're astute enough to read an article about testing applications, you're probably already a familiar with installing and starting up javascript projects. I'll be using yarn
rather than npm
here.
create-react-app comment-feed
cd comment-feed
yarn
As it stands, we can remove all of the files in the src
directory except for index.js. Then, right inside the src
folder, create a new folder called components
and a folder called containers
.
For testing utilities, I am going to build this app using Kent C Dodds' react-testing-library. It is a lightweight test utility that encourages the developer to test their application in the same way that it'll be used.
Like Enzyme, it exports a render function, but this render function always does a full mount of your component. It exports helper methods allowing you to locate elements by label or text or even test IDs. Enzyme does that as well with its mount
API, but the abstraction it creates actually offers more options, many of which allow you to get away with testing implementation details.
We don't want to do that anymore. We want to render a component and see if the right things happen when we click or change something. That's it! No more directly checking props or state or class names.
Let's install them and get to work.
yarn add react-testing-library
Building the Comment Feed with TDD
Let's do this first component TDD-style. Fire up your test runner.
yarn test --watch
Inside the containers
folder, we are going to add a file called CommentFeed.js. Alongside it, add a file called CommentFeed.test.js. For the very first test, let us verify that users can create comments. Too soon? Okay, since we don't have any code yet, we'll start with a smaller test. Let's check that we can render the feed.
// containers/CommentFeed.test.js
import { render } from 'react-testing-library'
import CommentFeed from './CommentFeed'
describe('CommentFeed', () => {
it('renders the CommentFeed', () => {
const { queryByText } = render(<CommentFeed />)
const header = queryByText('Comment Feed')
expect(header.innerHTML).toBe('Comment Feed')
})
})
Some notes on react-testing-library
First, let us note the render function here. It is very similar to the way react-dom
renders a component onto the DOM, but it returns an object which we can destructure to get some neat test helpers. In this case, we get queryByText
, which, given some text we expect to see on the DOM, will return that HTML element.
The React Testing Library docs have a hierarchy that should help you decide which query or get method to use. Generally, the order goes like this:
- getByLabelText (form inputs)
- getByPlaceholderText (only if your input doesn't have a label - less accessible!)
- getByText (buttons and headers)
- getByAltText (images)
- getByTestId (use this for things like dynamic text or otherwise odd elements you want to test)
Each of these has an associated queryByFoo
that does the same, except won't fail your test when it doesn't find an element. Use these if you're just testing for the existence of an element.
If none of these get you exactly what you're looking for, the render
method also returns the DOM element mapped to the container
property, so you can use it like container.querySelector('body #root')
.
The First Implementation Code
Now, the implementation will look fairly simple, we just need to make sure that "Comment Feed" is in the component.
import React, { Component } from 'react'
export default class CommentFeed extends Component {
render() {
const { header } = this.props
return (
<div>
<h2>{header}/h2>
</div>
)
}
}
It could be worse, I mean, I was about to do this whole article with styling components too. Fortunately, tests don't care too much for styles so we can focus on our application logic. This next test will verify that we can render comments. But we don't even have any comments, so let's add in that component too.
After the test though. I'm also going to create a props object to store to data we may reuse in these tests.
// containers/CommentFeed.test.js
import { render } from 'react-testing-library'
import CommentFeed from './CommentFeed'
describe('CommentFeed', () => {
const props = { header: 'Comment Feed', comments: [] }
it('renders the CommentFeed', () => {
const { queryByText } = render(<CommentFeed {...props} />)
const header = queryByText(props.header)
expect(header.innerHTML).toBe(props.header)
})
it('renders the comment list', () => {
const { container } = render(<CommentFeed {...props} />)
const commentNodes = container.querySelectorAll('.Comment')
expect(commentNodes.length).toBe(props.comments.length)
})
})
In this case, I am checking that the number of comments is equal to the number fed into the CommentFeed. It's trivial, but the failure of the test gives us the opportunity to create the Comment.js file.
import React from 'react'
const Comment = props => {
return (
<div className="Comment">
<h4>{props.author}</h4>
<p>{props.text}</p>
</div>
)
}
export default Comment
This green lights our test suite so can proceed without fear. All hail TDD, the savior of our kind. It works when we give it an empty array, of course. But what if we actually give it something?
describe('CommentFeed', () => {
/* ... */
it('renders the comment list with some entries', () => {
let comments = [
{
author: 'Ian Wilson',
text: 'A boats a boat but a mystery box could be anything.',
},
{
author: 'Max Powers Jr',
text: 'Krypton sucks.',
},
]
props = { header: 'Comment Feed', comments }
const { container } = render(<CommentFeed {...props} />)
const commentNodes = container.querySelectorAll('.Comment')
expect(commentNodes.length).toBe(props.comments.length)
})
})
We must update our implementation to actually render stuff now. Simple enough now that know where we're going, right?
import React, { Component } from 'react'
import Comment from '../components/Comment'
export default class CommentFeed extends Component {
renderComments() {
return this.props.comments.map((comment, i) => (
<Comment key={i} {...comment} />
))
}
render() {
const { header } = this.props
return (
<div className="CommentFeed">
<h2>{header}</h2>
<div className="comment-list">{this.renderComments()}</div>
</div>
)
}
}
Ah look at that, our test is once again passing. Here's a neat shot of its beauty.
Notice how I never once said we should fire up our program with yarn start
? We're going to keep it that way for a while. The point is, you must feel the code with your mind.
The styling is just what's on the outside; its what is on the inside that counts. ;)
Just in case you want to start the app though, update index.js to the following:
import React from 'react'
import ReactDOM from 'react-dom'
import CommentFeed from './containers/CommentFeed'
const comments = [
{
author: 'Ian Wilson',
text: 'A boats a boat but a mystery box could be anything.',
},
{
author: 'Max Powers Jr',
text: 'Krypton sucks.',
},
{
author: 'Kent Beck',
text: 'Red, Green, Refactor.',
},
]
ReactDOM.render(
<CommentFeed comments={comments} />,
document.getElementById('root')
)
The Add Comment Form
This is where things start getting more fun. This is where we go from sleepily checking for the existence of DOM nodes to actually doing stuff with that and validating behavior. All that other stuff was a warmup.
Let's start by describing what I want from this form. It should:
- contain a text input for the author
- contain a text input for then comment itself
- have a submit button
- eventually call the API or whatever service handles creating and storing the comment.
We can take down this list in a single integration test. For the previous test cases we took it rather slowly, but now we're going to pick up the pace and try to nail it in one fell swoop.
Notice how our test suite is developing? We went from hardcoding props inside their own test cases to creating a factory for them.
Arrange, Act, Assert
import React from 'react'
import { render, Simulate } from 'react-testing-library'
import CommentFeed from './CommentFeed'
// props factory to help us arrange tests for this component
const createProps = props => ({
header: 'Comment Feed',
comments: [
{
author: 'Ian Wilson',
text: 'A boats a boat but a mystery box could be anything.',
},
{
author: 'Max Powers Jr',
text: 'Krypton sucks.',
},
],
createComment: jest.fn(),
...props,
})
describe('CommentFeed', () => {
/* ... */
it('allows the user to add a comment', () => {
// Arrange - create props and locate elements
const newComment = { author: 'Socrates', text: 'Why?' }
let props = createProps()
const { container, getByLabelText } = render(<CommentFeed {...props} />)
const authorNode = getByLabelText('Author')
const textNode = getByLabelText('Comment')
const formNode = container.querySelector('form')
// Act - simulate changes to elements
authorNode.value = newComment.author
textNode.value = newComment.text
Simulate.change(authorNode)
Simulate.change(textNode)
Simulate.submit(formNode)
// Assert - check whether the desired functions were called
expect(props.createComment).toHaveBeenCalledTimes(1)
expect(props.createComment).toHaveBeenCalledWith(newComment)
})
})
This test can be broken into three parts: arrange, act, and assert. There are some assumptions made about the code, like the naming of our labels or the fact that we will have a createComment
prop.
When finding inputs, we want to try to find them by their labels, this prioritizes accessibility when we're building our applications. The easiest way to grab the form is by using container.querySelector
.
Next, we must assign new values to the inputs and simulate the change to update their state. This step may feel a little strange since normally we type one character at a time, updating the components state for each new character. This is more like the behavior of copy/paste, going from empty string to 'Socrates'.
After submitting the form, we can make assertions on things like which props were invoked and with what arguments. We could also use this moment to verify that the form inputs cleared.
Is it intimidating? No need to fear, my child, walk this way. Start by adding the form to your render function.
render() {
const { header } = this.props
return (
<div className="CommentFeed">
<h2>{header}</h2>
<form
className="comment-form"
onSubmit={this.handleSubmit}
>
<label htmlFor="author">
Author
<input
id="author"
type="text"
onChange={this.handleChange}
/>
</label>
<label htmlFor="text">
Comment
<input
id="text"
type="text"
onChange={this.handleChange}
/>
</label>
<button type="submit">Submit Comment</button>
</form>
<div className="comment-list">
{this.renderComments()}
</div>
</div>
)
}
I could break this form into its own separate component, but I will refrain for now. Instead, I'll add it to my "Refactor Wish List" I keep beside my desk. This is the way of TDD. When something seems like it can be refactored, make a note of it and move on. Refactor only when the presence of an abstraction benefits you and doesn't feel unnecessary.
Remember when we refactored our test suite by creating the createProps
factory? Just like that. We can refactor tests too.
Now, let's add in the handleChange
and handleSubmit
class methods. These get fired when we change an input or submit our form. I will also initialize our state.
export default class CommentFeed extends Component {
state = {
author: '',
text: '',
}
handleSubmit = event => {
event.preventDefault()
const { author, text } = this.state
this.props.createComment({ author, text })
}
handleChange = event => {
this.setState({ [event.target.id]: event.target.value })
}
/* ... */
}
And that did it. Our tests are passing and we have something that sort of resembles a real application. How does our coverage look?
Not bad, if we ignore all of the setups that go inside index.js, we have a fully covered web application with respect to lines executed.
Of course, there are probably other cases we want to test in order to verify that the application is working as we intend. That coverage number is just something your boss can brag about when they're talking to the other cohorts.
Liking Comments
How about we check that we can like a comment? This may be a good time to establish some concept of authentication within our application. But we'll not jump too far just yet. Let us first update our props factory to add an auth
field along with IDs for the comments we generate.
const createProps = props => ({
auth: {
name: 'Ian Wilson',
},
comments: [
{
id: 'comment-0',
author: 'Ian Wilson',
text: 'A boats a boat but a mystery box could be anything.',
},
{
id: 'comment-1',
author: 'Max Powers Jr',
text: 'Krypton sucks.',
},
],
/*...*/
})
The user who is "authenticated" will have their auth property passed down through the application, any actions that are relevant to whether they are authenticated will be noted.
In many applications, this property may contain some sort of access token or cookie that is sent up when making requests to the server. On the client, the presence of this property lets the application know that they can let the user view their profile or other protected routes.
In this testing example, however, we will not fiddle too hard with authentication. Imagine a scenario like this: When you enter a chatroom, you give your screen name. From that point on, you are the in charge of every comment that uses this screen name, despite who else signed in with that name.
While it is not a great solution, even in this contrived example, we are only concerned with testing that the CommentFeed component behaves as it should. We are not concerned with how our users are logged in.
In other words, we may have a totally different login component that handles the authentication of a particular user, thus sending them through hoops of fire and fury in order to derive the almighty auth
property that lets them wreak havoc in our application.
Let's "like" a comment. Add this next test case and then update the props factory to include likeComment
.
const createProps = props => ({
createComment: jest.fn(),
likeComment: jest.fn(),
..props
})
describe('CommentFeed', () => {
/* ... */
it('allows the user to like a comment', () => {
let props = createProps()
let id = props.comments[1].id
const { getByTestId } = render(<CommentFeed {...props} />)
const likeNode = getByTestId(id)
Simulate.click(likeNode)
expect(props.likeComment).toHaveBeenCalledTimes(1)
expect(props.likeComment).toHaveBeenCalledWith(id, props.auth.name)
})
})
And now for the implementation, we'll start by updating the Comment component to have a like button as well as a data-testid
attribute so we can locate it.
const Comment = props => {
return (
<div className="Comment">
<h4>{props.author}</h4>
<p>{props.text}</p>
<button
data-testid={props.id}
onClick={() => props.onLike(props.id, props.author)}
>
Like
</button>
</div>
)
}
I put the test id directly on the button so that we can immediately simulate a click on it without having to nest query selectors. I also attached an onClick
handler to the button so that it calls the onLike
function passed down to it.
Now we just add this class method to our CommentFeed:
handleLike = (id, author) => {
this.props.likeComment(id, author)
}
You may wonder why we don't simply pass the likeComment
prop directly to the Comment component, why do we make it a class property? In this case, because it is very simple, we don't have to build this abstraction. In the future, we may decide to add other onClick
handlers that, for example, handle analytics events.
Being able to bundle multiple different function calls in the handleLike
method of this container component has its advantages. We could also use this method to update the state of the component after a successful "Like" if we so choose.
Disliking Comments
At this point we have working tests for rendering, creating, and liking comments. Of course, we haven't implemented the logic that actually does that - we're not updating the store or writing to a database.
You might also notice that the logic we're testing is fragile and not terribly applicable to a real-world comment feed. For example, what if we tried to like a comment we already liked? Will it increment the likes count indefinitely, or will it unlike it?
I'll leave extending the functionality of the components to your imagination, but a good start would be to write a new test case. Here's one that builds off the assumption that we would like to implement disliking a comment we already liked:
const createProps = props => ({
header: 'Comment Feed',
comments: [
{
id: 'comment-0',
author: 'Ian Wilson',
text: 'A boats a boat but a mystery box could be anything.',
likes: ['user-0'],
},
{
id: 'comment-1',
author: 'Max Powers Jr',
text: 'Krypton sucks.',
likes: [],
},
],
auth: {
id: 'user-0',
name: 'Ian Wilson',
},
createComment: jest.fn(),
likeComment: jest.fn(),
unlikeComment: jest.fn(),
...props,
})
describe('CommentFeed', () => {
/* ... */
it('allows the user to unlike a comment', () => {
let props = createProps()
let id = props.comments[0].id
const { getByTestId } = render(<CommentFeed {...props} />)
const likeNode = getByTestId(id)
Simulate.click(likeNode)
expect(props.unlikeComment).toHaveBeenCalledTimes(1)
expect(props.unlikeComment).toHaveBeenCalledWith(id, props.auth)
})
})
Notice that this comment feed we're building allows me to like my own comments. Who does that?
I have updated the Comment component with some logic to determine whether or not the current user has liked the comment.
const Comment = props => {
const isLiked = props.likes.includes(props.currentUser.id)
const onClick = isLiked
? () => props.onDislike(props.id)
: () => props.onLike(props.id)
return (
<div className="Comment">
<h4>{props.author}</h4>
<p>{props.text}</p>
<button data-testid={props.id} onClick={onClick}>
{isLiked ? 'Unlike' : 'Like'}
</button>
</div>
)
}
Well I cheated a little bit, where we were passing author
to the onLike
function before, I changed to currentUser
, which is the auth
prop passed down to the Comment component. I realized this because I was vigorously writing tests - had I just been coding by coincidence this might've slipped past me until one of my coworkers berated me for my ignorance!
But there is no ignorance here, just tests and the code that follows. Be sure to update the CommentFeed so that it expects to pass down the auth property. For the onClick
handlers we can actually omit passing around the auth property, since we can derive that from the auth property in the parent's handleLike
and handleDislike
methods.
handleLike = id => {
this.props.likeComment(id, this.props.auth)
}
handleDislike = id => {
this.props.dislikeComment(id, this.props.auth)
}
renderComments() {
return this.props.comments.map((comment, i) => (
<Comment
key={i}
{...comment}
currentUser={this.props.auth}
onDislike={this.handleDislike}
onLike={this.handleLike}
/>
))
}
Wrapping up
Hopefully, your test suite is looking like an unlit Christmas tree.
There are so many different routes we can take at this, it can get a little overwhelming. Every time you get an idea for something, just write it down, either on paper or in a new test block.
For example, say you actually want to implement handleLike
and handleDislike
in one single class method, but you have other priorities right now. You can do this by documenting in a test case like so:
it('combines like and dislike methods', () => {})
This doesn't mean you need to write an entirely new test, you could also update the previous two cases. But the point is, you can use your test runner as a more imperative "To Do" list for your application.
Helpful Links
There are a few great pieces of content out there that deal with testing at large.
- "Introducing the React Testing Library" by Kent C Dodds. It's a good idea to understand the philosophy behind this testing library.
- "Software Testing Anti-patterns" by Kostis Kapelonis. An extremely in-depth article that discusses unit and integration testing. Also how not do to do them.
- "Test Driven Development by Example" by Kent Beck. This is a physical book that discussed TDD patterns. It is not too long and it is written very conversationally, making it easy to digest.
I hope that'll tide you over for a while.
Curious for more posts or witty remarks? Follow me on Medium, Github and Twitter!
Originally published at medium.freecodecamp.org
Top comments (3)
Really great article! I want to point out the Jest is a general purpose javascript testing framework and can be used for non-React projects as well.
Thank you, yep thats right!
Terrific writeup, thanks!