Testing in React can often be a challenge for developers. There are plenty of options and libraries to choose from, some more difficult to use than others. Some of that is down to the complexity or syntax of the testing frameworks, while React introduces plenty of its own issues that can be difficult to deal with, such as testing hooks and deferred actions.
This tutorial article assumes that you have basic knowledge of testing methodologies, either using jest, jasmine or other libraries, and you are familiar with the describe
, it
, before*
, after*
syntax.
React and jest
Jest with snapshops is the React recommended way of working, but they also suggest that you look at trying out React testing library. There’s the old-school way of testing, pre-jest, which is to invoke events on elements wrapped in act()
and then asserting changes to the dom. We won’t be advocating that approach in this post.
JavaScript testing libraries jasmine, chai, sinon and mocha have shaped the way that developers write their tests, with simple to understand syntax, assertable states, mocks, spies and more. Jest and Enzyme are relative newcomers to the testing world, but bring along the same ideas that have worked so well in the past, while introducing a few new ideas of their own.
Jest is a JavaScript testing utility built for JavaScript by Facebook, primarily to support the testing effort in their React framework. It uses the concepts introduced from chai, sinon, mocha, jasmine and other libraries, even using them underneath to drive assertions, mocks and spies, to create a developer-friendly testing environment. With its simplistic approach to testing and very easy integration into React, it has become the preferred testing library for React applications.
Where enzyme fits in
Not every testing library is created equally though, and there is significant complexity within React applications that are just too difficult to test easily with jest. This is where Enzyme bridges the gap, as an alternative JavaScript testing utility for React, developed by Airbnb. The library aims to make testing your components easier, by allowing the developer to manipulate, traverse and simulate runtime events, and work with the results. In practice, it works better than it does on paper.
Enzyme currently (at the time of writing this article) has adapters that will work with React versions 16.x
, 15.x
, 0.14.x
and 0.13.x
. The only thing the user needs to do is install the plugin using their preferred package manager, and configure the adapter in the Enzyme runtime environment.
The difference between react-testing-library
and enzyme
is that enzyme isn’t designed to override the testing environment, it’s a tool that you can use alongside jest. React testing library can serve the same purpose, but it also provides functionality to fundamentally alter the testing runtime environment.
Getting started
Code: Step 1 - Adding jest.
For this tutorial, we’ll be using a repository that I have created, with a basic component input form element and a rendered element for the output. As we progress through this post, I will share links with more completed code. So let’s check out the code and get cracking!
This is our basic form that we will be testing:
Installing enzyme
Code: Step 2 - Installing and configuring enzyme.
The process of installing enzyme is straight forward and has the following steps:
- Install the library through your preferred package manager.
- Configure an enzyme adapter
- Configure jest to use enzyme
- Test!
This is the current adapter to react version mapping. Depending on the version of React that your project uses, you will need to install one of these adapters (full list of adapters and compatibility).
Adapter package | React semver version |
---|---|
enzyme-adapter-react-16 |
^16.4.0-0 |
enzyme-adapter-react-16.3 |
~16.3.0-0 |
enzyme-adapter-react-16.2 |
~16.2 |
To get started with enzyme, we simply install it into our project, along with the required adapter:
npm i --save-dev enzyme enzyme-adapter-react-16
Configuring the adapter
Once enzyme has been installed, we need to configure enzyme to use the adapter you want to use for the version of React that you have. To do this, you can use the top-level Enzyme.Configure()
API:
// enzyme.setup.js
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({ adapter: new Adapter() });
Adding to jest
To be able to use enzyme tests with jest, we need to do an additional installation and configuration step, adding jest-enzyme and configuring it with jest.
npm i --save-dev jest-enzyme
If you don’t already have a jest.config.js
file, create it by copying the jest.config.js in the repository.
We need to add the enzyme configuration script to the jest.config.js
file:
module.exports = {
...
setupFiles: [ "<rootDir>/enzyme.setup.js" ]
}
Now that we’ve got enzyme added, let’s get cracking with tests!
Crash course on enzyme
The one major benefit that enzyme has over jest
and react-testing-library
is the way that you can test the rendering and interactivity of components, through the use of helper methods shallow
, mount
and render
.
Shallow rendering with shallow()
Shallow rendering is an extremely useful tool, as it constrains you to testing the component as a unit, without worrying about inadvertently testing and asserting the behaviour of child elements, hooks and other functionality.
The shallow
API does call React lifecycle methods such as componentDidMount
and componentDidUpdate
.
Full DOM rendering with mount()
Mounting a component is equivalent to render()
in react-testing-library; it performs a full dom render. This is ideal for use cases where you have components that may interact with an API, have deferred actions, interact with hooks, or are wrapped in higher order components.
Unlike shallow or static rendering, mount
actually mounts the component in the DOM, which means tests can affect each other if they’re using the same DOM.
Static rendering a component with render()
Static rendering utilises the render()
function to generate HTML from your React tree, to compare and analyze the results. The wrapper returned is similar to mount
and shallow
, however render
uses the thirdparty lib cheerio
to do the traversing and parsing.
React hooks support
Enzyme supports React hooks with some limitations in shallow
, due to the React renderer. The following hooks do not behave as expected when using shallow
:
useEffect()
anduseLayoutEffect()
doesn’t get calleduseCallback()
doesn’t memoize callback
Writing enzyme tests
Code: Step 3 - Writing enzyme tests.
Our app structure is made up of a single component Name
, with a test file name.test.js
. The first test that we’re going to write will make use of enzym’s shallow()
renderer, which can work with hooks like useReducer()
and useState()
, which are vital for functional components.
Writing our first test
In this test, we’re going to confirm that the component renders with an empty name by adding tests to the Name test suite file: <root>/src/components/name/name.test.js
.
Tests already exist in this file, so we can go ahead and delete those, leaving us with a single describe
:
describe("Component: Name", () => {
});
We need to set up the fixtures in our new test suite first, so that we write less boiler plate for each test. Add the following inside the Component: Name
test suite:
let component = null;
beforeEach(() => {
component = shallow(<Name />);
})
afterEach(() => {
component = null;
});
Now that we’ve got our fixture set up, let’s assert that the component shallow renders correctly using the exists()
utility function:
it("should render component", () => {
expect(component.exists("form")).toBe(true);
})
The shallow api provides us with useful helper functions that allow us to query the rendered DOM in a jQuery-like way. We can query on direct references of the component, an id, a class, an element, and even more complex query strings.
shallow
also provides additional functionality in the ShallowWrapper
API to check elements at node positions, if they exist, if they’re visible and more.
Asserting default states
It’s always prudent to assert the default rendered state of a component, given certain inputs. There are times when a component can be in a state of flux, given asynchronous and deferred actions, such as with the useEffect
hook, but we still need to test those initial, indeterminate and final states.
Let’s add an additional test and validate that our Name
component renders as expected without state with the text()
utility function.
it("should render default state", () => {
expect(component.exists("#output")).toBe(true);
expect(component.find("#output").text()).toBe("Hello, ");
});
Asserting name is displayed after entry
Now it’s time to simulate user interactions and the reflection of those interactions on our UI. Enzyme provides a useful utility function with the shallow
renderer to achieve that, by using simulate()
.
Simulate allows the user to trigger an event on the current node. Let’s add our test to make sure our name is displayed when we enter it.
it("should display name when user types into input field", () => {
component.find("input#name").simulate("change", { target: { value: "Charles" } });
expect(component.find("#output").text()).toBe("Hello, Charles");
});
Shallow rendering with children components
Code: Stage 4 - Shallow rendering with child nodes
The idea of shallow rendering is the render only the component you need, but there are times when that’s just not possible, such as when components have dependencies on shared libraries or shared resources. Other times, you may need to verify how a component behaves when a different theme or language is applied, and these changes are typically applied with the Context API.
In the next test, we’re going to test rendering child nodes within the parent node.
First of all, let’s create a new render component NameRenderer
:
//<root>/src/components/name/nameRenderer.js
import React from "react";
export const NameRenderer = ({ name }) => <div role="output" id="output">Hello, {name}</div>;
Running our test suite should result in two failures:
✓ should render component
✕ should render default state
✕ should display name when user types into input field
This is because we are asserting on the element <div#output>
which no longer exists in this component, but is rendered in another component. This is the side effect of using shallow
- it only renders the nodes within the current component.
Using the extremely helpful .debug()
utility function (also available with mount
), we can see that the <div>
has been replaced with the NameRenderer
component:
<Fragment>
<form autoComplete="off">
<input type="hidden" autoComplete="false" />
<label htmlFor="name">
Name:
</label>
<input aria-label="name-input" type="text" id="name" name="name" value="" onChange={[Function: handleChange]} />
</form>
<NameRenderer name="" />
</Fragment>
Enzyme provides us with the dive()
utility on the ShallowWrapper
API, which allows us to render child nodes one at a time. Be careful though, because this can, and does trigger effects, lifecycle events and other deferred and asynchronous actions contained within that component. By using dive, you’re also expanding the boundary of what a component unit test is as well.
Let’s update our first broken test, should render default state
, by implementing the dive()
function.
//<root>/src/components/name/name.test.js
it("should render default state", () => {
expect(component.exists(NameRenderer)).toBe(true);
expect(component.find(NameRenderer).dive().find("#output").text()).toBe("Hello, ");
});
Running our test suite, we get:
✓ should render component
✓ should render default state
✕ should display name when user types into input field
Success! Let’s update the last test case, which checks that the output is rendered:
//<root>/src/components/name/name.test.js
it("should display name when user types into input field", () => {
component.find("input#name").simulate("change", { target: { value: "Charles" } });
expect(component.find(NameRenderer).dive().find("#output").text()).toBe("Hello, Charles");
});
Let’s check again with npm t
…
✓ should render component
✓ should render default state
✓ should display name when user types into input field
Great, success! In this limited case, we have introduced a new component and tested that props are successfully passed to the component and rendered on the virtual dom.
Testing nested nodes in shallow should be avoided
Code: Stage 5 - Updating the enzyme tests.
We have a fundamental issue with our tests at this point, which breaks the concept of unit testing. Unit tests are intended to test indivdual units of source code with minimal integration where possible. In our example, we’re testing our Name
component, but also the integration to NameRenderer
and that NameRenderer
renders correctly!
Let’s fix up that up, by creating a a test suite for NameRenderer
and adjusting the unit tests in Name
.
Let’s create our nameRenderer.test.js
file, with our fixtures set up:
// <root>/src/components/name/nameRenderer.test.js
import { NameRenderer } from "./nameRenderer";
import React from "react";
import { shallow } from "enzyme";
describe("Component: NameRenderer", () => {
let component = null;
beforeEach(() => {
component = shallow(<NameRenderer />);
})
afterEach(() => {
component = null;
});
});
Even though our NameRenderer
components takes a { name: string }
prop, we haven’t defined it here, because we can use the .setProps()
utility function to update the value, and simulate the input value changing.
First, we’ll remove the default value assertion from the name.test.js
test suite, and create a new default value assertion in nameRenderer.test.js
test suite, querying the element with the id #output
:
// <root>/src/components/name/nameRenderer.test.js
it("should not render name", () => {
expect(component.find("#output").text()).toBe("Hello, ");
});
To simulate input props on a component changing, we can use the setProps
function, and pass through a new value for name
. Let’s add that to our test and validate the state of the component changes with the new value:
// <root>/src/components/name/nameRenderer.test.js
it("should render name from prop", () => {
component.setProps({ name: "Charles" });
expect(component.find("#output").text()).toBe("Hello, Charles");
});
Running our tests with npm t
should give us 4 passing tests:
PASS src/components/name/nameRenderer.test.js
Component: NameRenderer
✓ should not render name
✓ should render name from prop
PASS src/components/name/name.test.js
Component: Name
✓ should render component
✓ should display name when user types into input field
Great success, but we still have the lingering issue of testing the NameRenderer
component in the name.test.js
test suite. Lets update that now. We don’t need to validate the text changing in the component, we just need to validate that the properties on the child component change.
We can do that by using the .props()
utility function and validating the value of the props. When we use .props()
, it returns an object key/value map of all the properties passed to the component. Thus, in our test, it returns { name: 'Charles' }
;
Lets update our test and assert with props:
// <root>/src/components/name/name.test.js
it("should display name when user types into input field", () => {
component.find("input#name").simulate("change", { target: { value: "Charles" } });
expect(component.find(NameRenderer).props()).toStrictEqual({name: "Charles"});
});
We’ve updated our test suites to remove duplicated test efforts, and we’ve create tests against our renderer component. Let’s look at using mount
to work with React hooks.
Testing hooks with mount
Code: Stage 6 - Testing hooks with mount
.
Testing hooks in React can be done in multiple ways; using act()
to perform synchronous updates to the dom for rendered elements with ReactDOM
, use the hook testing functions from react-testing-library
, or use mount()
with enzyme. Let’s look at how we do it with enzyme.
While there are limitations for what we can achieve for hook testing with shallow
, those limitations don’t exist with mount
. But be aware that deferred and asynchronous actions do update the dom, and can report errors, and while you don’t necessarily have to deal with asynchronous errors, you should handle them, as jest does not report failed async promises as errors, if you don’t handle it correctly.
Let’s create our component that will talk to the json placeholder api, and update the component when the asynchronous request from the useEffect
hook is successful.
// <root>/src/components/todo/todo.js
import React, { useEffect, useState } from "react";
export const Todo = () => {
const [todo, setTodo] = useState(undefined);
useEffect(() => {
fetch(`https://jsonplaceholder.typicode.com/todos/1`)
.then(response => response.json())
.then(json => setTodo(json));
}, []);
return todo
? <div id="todo">{todo.id}: {todo.title}{todo.completed ? " [completed]" : null}</div>
: <div id="todo">Loading...</div>;
}
Because we’re using the browser built in fetch
library, we’re going to have to mock it. We won’t initialise the component in our beforeEach
because we need to wrap the asynchronous calls in act
to ensure that the react lifecycle events and hooks are correctly called.
// <root>/src/components/todo/todo.test.js
let data = {
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
};
beforeEach(() => {
global.fetch = jest.fn(() => Promise.resolve({
status: 200,
data,
json: () => data
}));
});
Whenever we test a mounted component, especially one with deferred and asynchronous actions that can update the UI, we need to wrap the test in act
:
// <root>/src/components/todo/todo.test.js
import { act } from 'react-dom/test-utils';
...
await act(async () => {
component = await mount(<Todo />);
});
Deferred actions within the act
will be executed, and the UI will be updated, but there may be times when additional deferred and asynchronous actions are executed, and the component may need to be ‘refreshed’. We can update the component using the .update()
utility function.
// <root>/src/components/todo/todo.test.js
await act(async () => {
component = await mount(<Todo />);
component.update();
});
Let’s put it all together now, adding tests to ensure the element exists, the element renders, and fetch
is called with the todo api - you don’t need to be connected to the internet for the tests to pass. As you can see below, our assertions are the same between shallow
and mount
, with the primary difference being that deferred and asynchronous actions are automatically called, along with other components rendering.
it("should render element 'todo'", async () => {
await act(async () => {
component = await mount(<Todo />);
});
expect(component.exists("#todo")).toBe(true);
});
it("should show todo once async resolves", async () => {
await act(async () => {
component = await mount(<Todo />);
component.update();
});
expect(component.find("#todo").text()).toBe("1: delectus aut autem");
})
it("should call fetch", async () => {
await act(async () => {
component = await mount(<Todo />);
component.update();
});
expect(global.fetch).toHaveBeenLastCalledWith("https://jsonplaceholder.typicode.com/todos/1");
})
When we run our npm t
we should see green across the board:
PASS src/components/name/nameRenderer.test.js
Component: NameRenderer
✓ should not render name
✓ should render name from prop
PASS src/components/name/name.test.js
Component: Name
✓ should render component
✓ should display name when user types into input field
PASS src/components/todo/todo.test.js
Component: Todo
✓ should render element 'todo'
✓ should show todo once async resolves
✓ should call fetch
Great success!
Summary
We’ve looked at use cases for shadow
and mount
, covered the differences between the two testing methods, and demonstrated the ability for mount
to execute useEffect
hooks, that update the UI.
Enzyme can do more a heck of a lot more than what we’ve covered. Its rich querying syntax allows for deep component comparison and testing, which far and away exceeds the what the next best tools can offer.
When used alongside jest
, enzyme is a terrific tool that makes testing components a breeze, and takes the genuine pain out of the equation. When testing React-specific functionality (did useEffect
get called? 🤔), just remember to test the outcome, not the React action itself.