React Hooks: What's going to happen to my tests?
How can we prepare our tests for React’s new hooks feature?
One of the most common questions I hear about the upcoming React Hooks feature is regarding testing. And I can understand the concern when your tests look like this:
// borrowed from a previous blog post: https://kcd.im/implementation-details test('setOpenIndex sets the open index state properly', () => { const wrapper = mount(<Accordion items={[]} />) expect(wrapper.state('openIndex')).toBe(0) wrapper.instance().setOpenIndex(1) expect(wrapper.state('openIndex')).toBe(1) })
That enzyme test works when Accordion
is a class component where the instance
actually exists, but there’s no concept of a component “instance” when your components are function components. So doing things like .instance()
or .state()
wont work when you refactor your components from class components with state/lifecycles to function components with hooks.
So if you were to refactor the Accordion
component to a function component, those tests would break. So what can we do to make sure that our codebase is ready for hooks refactoring without having to either throw away our tests or rewrite them? You can start by avoiding enzyme APIs that reference the component instance like the test above. You can read more about this in my “implementation details” blog post.
Let’s look at a simpler example of a class component. My favorite example is a <Counter />
component:
// counter.js import React from 'react' class Counter extends React.Component { state = {count: 0} increment = () => this.setState(({count}) => ({count: count + 1})) render() { return <button onClick={this.increment}>{this.state.count}</button> } } export default Counter
Now let’s see how we could test it in a way that’s ready for refactoring it to use hooks:
// __tests__/counter.js import React from 'react' import 'react-testing-library/cleanup-after-each' import {render, fireEvent} from 'react-testing-library' import Counter from '../counter.js' test('counter increments the count', () => { const {container} = render(<Counter />) const button = container.firstChild expect(button.textContent).toBe('0') fireEvent.click(button) expect(button.textContent).toBe('1') })
That test will pass. Now, let’s refactor this to a hooks version of the same component:
// counter.js import React from 'react' function Counter() { const [count, setCount] = useState(0) const incrementCount = () => setCount(currentCount => currentCount + 1) return <button onClick={incrementCount}>{count}</button> } export default Counter
Guess what! Because our tests avoided implementation details, our hooks are passing! How neat is that!? :)
useEffect is not componentDidMount + componentDidUpdate + componentWillUnmount
Another thing to consider is the useEffect
hook because it actually is a little unique/special/different/awesome. When you’re refactoring from class components to hooks, you’ll typically move the logic from componentDidMount
, componentDidUpdate
, and componentWillUnmount
to one or more useEffect
callbacks (depending on the number of concerns your component has in those lifecycles). But this is actually not a refactor. Let’s get a quick review of what a “refactor” actually is.
When you refactor code, you’re making changes to the implementation without making user-observable changes. Here’s what wikipedia says about “code refactoring”:
Code refactoring is the process of restructuring existing computer code—changing the factoring—without changing its external behavior.
Ok, let’s try that idea out with an example:
const sum = (a, b) => a + b
Here’s a refactor of this function:
const sum = (a, b) => b + a
It still works exactly the same, but the implementation itself is a little different. Fundamentally that’s what a “refactor” is. Ok, now, here’s what a refactor is not:
const sum = (...args) => args.reduce((s, n) => s + n, 0)
This is awesome, our sum
is more capable, but what we did was not technically a refactor, it was an enhancement. Let’s compare:
call | result before | result after |
---|---|---|
sum() | NaN | 0 |
sum(1) | NaN | 1 |
sum(1, 2) | 3 | 3 |
sum(1, 2, 3) | 3 | 6 |
So why was this not a refactor? It’s because we are “changing its external behavior.” Now, this change is desirable, but it is a change.
So what does all this have to do with useEffect
? Let’s look at another example of our counter component as a class with a new feature:
class Counter extends React.Component { state = {count: Number(window.localStorage.getItem('count') || 0)} increment = () => this.setState(({count}) => ({count: count + 1})) componentDidMount() { window.localStorage.setItem('count', this.state.count) } componentDidUpdate(prevProps, prevState) { if (prevState.count !== this.state.count) { window.localStorage.setItem('count', this.state.count) } } render() { return <button onClick={this.increment}>{this.state.count}</button> } }
Ok, so we’re saving the value of count
in localStorage
using componentDidMount
and componentDidUpdate
. Here’s what our implementation-details-free test would look like:
// __tests__/counter.js import React from 'react' import 'react-testing-library/cleanup-after-each' import {render, fireEvent, cleanup} from 'react-testing-library' import Counter from '../counter.js' afterEach(() => { window.localStorage.removeItem('count') }) test('counter increments the count', () => { const {container} = render(<Counter />) const button = container.firstChild expect(button.textContent).toBe('0') fireEvent.click(button) expect(button.textContent).toBe('1') }) test('reads and updates localStorage', () => { window.localStorage.setItem('count', 3) const {container, rerender} = render(<Counter />) const button = container.firstChild expect(button.textContent).toBe('3') fireEvent.click(button) expect(button.textContent).toBe('4') expect(window.localStorage.getItem('count')).toBe('4') })
That test passes! Woo! Now let’s “refactor” this to hooks again with these new features:
import React, {useState, useEffect} from 'react' function Counter() { const [count, setCount] = useState(() => Number(window.localStorage.getItem('count') || 0), ) const incrementCount = () => setCount(currentCount => currentCount + 1) useEffect( () => { window.localStorage.setItem('count', count) }, [count], ) return <button onClick={incrementCount}>{count}</button> } export default Counter
Cool, as far as the user is concerned, this component will work exactly the same as it had before. But it’s actually working differently from how it was before. The real trick here is that the useEffect
callback is scheduled to run at a later time. So before, we set the value of localStorage
synchronously after rendering. Now, it’s scheduled to run later after rendering. Why is this? Let’s checkout this tip from the React Hooks docs:
Unlike
componentDidMount
orcomponentDidUpdate
, effects scheduled withuseEffect
don’t block the browser from updating the screen. This makes your app feel more responsive. The majority of effects don’t need to happen synchronously. In the uncommon cases where they do (such as measuring the layout), there is a separateuseLayoutEffect
Hook with an API identical touseEffect
.
Ok, so by using useEffect
that’s better for performance! Awesome! We’ve made an enhancement to our component and our component code is actually simpler to boot! NEAT!
But again, this is not a refactor. It’s actually a change in behavior. As far as the end user is concerned, that change is unobservable, but from our tests perspective, we can observe that change. And that explains why they’re breaking :-(
FAIL __tests__/counter.js ✓ counter increments the count (31ms) ✕ reads and updates localStorage (12ms) ● reads and updates localStorage expect(received).toBe(expected) // Object.is equality Expected: "4" Received: "3" 23 | fireEvent.click(button) 24 | expect(button.textContent).toBe('4') > 25 | expect(window.localStorage.getItem('count')).toBe('4') | ^ 26 | }) 27 | at Object.toBe (src/__tests__/05-testing-effects.js:25:48)
So our problem is that our tests were expecting to be able to read the changed value of localStorage
synchronously after the user interacts with the component (and the state was updated and the component was rerendered), but now that’s happening asynchronously.
So there are a few ways we can solve this problem:
- Change from
React.useEffect
toReact.useLayoutEffect
as noted in the tip referenced above. This would be the easiest solution, but unless you actually need this to run synchronously, you should probably not do this as it could hurt performance. - Use
react-testing-library
‘swait
utility and make the testasync
. This is arguably the best solution because the operation actually is asynchronous, but the ergonomics aren’t all that great and there’s actually currently a bug when trying this in jsdom (works in the browser). I haven’t looked into where the bug lives (I’m guessing it’s in jsdom) because I like the next solution best. - Force the effects to flush synchronously. You can actually force the effects to run synchronously by calling
ReactDOM.render
(watch me show how this works by diving into the react source).react-testing-library
exports an experimental API for making this easy calledflushEffects
. This is my preferred option.
So let’s look at the diff for the changes our test needs to account for this feature enhancement:
@@ -1,6 +1,7 @@ import React from 'react' import 'react-testing-library/cleanup-after-each' -import {render, fireEvent} from 'react-testing-library' +import {render, fireEvent, flushEffects} from 'react-testing-library' import Counter from '../counter' afterEach(() => { window.localStorage.removeItem('count') @@ -21,5 +22,6 @@ test('reads and updates localStorage', () => { expect(button.textContent).toBe('3') fireEvent.click(button) expect(button.textContent).toBe('4') + flushEffects() expect(window.localStorage.getItem('count')).toBe('4') })
Nice! So any time we want to make assertions based on effect callbacks, we can call flushEffects()
and everything works exactly as it had before.
Wait Kent… Isn’t this testing implementation details? YES! I’m afraid that it is. If you don’t like that, then you can feel free to make every interaction with your component asynchronous because the fact that anything happens synchronously is actually a bit of an implementation detail as well. Instead, I make the trade-off of getting the ergonomics of testing my components synchronously in exchange for including this small implementation detail. There are no absolutes in software (except to never shallow render components 😉), we need to acknowledge the trade-offs here. I simply feel like this is one area I’m willing to dip into the details in favor of nice testing ergonomics (read more about this in “The Merits of Mocking”).
What about render props components?
This is probably my favorite actually. Here’s a simple counter render prop component:
class Counter extends React.Component { state = {count: 0} increment = () => this.setState(({count}) => ({count: count + 1})) render() { return this.props.children({ count: this.state.count, increment: this.increment, }) } } // usage: // <Counter> // {({ count, increment }) => <button onClick={increment}>{count}</button>} // </Counter>
Here’s how I would test this:
// __tests__/counter.js import React from 'react' import 'react-testing-library/cleanup-after-each' import {render, fireEvent} from 'react-testing-library' import Counter from '../counter.js' function renderCounter(props) { let utils const children = jest.fn(stateAndHelpers => { utils = stateAndHelpers return null }) return { ...render(<Counter {...props}>{children}</Counter>), children, // this will give us access to increment and count ...utils, } } test('counter increments the count', () => { const {children, increment} = renderCounter() expect(children).toHaveBeenCalledWith(expect.objectContaining({count: 0})) increment() expect(children).toHaveBeenCalledWith(expect.objectContaining({count: 1})) })
Ok, so let’s refactor the counter to a component that uses hooks:
function Counter(props) { const [count, setCount] = useState(0) const increment = () => setCount(currentCount => currentCount + 1) return props.children({ count: count, increment, }) }
Cool, and because we wrote our test the way we did, it’s actually still passing. Woo! BUT! As we learned from “React Hooks: What’s going to happen to render props?” custom hooks are a better primitive for code sharing in React. So let’s rewrite this to a custom hook:
function useCounter() { const [count, setCount] = useState(0) const increment = () => setCount(currentCount => currentCount + 1) return {count, increment} } export default useCounter // usage: // function Counter() { // const {count, increment} = useCounter() // return <button onClick={increment}>{count}</button> // }
Awesome… but how do we test useCounter
? And wait! We can’t update our entire codebase to the new useCounter
! We were using the <Counter />
render prop based component in like three hundred places!? Rewrites are the worst!
Nah, I got you. Do this instead:
function useCounter() { const [count, setCount] = useState(0) const increment = () => setCount(currentCount => currentCount + 1) return {count, increment} } const Counter = ({children, ...props}) => children(useCounter(props)) export default Counter export {useCounter}
Our new <Counter />
render-prop based component there is actually exactly the same as the one we had before. So this is a true refactor. But now anyone who can take the time to upgrade can use our useCounter
custom hook.
Oh, and guess what. Our tests are still passing!!! WHAT! How neat right?
So when everyone’s upgraded we can remove the Counter function component right? You may be able to do that, but I would actually move it to the __tests__
because that’s how I like testing custom hooks! I prefer making a render-prop based component out of a custom hook, and actually rendering that and asserting on what the function is called with.
Fun trick right? I show you how to do this in my new course on egghead.io. Enjoy!
Conclusion
One of the best things you can do before you refactor code is have a good test suite/type definitions in place so when you inadvertently break something you can be made aware of the mistake right away. But your test suite can’t do you any good if you have to throw it away when you refactor it. Take my advice: avoid implementation details in your tests. Write tests that will work today with classes, and in the future if those classes are refactored to functions with hooks. Good luck!
Learn more about React Hooks from me:
If you thought this was interesting, I highly recommend you watch these (while they’re both still free):
- React Hooks and Suspense - A great primer on Hooks and Suspense
- Simplify React Apps with React Hooks - Let’s take some real-world class components and refactor them to function components with hooks.
Things to not miss:
- rescripts - 💥 Use the latest react-scripts with custom configurations for Babel, ESLint, TSLint, Webpack,… ∞ by Harry Solovay
- Contributing to Open Source on GitHub for beginners - A talk I gave at my Alma mater (BYU) this last week
- Make a SUPER simple personal URL shortener with Netlify (I’m still livestreaming almost every week day at kcd.im/devtips
- The three browsers holding JavaScript back the most are:… An interesting thread by Jamie Kyle.
- Emotion 10 released! - This is still my favorite CSS-in-JS solution and this is why I prefer it over styled-components.
Some tweets from this last week:
Netflix: This video for your kids is 20 minutes. Me: But I need just a few more minutes to keep them occupied while I finish something… Netflix: Sorry. Me: document.querySelectorAll(‘video’).forEach(v => v.playbackRate = 0.8) Netflix: Sssooouuunnndddsss gggoooddd.
Another day, another case when someone was frustrated that getByLabelText want working for them and then they realized their label wasn’t wired up to their input properly. react-testing-library: make application’s more accessible, one test at a time. 💖🐐
Introducing a new @eggheadio course: Simplify React Apps with React Hooks and Suspense!!! 📝 Blog Post: 📺 Course: 💁♂️ Intro video: Free for a limited time! Also, @eggheadio’s doing a 45% off sale! 🤑
Introducing a new course: Simplify React Apps with React Hooks and Suspense
Learn about the massive improvements coming to function components in React via a fresh new course showing you how to refactor an existing…
Simplify React Apps with React Hooks | egghead.io
With the massive improvements to function components in React via hooks and suspense, you may be interested in seeing how to refactor a typical class component to a simpler function component that uses React Hooks features. In this course, Kent will take a modern React codebase that uses classes and refactor the entire thing to use function components as much as possible. We’ll look at state, side effects, async code, caching, and more! Want a primer on hooks and suspense? Watch Kent’s React Hooks and Suspense Playlist!
Introduction to Refactoring a React Application to React Hooks | egghead.io
Let’s get a quick overview of what this course is all about and how it’s been structured to make sure you’re as productive as possible with these new features.
This week’s blog post is “React Hooks: What’s going to happen to render props?”. 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:
I just published “React Hooks: What’s going to happen to render props?”
React Hooks: What’s going to happen to render props?
What am I going to do with all these render props components now that react hooks solve the code reuse problem better than render props ever did?
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 the creator of TestingJavaScript.com and I’m an instructor on egghead.io and Frontend Masters. 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.