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

December 24th, 2018 — 10 min read

by Mat Reding
by Mat Reding

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 * as 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 { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

test('counter increments the count', () => {
	render(<Counter />)
	const button = screen.getByRole('button')
	expect(button).toHaveTextContent('0')
	userEvent.click(button)
	expect(button).toHaveTextContent('1')
})

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

// counter.js
import * as React from 'react'

function Counter() {
	const [count, setCount] = useState(0)
	const incrementCount = () => setCount((c) => c + 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 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:

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 { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import Counter from '../counter'

afterEach(() => {
	window.localStorage.removeItem('count')
})

test('counter increments the count', () => {
	render(<Counter />)
	const button = screen.getByRole('button')
	expect(button).toHaveTextContent('0')
	userEvent.click(button)
	expect(button).toHaveTextContent('1')
})

test('reads and updates localStorage', () => {
	window.localStorage.setItem('count', 3)
	render(<Counter />)
	const button = screen.getByRole('button')
	expect(button).toHaveTextContent('3')
	userEvent.click(button)
	expect(button).toHaveTextContent('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((c) => c + 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 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:

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 * as React from 'react'
import { render } from '@testing-library/react'

import Counter from '../counter'

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 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 renderHook from @testing-library/react.

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!

Epic React

Get Really Good at React

Illustration of a Rocket

Testing JavaScript

Ship Apps with Confidence

Illustration of a trophy
Kent C. Dodds
Written by Kent C. Dodds

Kent C. Dodds is a JavaScript software engineer and teacher. Kent'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.

Learn more about Kent

Want to learn more?

Join Kent in a live workshop

If you found this article helpful.

You will love these ones as well.