Introduction
Testing is a double-edged sword. On one hand, having a solid test suite makes code easier to refactor, and gives confidence that it works the way it should. On the other hand, tests must be written and maintained. They have a cost, like any other code.
In a magical world, we could write our code, and then verify that it works with very little extra code.
Snapshot tests come close to offering this dreamy future. In this tutorial, we will go over what snapshot tests are and how to start using them with React.
What is a Snapshot Test?
A snapshot test verifies that a piece of functionality works the same as it did when the snapshot was created. It’s like taking a picture of an app in a certain state, and then being able to automatically verify that nothing has changed.
We used the word “picture” there, but the snapshot tests we’ll be looking at have nothing to do with images or screenshots. They are purely textual.
Here’s an example. Let’s say you created a React component which renders a list of 3 things, like this:
Once you have it working, you can take a “snapshot” of it — you just need to copy and paste its HTML representation into a file.
<ul class="todo-list">
<li class="todo-item">A New Hope</li>
<li class="todo-item">The Empire Strikes Back</li>
<li class="todo-item">Return of the Jedi</li>
</ul>
Then, later on, you can verify that the component still works correctly by rendering it with the same data, and then comparing the rendered HTML against the saved snapshot.
This is, essentially, what a snapshot test does. The first time it is run, it saves a snapshot of the component. Next time it runs (and every time thereafter) it compares the rendered component to the snapshot. If they differ, the test fails. Then, you have the opportunity to either update the snapshot, or fix the component to make it match.
Write the Component First
An important consequence of the way snapshot tests work is that the component should work before you write a test for it. Snapshot testing is not test-driven development.
Strict test-driven development follows the “red-green-refactor” pattern: write a failing test, then write enough code to make that test pass, then refactor if necessary.
Snapshot testing, in contrast, follows something like a “green-green-refactor” approach: make the component work, then write a test to take a snapshot, then refactor if necessary.
TDD purists may think this sounds bad. We recommend thinking of snapshot testing as a tool in your arsenal — just one tool. It’s not a solution to every testing situation, just like TDD isn’t perfectly suited to every situation.
Likewise, snapshot testing doesn’t entirely replace other testing libraries and techniques. You can still use Enzyme and ReactTestUtils. You should still test Redux parts (actions, reducers, etc) in isolation.
Snapshot testing is a new tool to add to your toolbelt. It’s not a whole new toolbelt.
Try It Out
Now that we have the theory covered, let’s see what these snapshot tests look like and write a few of them.
If you don’t have an existing project, create one with Create React App and follow along:
- Install node and npm if you don’t already have them
- Install Create React App by running this command:
npm install -g create-react-app
- Create a project by running:
create-react-app snapshot-testing
Introducing Jest
The tool we’ll be using to run these tests is called Jest. It is a test runner that also comes with expectations (the expect
function) and mocks and spies. If you’ve done some testing before, you may be familiar with libraries like Mocha, Sinon, and Chai for handling these pieces — Jest provides everything in one package. The full API can be seen here. It also has the “snapshot testing” feature we’ll be using here, which no other tools currently have.
If you have an existing project that you’d like to add snapshot testing to, I will point you to the official documentation rather than duplicate it here. Even if you plan to integrate Jest into your own project, we suggest using Create React App and following the rest of this tutorial to get a feel for how snapshot testing works. For the rest of this tutorial, we’ll assume you’re using Create React App.
The project that Create React App generates comes with one test to start with. Try it out and make sure everything is working by running this command in the terminal:
npm test
This one command will run all the tests in “watch” mode. This means that after running all the tests once, it will watch for changes to files, and re-run the tests for the files that change.
You should see something like this:
Jest’s built-in watch mode is one of the best things about it. Unlike most other testing tools that simply show you successes and failures, Jest goes out of its way to make testing easier. The team at Facebook has clearly been working at making the developer experience great.
It will only re-run tests in files that have changed — but it even goes one step further, and will re-run tests for files that import the files that changed. It knows about your project dependency tree and uses that to intelligently reduce the amount of work it needs to do.
Jest will also help you manage your snapshots by telling you when they are no longer used, and you can easily clean them up by pressing the “u” key.
At the bottom, you can see that there are a few commands you can issue. One of them is q
, to quit. Hit q
now, and we’ll get ready to create our first snapshot test (you can also quit with Ctrl-C
).
Setting Up Snapshot Testing
Let’s take a look at the App.test.js
file. It contains this single boilerplate test:
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
it('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(<App />, div);
});
This is not a snapshot test, but it does verify that the test runner (Jest) is working. So, let’s add a real snapshot test.
First, we need to add one import
at the top:
import renderer from 'react-test-renderer';
This is the Jest snapshot renderer, which we’ll use in a second. It does not come preinstalled, however, so next we must install it. At the command line, run this:
npm install --save-dev react-test-renderer
Now, you can start the tests in watch mode again:
npm test
Did You Get an Error?
If you’re using React 15.4, everything should work at this point. However, if you’re using an older version of React, you might see this error:
Invariant Violation: ReactCompositeComponent: injectEnvironment() can only be called once.
You can read this Github issue for more information about why this fails, but if you are unable to use React 15.4 for some reason, add this line to the top of App.test.js
, under the imports:
jest.mock('react-dom');
You should be able to run npm test
again, and it should work.
Add a Snapshot Test
Now, for the first real snapshot test. Add this code at the bottom of App.test.js
:
it('renders a snapshot', () => {
const tree = renderer.create(<App/>).toJSON();
expect(tree).toMatchSnapshot();
});
Let’s go over what’s happening here.
First, we’re using an arrow function to create the test (the () => {
part). If you’re not familiar with them, don’t worry: the () => {
is equivalent to function() {
in this case. It’s just easier to write. Arrow functions also preserve the “this” binding, but we’re not making use of that capability here.
Next, we call renderer.create
and pass it a React element — <App/>
— in JSX form. Contrast this with the ReactDOM.render
in the test above. They both render the element, but renderer.create
creates a special output that has a toJSON
method.
This toJSON
call is important: it turns the component representation into JSON, like it says, which makes it easier to save as a snapshot, and compare to existing snapshots.
You can see what it looks like if you add a console.log(tree)
after the renderer.create
line. Try removing the toJSON
call too, and see what that object looks like.
Finally, the line expect(tree).toMatchSnapshot()
does one of these two things:
- If a snapshot already exists on disk, it compares the new snapshot in
tree
to the one on disk. If they match, the test passes. If they don’t, the test fails. - If a snapshot does not already exist, it creates one, and the test passes.
By “already exists on disk”, we mean that Jest will look in a specific directory, called __snapshots__
, for a snapshot that matches the running test file. For example, it will look for App.test.js.snap
when running snapshot comparisons in the App.test.js
file.
These snapshot files should be checked into source control along with the rest of your code.
Here’s what that snapshot file contains:
exports[test renders a snapshot 1] = `
<div
className="App">
<div
className="App-header">
<img
alt="logo"
className="App-logo"
src="test-file-stub" />
<h2>
Welcome to React
</h2>
</div>
<p
className="App-intro">
To get started, edit
<code>
src/App.js
</code>
and save to reload.
</p>
</div>
`;
You can see that it’s basically just an HTML rendering of the component. Every snapshot comparison (a call expect(...).toEqualSnapshot()
) will create a new entry in this snapshot file with a unique name.
Failed Snapshot Tests
Let’s look at what happens when a test fails.
Open src/App.js
and delete this line:
<h2>Welcome to React</h2>
Now run the tests, by running npm test
. You should see output similar to this:
This is a diff, showing the differences between the expected output (the snapshot) and the actual output. Here’s how to read it:
The lines colored green (with the − signs) were expected, but missing. Those are lines that the snapshot has, but the new test output does not.
The lines colored red (with the + signs) were not expected. Those lines were not in the snapshot, but they appeared in the rendered output.
Lines colored gray are correct, and unchanged.
To get a feel for how this works, put back the line you took out:
<h2>Welcome to React</h2>
When you save the file, the tests will automatically re-run, and should pass.
Try different combinations of small changes, and then look at the diff to see how it represents additions, deletions, and changes.
Certain kinds of changes, like trailing spaces, can be difficult to see in the diff output. If you look at the expected vs. actual output and can see no differences, spaces may be the culprit.
Updating Snapshot Tests
Now, let’s say we wanted to make the header smaller. Change the h2
tags to h3
. The test will fail.
Here’s a great feature of Jest: all you need to do is hit the u
key to replace the incorrect snapshots with the latest ones! Try it now. Hit u
. The tests will re-run and pass this time.
Create a New Component with Tests
Now, let’s create a new component and use snapshot tests to verify it works. It’ll be a simple counter component that doesn’t allow negative numbers.
Create a new file src/PositiveCounter.js
, and paste in this code:
import React, { Component } from 'react';
export default class PositiveCounter extends Component {
constructor(props) {
super(props);
this.state = {
count: 0
};
}
increment = () => {
this.setState({
count: this.state.count + 1
});
}
decrement = () => {
this.setState({
count: Math.max(0, this.state.count - 1)
});
}
render() {
return (
<span>
Value: {this.state.count}
<button className="decrement" onClick={this.decrement}>−</button>
<button className="increment" onClick={this.increment}>+</button>
</span>
);
}
}
If we were writing normal unit tests, now would be a good time to write some. Or, if we were doing test-driven development, we might’ve already written a few tests. Those are still valid approaches that can be combined with snapshot testing, but snapshot tests serve a different purpose.
Before we write a snapshot test, we should manually verify that the component works as expected.
Open up src/App.js
and import the new PositiveCounter component at the top:
import PositiveCounter from './PositiveCounter';
Then, put it inside the render method somewhere:
class App extends Component {
render() {
return (
<div className="App">
<PositiveCounter/>
...
</div>
);
}
}
Start up the app by running npm start
in the terminal, and you should see the new counter. If you still have the test watcher running, it will fail because the content of App
has changed. Press u
to update the test.
Try out the PositiveCounter component. You should be able to click “+” a few times, then “-” a few times, but the number should never go below 0.
Now that we know it works, let’s write the snapshot tests.
Create a new file, src/PositiveCounter.test.js
, and start it off like this:
import React from 'react';
import ReactDOM from 'react-dom';
import PositiveCounter from './PositiveCounter';
import renderer from 'react-test-renderer';
it('should render 0', () => {
const tree = renderer.create(<PositiveCounter/>).toJSON();
expect(tree).toMatchSnapshot();
});
If npm test
isn’t running, start it now. You should see “1 snapshot written in 1 test suite”, and the test will pass. You can inspect the file src/__snapshots__/PositiveCounter.test.js.snap
to see what it rendered.
Let’s now add a test that increments the counter:
it('should render 2', () => {
const component = renderer.create(<PositiveCounter/>);
component.getInstance().increment();
component.getInstance().increment();
expect(component.toJSON()).toMatchSnapshot();
});
Jest will again report that it wrote 1 snapshot, and the test will pass. Inspecting the snapshot file will verify that it rendered a “2” for this test. Remember, though: we already verified that the component works correctly. All we’re doing with this test is making sure it doesn’t stop working, due to changes in child components, a refactoring, or some other change.
Here we used the component.getInstance()
function to get an instance of the PositiveCounter
class, then called its increment
method.
Notice that we’re not actually “clicking” the button itself, but rather calling the method directly. At this time, Jest doesn’t seem to have good facilities for finding child components. If we wanted to click the button itself, we could write this instead:
component.toJSON().children[3].props.onClick()
However, this is fairly brittle and difficult to write, especially if there are multiple levels of nesting. The only advantage to this is that it verifies the onClick
function is bound correctly. If you need to do DOM interaction like this, it might be better to write that a separate test using Enzyme or ReactTestUtils.
Let’s add one more test. This one will verify that the counter cannot go negative:
it('should not go negative', () => {
const component = renderer.create(<PositiveCounter/>);
component.getInstance().increment();
component.getInstance().decrement();
component.getInstance().decrement();
expect(component.toJSON()).toMatchSnapshot();
});
Remember we’ve already tested this functionality manually — this is just cementing it in place. The test should pass.
Continuous Testing
We can set up our little project to be tested every time new code is pushed. Let’s look at how to do that with Semaphore. Setup only takes a couple minutes, and it’s free.
- Push your code to a repository on either GitHub or Bitbucket.
- Sign up for an account at Semaphore, confirm your email, and sign in.
Click the “Add new project” button:
You’ll be prompted to select an organization. Just select your account:
Next, if you haven’t already, select either Github or Bitbucket, depending on where your repository lives:
Then, select the project repository from the list:
Next, select the branch (most likely “master”). Semaphore will analyze the project, and should automatically choose npm test
as the Job 1 command:
You’ll probably want to select the latest version of Node at the top:
Finally, click “Build with These Settings” and it’ll start building.
Wrapping Up
In this article, we covered how to get set up with snapshot testing, write a few tests, and run them in the cloud on Semaphore.
Snapshot tests are a quick and easy way to make sure your components continue to work through refactoring and other changes. It doesn’t replace other styles of testing, such as using Enzyme or ReactTestUtils, but it augments them with a nice first-pass approach. With snapshot tests, you have even fewer excuses to write tests. Try them out in your own project.
If you have any questions and comments, feel free to leave them in the comment section below.
Thanks to Christopher Pojer for feedback and comments.