Shallow Testing Hooks with Enzyme

Will Ockelmann-Wagner ·

Earlier this year, React added the powerful new Hooks feature. Hooks allow you to ditch class components and stick with functional components, even when you need local state and lifecycle methods. They also make it much easier to extract logic from one component and share it with others.

Here’s a quick example of a react component that uses hooks for local state and lifecycle methods:

const HelloWorld = ({ onLoad, onExit }) => {
// Set up local state
const [name, setName] = React.useState("");
// run onLoad on mount, and onExit when object unmounts.
React.useEffect(() => {
onLoad();
return onExit;
}, []);
// Say hello every time the name changes.
React.useEffect(() => console.log(`hello ${name}`), [name]);
// Control an input with the name.
return <input value={name} onChange={event => setName(event.target.value)} />;
};
view raw hello-world.js hosted with ❤ by GitHub

Hooks significantly simplify your code, and you can use them within an existing React codebase that otherwise uses classes. And it’s easy to dive right into using hooks – until you try to test your fancy new components.

If you’re used to using Enzyme’s shallow rendering, you’re going to find yourself really confused when none of your tests pass, even though everything’s working perfectly in your browser. Don’t worry! The problem probably isn’t with your code.

The problem is Hooks are shiny and new, and React and Enzyme are still catching up on the testing front. The Enzyme devs have done a ton of work already, and there’s a Github issue you can follow to see how that’s going. But as of right now, a shallow rendered component will support useState, and that’s about it. 

There are alternatives. React Testing Library, a library that always renders all of a component’s children, supports all the new stuff. And at this point, Enzyme’s mount also supports hooks. But if you’re in an existing codebase that relies on shallow rendering for unit testing, or you just want to unit test your components in isolation (and maybe use Cypress or something for integration testing), it might feel like you have to hold off on Hooks. But don’t git reset --hard just yet!! 

Example Problem: loading authors and posts

To have a concrete example to work with, we’ll use JSONPlaceholder, a free API that lets you query for example data about users, blog posts, comments, and images. We’ll build a page that fetches and displays a list of authors. When you select an author, we’ll fetch and display their blog posts.

Here’s what this little app looks like (you can also see the live version here):

And here’s the implementation using hooks – specifically useState for local state, and useEffect for side effects and lifecycle hooks:

const Authors = ({ fetchAuthors, fetchPosts }) => {
const [authors, setAuthors] = useState([]);
const [activeAuthor, setActiveAuthor] = useState(null);
const [posts, setPosts] = useState([]);
// Load authors on start
React.useEffect(() => {
fetchAuthors().then(setAuthors);
}, []);
// Load Posts when author changes
React.useEffect(() => {
setPosts([]);
if (activeAuthor) {
fetchPosts(activeAuthor.id).then(setPosts);
}
}, [activeAuthor]);
return (
<div className="authors">
<div className="author-options">
<h3>Select an Author:</h3>
{authors.map(author => (
<Author
key={author.id}
author={author}
activeAuthor={activeAuthor}
onSelect={setActiveAuthor}
/>
))}
</div>
{activeAuthor && (
<div className="posts">
<h3>Posts by {activeAuthor.name}</h3>
{posts.map(post => (
<Post key={post.id} post={post} />
))}
</div>
)}
</div>
);
};
view raw authors.js hosted with ❤ by GitHub

If you’re more used to class components and this looks a bit weird, I’d suggest digging into quite comprehensive React hooks docs.

Using a useEffect hook

Since Enzyme supports useState in shallow rendered components these days, so we’ll mostly focus on useEffect. useEffect is just a function that takes a callback function and a list of values, and calls the callback once on first render and again every time the list of values changes. So we can call a function just once on first render by passing an empty dependency list:

const [authors, setAuthors] = useState([]);
React.useEffect(() => {
fetchAuthors().then(setAuthors);
}, []);

Or we can trigger a side-effect every time a value (which might come from props or local state) changes:

const [activeAuthor, setActiveAuthor] = useState(null);
const [posts, setPosts] = useState([]);
React.useEffect(() => {
fetchPosts(activeAuthor.id).then(setPosts);
}, [activeAuthor]);

Testing a useEffect hook

The trick to shallow testing hooks like useEffect is to use spies. The examples here are specifically for Jest, but they should work equally well with Chai Spies or whatever spying library you use.

When your app is running, useEffect will schedule its effect to be run after the first render. But in a test, most cases where you’re shallow rendering you’d prefer useEffect call its callback immediately, so the effects happen during that first render.

We can make that happen with a spy:

jest.spyOn(React, 'useEffect').mockImplementation(f => f());

This makes useEffect do what we want – take the callback f, and call it synchronously. Then your test can have assertions on the side effects of your function, and on what got passed to useEffect itself. Testing that useEffect react to its props correctly (that it re-calls a loader every time the url changes, for instance) is better handled in an integration test.

The one caveat here is, if you import React, { useEffect } from 'react' in the way the docs suggest, you’re not going to be importing your mocked function, and your test will still fail. But if you just use React.useEffect in your component, everything will work just fine.

With that, we can write tests for the first render. The component should load and render the list of authors, but should not load posts, because the user has not selected an author. Here are those unit tests:

import React from "react";
import { shallow } from "enzyme";
import Authors from "./Authors";
describe("Authors", () => {
let props;
let wrapper;
const alice = { id: 1, name: "alice" };
const bob = { id: 2, name: "bob" };
const authors = [alice, bob];
const posts = [{ id: 1, title: "a post", body: "the body" }];
beforeEach(() => {
useEffect = jest.spyOn(React, "useEffect").mockImplementation(f => f());
props = {
fetchAuthors: jest.fn().mockResolvedValue(authors),
fetchPosts: jest.fn().mockResolvedValue(posts)
};
wrapper = shallow(<Authors {…props} />);
});
describe("on start", () => {
it("loads the authors", () => {
expect(props.fetchAuthors).toHaveBeenCalled();
});
it("does not load posts", () => {
expect(props.fetchPosts).not.toHaveBeenCalled();
});
it("renders the authors", () => {
expect(wrapper.find("Author")).toHaveLength(2);
const firstAuthor = wrapper.find("Author").first();
expect(firstAuthor.prop("author")).toEqual(alice);
expect(firstAuthor.prop("activeAuthor")).toEqual(null);
});
});
});

If you run this code though, there’s a problem. You’ll get a whole bunch of these:

UnhandledPromiseRejectionWarning: Invariant Violation: Too many re-renders. React limits the number of renders to prevent an infinite loop.

The issue is that useEffect calls setAuthors, which triggers a render, which calls useEffect, which calls setAuthors, and so on. With the real useEffect, you can guard against infinite loops by passing an array of dependencies. But with our mocked version, we’ve lost all that memoization goodness in exchange for testability.

This often isn’t a problem – if you’re using something like redux, the functions you call in useEffect will probably just dispatch actions, and won’t trigger a render in your test. When you’re using local state though, you have to be more explicit about when useEffect should run.

So instead of just always mocking useEffect, you tell it exactly how many times to run with mockImplementationOnce. Putting the mocking into a function makes this all a little easier to use:

describe("Authors", () => {
let props;
let wrapper;
let useEffect;
const mockUseEffect = () => {
useEffect.mockImplementationOnce(f => f());
};
beforeEach(() => {
useEffect = jest.spyOn(React, "useEffect");
props = {
fetchAuthors: jest.fn().mockResolvedValue(authors),
fetchPosts: jest.fn().mockResolvedValue(posts)
};
mockUseEffect();
mockUseEffect();
wrapper = shallow(<Authors {…props} />);
});
describe("on start", () => {
it("loads the authors", () => {
expect(props.fetchAuthors).toHaveBeenCalled();
});
});
});

Now the two useEffect calls will run once each, and then stop – which is just what they would do in the real component!

Finally, we can use this new technique to test what happens when a state change causes an effect to run again. For example, when a user selects an author we’ll want to check the right posts are loaded and then displayed.

We can simulate an author select event. That should change the activeAuthor local state, re-run useEffect to fetch the posts, and put them in the local state as posts. And because we’re mocking both the fetching functions and useEffect, this will all happen nice and synchronously in the test.

describe("given selected author", () => {
beforeEach(() => {
// Expect one more render loop of useEffect
mockUseEffect();
mockUseEffect();
// Trigger the select action
wrapper
.find("Author")
.first()
.simulate("select", alice);
});
it("sets the active author", () => {
expect(wrapper.find("Author")).toHaveLength(2);
const firstAuthor = wrapper.find("Author").first();
expect(firstAuthor.prop("author")).toEqual(alice);
});
it("loads the right posts", () => {
expect(props.fetchPosts).toHaveBeenCalledWith(alice.id);
});
it("renders the posts", () => {
expect(wrapper.find("Post").prop("post")).toEqual(posts[0]);
});
});

This spying technique works well with other hooks like useRef, as well as your own custom hooks. And as a reminder, we didn’t have to do anything special for useState to work in the shallow tests.

And that’s all there is to it! Now you can use the newest cutting-edge React feature, without having to give up your nice isolated Enzyme shallow tests.

To sum up:

  • Mock the hook with: jest.spyOn(React, 'useEffect').mockImplementation(f => f());
  • Use React.useEffect instead of using the import { useEffect } from 'react'
  • Try mockImplementationOnce if you run into infinite loop problems in your tests.
  • You can use these techniques with any hook, not just useEffect.

Finally, if you want to see these tests in a working project, here’s the repo with all the code from this post: https://github.com/will-wow/shallow-react-16.


Interested in more software development tips and insights? Visit the development section on our blog!

Now hiring developers, designers and product managers.
Apply now: www.carbonfive.com/careers

 

Will Ockelmann-Wagner
Will Ockelmann-Wagner

Will Ockelmann-Wagner is a software developer at Carbon Five. He’s into functional programming and testable code.