This site runs best with JavaScript enabled.

React Hooks: What's going to happen to my tests?

Software Engineer, React Training, Testing JavaScript Training

Photo by Mat Reding on Unsplash


How can we prepare our tests for React's new hooks feature?

Current Available Translations:

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:

1// borrowed from a previous blog post:
2// https://kcd.im/implementation-details
3test('setOpenIndex sets the open index state properly', () => {
4 const wrapper = mount(<Accordion items={[]} />)
5 expect(wrapper.state('openIndex')).toBe(0)
6 wrapper.instance().setOpenIndex(1)
7 expect(wrapper.state('openIndex')).toBe(1)
8})

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:

1// counter.js
2import React from 'react'
3
4class Counter extends React.Component {
5 state = {count: 0}
6 increment = () => this.setState(({count}) => ({count: count + 1}))
7 render() {
8 return <button onClick={this.increment}>{this.state.count}</button>
9 }
10}
11
12export default Counter

Now let's see how we could test it in a way that's ready for refactoring it to use hooks:

1// __tests__/counter.js
2import React from 'react'
3import {render, fireEvent} from '@testing-library/react'
4import Counter from '../counter.js'
5
6test('counter increments the count', () => {
7 const {container} = render(<Counter />)
8 const button = container.firstChild
9 expect(button.textContent).toBe('0')
10 fireEvent.click(button)
11 expect(button.textContent).toBe('1')
12})

That test will pass. Now, let's refactor this to a hooks version of the same component:

1// counter.js
2import React from 'react'
3
4function Counter() {
5 const [count, setCount] = useState(0)
6 const incrementCount = () => setCount(c => c + 1)
7 return <button onClick={incrementCount}>{count}</button>
8}
9
10export 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 componentWillUnmountto 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:

1const sum = (a, b) => a + b

Here's a refactor of this function:

1const 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:

1const 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:

1| call | result before | result after |
2|--------------|---------------|--------------|
3| sum() | NaN | 0 |
4| sum(1) | NaN | 1 |
5| sum(1, 2) | 3 | 3 |
6| 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:

1class Counter extends React.Component {
2 state = {
3 count: Number(window.localStorage.getItem('count') || 0),
4 }
5 increment = () => this.setState(({count}) => ({count: count + 1}))
6 componentDidMount() {
7 window.localStorage.setItem('count', this.state.count)
8 }
9 componentDidUpdate(prevProps, prevState) {
10 if (prevState.count !== this.state.count) {
11 window.localStorage.setItem('count', this.state.count)
12 }
13 }
14 render() {
15 return <button onClick={this.increment}>{this.state.count}</button>
16 }
17}

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:

1// __tests__/counter.js
2import React from 'react'
3import {render, fireEvent} from '@testing-library/react'
4import Counter from '../counter.js'
5
6afterEach(() => {
7 window.localStorage.removeItem('count')
8})
9
10test('counter increments the count', () => {
11 const {container} = render(<Counter />)
12 const button = container.firstChild
13 expect(button.textContent).toBe('0')
14 fireEvent.click(button)
15 expect(button.textContent).toBe('1')
16})
17
18test('reads and updates localStorage', () => {
19 window.localStorage.setItem('count', 3)
20 const {container, rerender} = render(<Counter />)
21 const button = container.firstChild
22 expect(button.textContent).toBe('3')
23 fireEvent.click(button)
24 expect(button.textContent).toBe('4')
25 expect(window.localStorage.getItem('count')).toBe('4')
26})

That test passes! Woo! Now let's "refactor" this to hooks again with these new features:

1import React, {useState, useEffect} from 'react'
2
3function Counter() {
4 const [count, setCount] = useState(() =>
5 Number(window.localStorage.getItem('count') || 0),
6 )
7 const incrementCount = () => setCount(c => c + 1)
8 useEffect(() => {
9 window.localStorage.setItem('count', count)
10 }, [count])
11 return <button onClick={incrementCount}>{count}</button>
12}
13
14export 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 or componentDidUpdate, effects scheduled with useEffect 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 separate useLayoutEffect Hook with an API identical to useEffect.

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!

However, this is not a refactor. It's actually a change in behavior. As far as the end user is concerned, that change is unobservable. In our efforts to ensure that our tests are free of implementation details, that change should be unobservable as well.

Whelp, thanks to the new act utility from react-dom/test-utils we can make that happen. So React Testing Library integrates with that utility and makes it so all our tests continue to pass as written, allowing the tests we write to be free of implementation details and continue to resemble the way our software is used as closely as possible.

What about render props components?

This is probably my favorite actually. Here's a simple counter render prop component:

1class Counter extends React.Component {
2 state = {count: 0}
3 increment = () => this.setState(({count}) => ({count: count + 1}))
4 render() {
5 return this.props.children({
6 count: this.state.count,
7 increment: this.increment,
8 })
9 }
10}
11// usage:
12// <Counter>
13// {({ count, increment }) => <button onClick={increment}>{count}</button>}
14// </Counter>

Here's how I would test this:

1// __tests__/counter.js
2import React from 'react'
3import {render, fireEvent} from '@testing-library/react'
4import Counter from '../counter.js'
5
6function renderCounter(props) {
7 let utils
8 const children = jest.fn(stateAndHelpers => {
9 utils = stateAndHelpers
10 return null
11 })
12 return {
13 ...render(<Counter {...props}>{children}</Counter>),
14 children,
15 // this will give us access to increment and count
16 ...utils,
17 }
18}
19
20test('counter increments the count', () => {
21 const {children, increment} = renderCounter()
22 expect(children).toHaveBeenCalledWith(expect.objectContaining({count: 0}))
23 increment()
24 expect(children).toHaveBeenCalledWith(expect.objectContaining({count: 1}))
25})

Ok, so let's refactor the counter to a component that uses hooks:

1function Counter(props) {
2 const [count, setCount] = useState(0)
3 const increment = () => setCount(currentCount => currentCount + 1)
4 return props.children({
5 count: count,
6 increment,
7 })
8}

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:

1function useCounter() {
2 const [count, setCount] = useState(0)
3 const increment = () => setCount(currentCount => currentCount + 1)
4 return {count, increment}
5}
6
7export default useCounter
8
9// usage:
10// function Counter() {
11// const {count, increment} = useCounter()
12// return <button onClick={increment}>{count}</button>
13// }

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:

1function useCounter() {
2 const [count, setCount] = useState(0)
3 const increment = () => setCount(currentCount => currentCount + 1)
4 return {count, increment}
5}
6
7const Counter = ({children, ...props}) => children(useCounter(props))
8
9export default Counter
10export {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 useCountercustom 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!

What about hooks libraries?

If you're writing a generic or open source hook, then you may want to test it without a specific component in mind. In that case, I recommend using react-hooks-testing-library.

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!

Discuss on TwitterEdit post on GitHub

Share article
loading relevant upcoming workshops...

TestingJavaScript.com

Your essential guide to flawless testing.

Jump on this self-paced workshop and learn the smart, efficient way to test any JavaScript application. 🏆

Start Now
Kent C. Dodds

Kent C. Dodds is a JavaScript software engineer and teacher. He's taught hundreds of thousands of people how to make the world a better place with quality software development tools and practices. He lives with his wife and four kids in Utah.