Test Isolation with React
Why your tests should be completely isolated from one another and how to do that.
Read to the end, I’ve got some cool things in the “things not to miss” section.
Before we get into today’s newsletter, I want to let you know about my Online and Live Advanced React Component Patterns Workshop:
- June 19th-20th (TOMORROW from the time this newsletter is sent)
- 3.5 hours per day starting at 3:00 PM Pacific Time each day
- Using Zoom for it’s superior capabilities including break rooms where you can get special attention and time to ask me questions directly.
- High quality exercises completed by thousands of developers already
- Patterns include: Render Props, the Provider Pattern (with
React.createContext
), Control props, State reducers, Compound Components, and more!
If you click this link, you’ll get 10% off (any workshop on workshop.me):
https://workshop.me/2018-06-advanced-react?a=kent
Ask your boss right now if they’d like you to be better at React π
Ok, now onto the content of today’s newsletter. I think you’ll really like it!
The inspiration for this newsletter comes from seeing React tests that look like this:
const utils = render(<foo></foo>) test('test 1', () => { // use utils here }) test('test 2', () => { // use utils here too })
So I want to talk about the importance of test isolation and guide you to a better way to write your tests to improve the reliability of the tests, simplify the code, and increase the confidence your tests and provide as well.
Let’s take this simple component as an example:
import React from 'react' class Counter extends React.Component { static defaultProps = { initialCount: 0, maxClicks: 3, } initialState = {count: this.props.initialCount} state = this.initialState handleReset = () => this.setState(this.initialState) handleClick = () => this.setState( ({count}) => (this.clicksAreTooMany(count) ? null : {count: count + 1}), ) clicksAreTooMany(count) { return count >= this.props.maxClicks } render() { const {count} = this.state const tooMany = this.clicksAreTooMany(count) return ( <div> <button disabled="{tooMany}" onclick="{this.handleClick}"> Count: {count} </button> {tooMany ? <button onclick="{this.handleReset}">reset</button> : null} </div> ) } } export {Counter}
Here’s a rendered version of the component:
Our first test suite
Let’s start with a test suite like the one that inspired this newsletter:
import 'jest-dom/extend-expect' // gives us the toHaveTextContent/toHaveAttribute matchers import React from 'react' import {renderIntoDocument, cleanup, fireEvent} from 'react-testing-library' import {Counter} from '../counter' const {getByText} = renderIntoDocument( <counter initialcount="{3}" maxclicks="{4}"></counter>, ) const counterButton = getByText(/^count/i) afterAll(cleanup) // when all tests are finished, unmount the component test('the counter is initialized to the initialCount', () => { expect(counterButton).toHaveTextContent(/3/) }) test('when clicked, the counter increments the click', () => { fireEvent.click(counterButton) expect(counterButton).toHaveTextContent(/4/) }) test(`the counter button is disabled when it's hit the maxClicks`, () => { fireEvent.click(counterButton) expect(counterButton).toHaveAttribute('disabled') }) test(`the counter button does not increment the count when clicked when it's hit the maxClicks`, () => { expect(counterButton).toHaveTextContent(/4/) }) test(`the reset button has been rendered and resets the count when it's hit the maxClicks`, () => { fireEvent.click(getByText(/reset/i)) expect(counterButton).toHaveTextContent(/3/) })
These tests give us 100% coverage of the component and verify exactly what they say they’ll verify. The problem is that they share mutable state. What is the mutable state they’re sharing? The component! One test clicks the counter button and the other tests rely on that fact to pass. If we were to delete (or .skip
) the test called “when clicked, the counter increments the click” it would break all the following tests:
This is a problem because it means that we can’t reliably refactor these tests, or run a single test in isolation of the others for debugging purposes because we don’t know which tests are impacting the functionality of others. It can be really confusing when someone comes in to make changes to one test and other tests start breaking out of nowhere.
Better
So let’s try something else and see how that changes things:
import 'jest-dom/extend-expect' import React from 'react' import {renderIntoDocument, cleanup, fireEvent} from 'react-testing-library' import {Counter} from '../counter' let getByText, counterButton beforeEach(() => { const utils = renderIntoDocument(<counter initialcount="{3}" maxclicks="{4}"></counter>) getByText = utils.getByText counterButton = utils.getByText(/^count/i) }) afterEach(cleanup) test('the counter is initialized to the initialCount', () => { expect(counterButton).toHaveTextContent(/3/) }) test('when clicked, the counter increments the click', () => { fireEvent.click(counterButton) expect(counterButton).toHaveTextContent(/4/) }) test(`the counter button is disabled when it's hit the maxClicks`, () => { fireEvent.click(counterButton) expect(counterButton).toHaveAttribute('disabled') }) test(`the counter button does not increment the count when clicked when it's hit the maxClicks`, () => { fireEvent.click(counterButton) fireEvent.click(counterButton) expect(counterButton).toHaveTextContent(/4/) }) test(`the reset button has been rendered and resets the count when it's hit the maxClicks`, () => { fireEvent.click(counterButton) fireEvent.click(getByText(/reset/i)) expect(counterButton).toHaveTextContent(/3/) })
With this, each test is completely isolated from the other. We can delete or skip any test and the rest of the tests continue to pass. The biggest fundamental difference here is that each test has its own count instance to work with and it’s unmounted after each test (afterEach(cleanup)
). This significantly reduces the amount of complexity of our tests with minor changes.
One thing people often say against this approach is that it’s slower than the previous approach. I’m not totally sure how to respond to that… Like, how much slower? Like a few milliseconds? In that case, so what? A few seconds? Then your component should probably be optimized because that’s just terrible. I know it adds up over time, but with the added confidence and improved maintainability of this approach, I’d gladly wait an extra few seconds to render things this way. In addition, you shouldn’t often have to run the entire test base anyway thanks to great watch mode support like we have in Jest.
Even better
So I’m actually still not super happy with the tests we have above. I’m not a huge fan of beforeEach
and sharing variables between tests. I feel like they lead to tests that are harder to understand. Let’s try again:
import 'jest-dom/extend-expect' import React from 'react' import {renderIntoDocument, cleanup, fireEvent} from 'react-testing-library' import {Counter} from '../counter' afterEach(cleanup) function renderCounter(props) { const utils = renderIntoDocument( <counter initialcount="{3}" maxclicks="{4}" {...props}=""></counter>, ) const counterButton = utils.getByText(/^count/i) return {...utils, counterButton} } test('the counter is initialized to the initialCount', () => { const {counterButton} = renderCounter() expect(counterButton).toHaveTextContent(/3/) }) test('when clicked, the counter increments the click', () => { const {counterButton} = renderCounter() fireEvent.click(counterButton) expect(counterButton).toHaveTextContent(/4/) }) test(`the counter button is disabled when it's hit the maxClicks`, () => { const {counterButton} = renderCounter({ maxClicks: 4, initialCount: 4, }) expect(counterButton).toHaveAttribute('disabled') }) test(`the counter button does not increment the count when clicked when it's hit the maxClicks`, () => { const {counterButton} = renderCounter({ maxClicks: 4, initialCount: 4, }) fireEvent.click(counterButton) expect(counterButton).toHaveTextContent(/4/) }) test(`the reset button has been rendered and resets the count when it's hit the maxClicks`, () => { const {getByText, counterButton} = renderCounter() fireEvent.click(counterButton) fireEvent.click(getByText(/reset/i)) expect(counterButton).toHaveTextContent(/3/) })
Here we’ve increased some boilerplate, but now every test is not only isolated technically, but also visually. You can look at a test and see exactly what it does without having to worry about what hooks are happening within the test. This is a big win in the ability for you to be able to refactor, remove, or add to the tests.
Even better better
I like what we have now, but I think we need to take things one step further before I feel really happy about things. We’ve split our tests up by functionality, but what we really want to be confidence in is the use case that our component satisfies. It allows clicks until the maxClicks is reached, then requires a reset. That’s what we’re trying to verify and gain confidence in. I’m much more interested in use cases when I’m testing than specific functionality. So what would these tests look like if we concerned ourselves more with the use case than the individual functionality?
import 'jest-dom/extend-expect' import React from 'react' import {renderIntoDocument, cleanup, fireEvent} from 'react-testing-library' import {Counter} from '../counter' afterEach(cleanup) test('allows clicks until the maxClicks is reached, then requires a reset', () => { const {getByText} = renderIntoDocument( <counter initialcount="{3}" maxclicks="{4}"></counter>, ) const counterButton = getByText(/^count/i) // the counter is initialized to the initialCount expect(counterButton).toHaveTextContent(/3/) // when clicked, the counter increments the click fireEvent.click(counterButton) expect(counterButton).toHaveTextContent(/4/) // the counter button is disabled when it's hit the maxClicks expect(counterButton).toHaveAttribute('disabled') // the counter button no longer increments the count when clicked. fireEvent.click(counterButton) expect(counterButton).toHaveTextContent(/4/) // the reset button has been rendered and is clickable fireEvent.click(getByText(/reset/i)) // the counter is reset to the initialCount expect(counterButton).toHaveTextContent(/3/) // the counter can be clicked and increment the count again fireEvent.click(counterButton) expect(counterButton).toHaveTextContent(/4/) })
I really love this kind of test. It helps me avoid thinking about functionality and focus more on what I’m trying to accomplish with the component. It serves as much better documentation of the component than the other tests as well.
In the past, the reason we wouldn’t do this (have multiple assertions in a single test) is because it was hard to tell which part of the test broke. But now we have much better error output and it’s really easy to identify what part of the test broke. For example:
The code frame is especially helpful. It shows not only the line number, but the code around the failed assertion which shows our comments and other code to really help give us context around the error message that not even our previous tests gave us.
I should mention, this isn’t to say that you shouldn’t separate test cases for a component! There are many reasons you’d want to do that and most of the time you will. Just focus more on use cases than functionality and you’ll generally cover most of the code you care about with that. Then you can have a few extra tests to handle edge cases.
Conclusion
I hope this is helpful to you! You can find the code for this example here. Try to keep your tests isolated from one another and focus on use cases rather than functionality and you’ll have a much better time testing! Good luck!
Looking for a job? Looking for a developer? Check out my job board: kcd.im/jobs
Learn more about testing from me:
- Frontend Masters:
- Confidently Ship Production React Apps - Something new on egghead.io. It’s a recording of one of my talks especially for egghead.io. I think you’ll really enjoy it (and it’s π)
- Write tests. Not too many. Mostly integration. - My talk at Assert.js conference (and here’s the blog post)
- Testing Practices and Principles - A recording of my workshop at Assert.js
Things to not miss:
- Byteconf React Speakers: Round One - Look! It’s a free online conference and I’m speaking at it!
- π downshift 2.0.0 released π - Even better accessibility, React Native and ReasonReact support, even simpler API, improved docs, new examples site, Flow and TypeScript support, and a new online community βοΈ
- keycode.info by Wes Bos - shows you the javascript character code for the key you type. Handy!
- Chrome Pixelbook - It’s what I’m using to write this right now and it’s pretty slick!
- Testing Socket.io-client app using Jest and react-testing-library by my friend Justice Mba.
- Webpack 4βββMysterious SplitChunks Plugin - My fellow PayPal engineer Hemal Patel wrote about how the
splitChunks.chunks
feature works. Pretty interesting!
Some tweets from this last week:
> With the react team making the react docs really awesome and comprehensive, maybe there’s a need for a site that’s more terse π€ maybe such a site exists…? β [10 Jun 2018]https://twitter.com/kentcdodds/status/1005920207670370304)
> Useful examples and tricks of how to use destructuring in JavaScript with Objects and Arrays. #DevTipsWithKent youtube.com/watch?v=FsgGx1SMXn0 c 11 Jun 2018
> I’m planning on doing a few @eggheadio courses about testing over the next few weeks/months. Starting here: github.com/kentcdodds/js-testing-fundamentals (VERY fundamental). β 11 Jun 2018
This week’s blog post is “Dealing with FOMO”. It’s the published version of my newsletter from 2 weeks ago. If you thought it was good, go ahead and give it some claps (πx50) and a retweet:
Get a mention in this newsletter right here by becoming my sponsor Patreon
P.S. If you like this, make sure to subscribe, follow me on twitter, buy me lunch, support me on patreon, and share this with your friends π
π Hi! Iβm Kent C. Dodds. I work at PayPal as a full stack JavaScript engineer. I represent PayPal on the TC39. Iβm actively involved in the open source community. Iβm an instructor on egghead.io, Frontend Masters, and Workshop.me. Iβm also a Google Developer Expert. Iβm happily married and the father of four kids. I like my family, code, JavaScript, and React.