Snapshot test your Vue components with Jest

Published by at 17th June 2017 1:12 pm

At work I've recently started using Vue as my main front-end framework instead of Angular 1. It has a relatively shallow learning curve and has enough similarities with both React and Angular 1 that if you're familiar with one or both of them it feels quite familiar. We're a Laravel shop and Laravel comes out of the box with a basic scaffolding for using Vue, so not only is it the path of least resistance, but many of my colleagues knew it already and it's used on some existing projects (one of which I've been helping out on this week), so it made sense to learn it. Add to that the fact that the main alternative is Angular 2, which I vehemently dislike, and learning Vue was a no-brainer.

Snapshot tests are a really useful way of making sure your user interface doesn't change unexpectedly. Facebook introduced them to their Jest testing framework last year, and they've started to appear in other testing frameworks too. In their words...

A typical snapshot test case for a mobile app renders a UI component, takes a screenshot, then compares it to a reference image stored alongside the test. The test will fail if the two images do not match: either the change is unexpected, or the screenshot needs to be updated to the new version of the UI component.

This makes it easy to make sure than a UI component, such as a React or Vue component, does not unexpectedly change how it is rendered. In the event that it does change, it will fail the test, and it's up to the developer to confirm whether or not that's expected - if so they can generate a new version of the snapshot and be on their way. Without it, you're stuck manually testing that the right HTML tags get generated, which is a chore.

Jest's documentation is aimed pretty squarely at React, but it's not hard to adapt it to work with Vue components. Here I'll show you how I got it working with Vue.

Setting up a new project

I used the Vue CLI boilerplate generator to set up my initial dependencies for this project. I then had to install some further packages:

$ npm install --save-dev jest babel-jest jest-vue-preprocessor

After that, I had to configure Jest to work with Vue. The finished package.json looked like this:

1{
2 "name": "myproject",
3 "version": "1.0.0",
4 "description": "A project",
5 "author": "Matthew Daly <matthew@matthewdaly.co.uk>",
6 "private": true,
7 "scripts": {
8 "dev": "node build/dev-server.js",
9 "start": "node build/dev-server.js",
10 "build": "node build/build.js",
11 "lint": "eslint --ext .js,.vue src",
12 "test": "jest __test__/ --coverage"
13 },
14 "dependencies": {
15 "vue": "^2.3.3",
16 "vue-router": "^2.3.1"
17 },
18 "devDependencies": {
19 "autoprefixer": "^6.7.2",
20 "babel-core": "^6.22.1",
21 "babel-eslint": "^7.1.1",
22 "babel-jest": "^20.0.3",
23 "babel-loader": "^6.2.10",
24 "babel-plugin-transform-runtime": "^6.22.0",
25 "babel-preset-env": "^1.3.2",
26 "babel-preset-stage-2": "^6.22.0",
27 "babel-register": "^6.22.0",
28 "chalk": "^1.1.3",
29 "connect-history-api-fallback": "^1.3.0",
30 "copy-webpack-plugin": "^4.0.1",
31 "css-loader": "^0.28.0",
32 "eslint": "^3.19.0",
33 "eslint-config-standard": "^6.2.1",
34 "eslint-friendly-formatter": "^2.0.7",
35 "eslint-loader": "^1.7.1",
36 "eslint-plugin-html": "^2.0.0",
37 "eslint-plugin-promise": "^3.4.0",
38 "eslint-plugin-standard": "^2.0.1",
39 "eventsource-polyfill": "^0.9.6",
40 "express": "^4.14.1",
41 "extract-text-webpack-plugin": "^2.0.0",
42 "file-loader": "^0.11.1",
43 "friendly-errors-webpack-plugin": "^1.1.3",
44 "html-webpack-plugin": "^2.28.0",
45 "http-proxy-middleware": "^0.17.3",
46 "jest": "^20.0.4",
47 "jest-vue-preprocessor": "^1.0.1",
48 "opn": "^4.0.2",
49 "optimize-css-assets-webpack-plugin": "^1.3.0",
50 "ora": "^1.2.0",
51 "rimraf": "^2.6.0",
52 "semver": "^5.3.0",
53 "shelljs": "^0.7.6",
54 "url-loader": "^0.5.8",
55 "vue-loader": "^12.1.0",
56 "vue-style-loader": "^3.0.1",
57 "vue-template-compiler": "^2.3.3",
58 "webpack": "^2.6.1",
59 "webpack-bundle-analyzer": "^2.2.1",
60 "webpack-dev-middleware": "^1.10.0",
61 "webpack-hot-middleware": "^2.18.0",
62 "webpack-merge": "^4.1.0"
63 },
64 "engines": {
65 "node": ">= 4.0.0",
66 "npm": ">= 3.0.0"
67 },
68 "browserslist": [
69 "> 1%",
70 "last 2 versions",
71 "not ie <= 8"
72 ],
73 "jest": {
74 "testRegex": "spec.js$",
75 "moduleFileExtensions": [
76 "js",
77 "vue"
78 ],
79 "transform": {
80 "^.+\\.js$": "<rootDir>/node_modules/babel-jest",
81 ".*\\.(vue)$": "<rootDir>/node_modules/jest-vue-preprocessor"
82 }
83 }
84}

I won't include things like the Webpack config, because that's all generated by Vue CLI. Note that we need to tell Jest what file extensions it should work with, including .vue, and we need to specify the appropriate transforms for different types of files. We use jest-vue-preprocessor for .vue files and babel-jest for .js files.

With that done, we can create a basic component. We'll assume we're writing a simple issue tracker here, and our first component will be at src/components/Issue.vue:

1<template>
2 <div>
3 <h1>An Issue</h1>
4 </div>
5</template>
6
7<script>
8export default {
9 data () {
10 return {}
11 }
12}
13</script>
14
15<style scoped>
16</style>

Next, we create a simple test for this component. Save this as __test__/components/issue.spec.js:

1import Issue from '../../src/components/Issue.vue'
2import Vue from 'vue'
3
4const Constructor = Vue.extend(Issue)
5const vm = new Constructor().$mount()
6
7describe('Issue', () => {
8 it('should render', () => {
9 expect(vm.$el.querySelector('h1').textContent).toEqual('An Issue')
10 });
11
12 it('should match the snapshot', () => {
13 expect(vm.$el).toMatchSnapshot()
14 });
15});

Constructor is what creates our Vue component, while vm is our actual newly-mounted Vue component. We can refer to the HTML inside the component through vm.$el, so we can then work with the virtual DOM easily.

In the first test we use the more traditional method of verifying our UI component has worked as expected - we fetch an HTML tag inside it and verify that the content inside is what we expect. This is fine for a small component, but as the components get larger we'll find it more of a chore.

The second test is much simpler and more concise. We simply assert that it matches the snapshot. Not only is that easier, but it can scale to components of any size because we don't have to check every little element.

Let's run our tests:

1$ npm test
2
3> myproject@1.0.0 test /home/matthew/Projects/myproject
4> jest __test__/ --coverage
5
6 PASS __test__/components/issue.spec.js
7 Issue
8 ✓ should render (46ms)
9 ✓ should match the snapshot (14ms)
10
11Snapshot Summary
121 snapshot written in 1 test suite.
13
14Test Suites: 1 passed, 1 total
15Tests: 2 passed, 2 total
16Snapshots: 1 added, 1 total
17Time: 8.264s
18Ran all test suites matching "__test__/".
19-----------------------------------------------------------|----------|----------|----------|----------|----------------|
20File | % Stmts | % Branch | % Funcs | % Lines |Uncovered Lines |
21-----------------------------------------------------------|----------|----------|----------|----------|----------------|
22All files | 96.15 | 50 | 100 | 96 | |
23 root | 100 | 100 | 100 | 100 | |
24 unknown | 100 | 100 | 100 | 100 | |
25 root/home/matthew/Projects/myproject/__test__/components | 100 | 100 | 100 | 100 | |
26 issue.spec.js | 100 | 100 | 100 | 100 | |
27 root/home/matthew/Projects/myproject/src/components | 94.44 | 50 | 100 | 94.12 | |
28 Issue.vue | 94.44 | 50 | 100 | 94.12 | 39 |
29-----------------------------------------------------------|----------|----------|----------|----------|----------------|

Note this section:

1Snapshot Summary
21 snapshot written in 1 test suite.

This tells us that the snapshot has been successfully written. If we run the tests again we should see that it checks against the existing snapshot:

1$ npm test
2
3> myproject@1.0.0 test /home/matthew/Projects/myproject
4> jest __test__/ --coverage
5
6 PASS __test__/components/issue.spec.js
7 Issue
8 ✓ should render (40ms)
9 ✓ should match the snapshot (12ms)
10
11Test Suites: 1 passed, 1 total
12Tests: 2 passed, 2 total
13Snapshots: 1 passed, 1 total
14Time: 3.554s
15Ran all test suites matching "__test__/".
16-----------------------------------------------------------|----------|----------|----------|----------|----------------|
17File | % Stmts | % Branch | % Funcs | % Lines |Uncovered Lines |
18-----------------------------------------------------------|----------|----------|----------|----------|----------------|
19All files | 96.15 | 50 | 100 | 96 | |
20 root | 100 | 100 | 100 | 100 | |
21 unknown | 100 | 100 | 100 | 100 | |
22 root/home/matthew/Projects/myproject/__test__/components | 100 | 100 | 100 | 100 | |
23 issue.spec.js | 100 | 100 | 100 | 100 | |
24 root/home/matthew/Projects/myproject/src/components | 94.44 | 50 | 100 | 94.12 | |
25 Issue.vue | 94.44 | 50 | 100 | 94.12 | 39 |
26-----------------------------------------------------------|----------|----------|----------|----------|----------------|

Great stuff. Now, if we make a minor change to our component, such as changing the text from An Issue to My Issue, does it pick that up?

1$ npm test
2
3> myproject@1.0.0 test /home/matthew/Projects/myproject
4> jest __test__/ --coverage
5
6 FAIL __test__/components/issue.spec.js (5.252s)
7 ● Issue › should render
8
9 expect(received).toEqual(expected)
10
11 Expected value to equal:
12 "An Issue"
13 Received:
14 "My Issue"
15
16 at Object.<anonymous> (__test__/components/issue.spec.js:9:52)
17 at Promise.resolve.then.el (node_modules/p-map/index.js:42:16)
18
19 ● Issue › should match the snapshot
20
21 expect(value).toMatchSnapshot()
22
23 Received value does not match stored snapshot 1.
24
25 - Snapshot
26 + Received
27
28 <div>
29 <h1>
30 - An Issue
31 + My Issue
32 </h1>
33 </div>
34
35 at Object.<anonymous> (__test__/components/issue.spec.js:13:20)
36 at Promise.resolve.then.el (node_modules/p-map/index.js:42:16)
37
38 Issue
39 ✕ should render (48ms)
40 ✕ should match the snapshot (25ms)
41
42Snapshot Summary
431 snapshot test failed in 1 test suite. Inspect your code changes or run with `npm test -- -u` to update them.
44
45Test Suites: 1 failed, 1 total
46Tests: 2 failed, 2 total
47Snapshots: 1 failed, 1 total
48Time: 7.082s
49Ran all test suites matching "__test__/".
50-----------------------------------------------------------|----------|----------|----------|----------|----------------|
51File | % Stmts | % Branch | % Funcs | % Lines |Uncovered Lines |
52-----------------------------------------------------------|----------|----------|----------|----------|----------------|
53All files | 96.15 | 50 | 100 | 96 | |
54 root | 100 | 100 | 100 | 100 | |
55 unknown | 100 | 100 | 100 | 100 | |
56 root/home/matthew/Projects/myproject/__test__/components | 100 | 100 | 100 | 100 | |
57 issue.spec.js | 100 | 100 | 100 | 100 | |
58 root/home/matthew/Projects/myproject/src/components | 94.44 | 50 | 100 | 94.12 | |
59 Issue.vue | 94.44 | 50 | 100 | 94.12 | 39 |
60-----------------------------------------------------------|----------|----------|----------|----------|----------------|

Yes, we can see that it's picked up on the change and thrown an error. Note this line:

1 snapshot test failed in 1 test suite. Inspect your code changes or run with `npm test -- -u` to update them.

Jest is telling us that our snapshot has changed, but if we expect that, we can just run npm test -- -u to replace the existing one with our new one. Then, our tests will pass again.

Now, this component is pretty useless. It doesn't accept any external input whatsoever, so the response is always going to be the same. How do we test a more dynamic component? Amend the component to look like this:

1<template>
2 <div>
3 <h1>{{ issue.name }}</h1>
4 </div>
5</template>
6
7<script>
8export default {
9 props: {
10 issue: Object
11 },
12 data () {
13 return {}
14 }
15}
16</script>
17
18<style scoped>
19</style>

We're now passing the issue object into our component as a prop, and getting the name from that. That will break our test, so we need to amend it to pass through the props:

1import Issue from '../../src/components/Issue.vue'
2import Vue from 'vue'
3
4const Constructor = Vue.extend(Issue)
5const issue = {
6 name: 'My Issue'
7}
8const vm = new Constructor({
9 propsData: { issue: issue }
10}).$mount()
11
12describe('Issue', () => {
13 it('should render', () => {
14 expect(vm.$el.querySelector('h1').textContent).toEqual('My Issue')
15 });
16
17 it('should match the snapshot', () => {
18 expect(vm.$el).toMatchSnapshot()
19 });
20});

Here we pass our prop into the constructor for the component. Now, let's run the tests again:

1$ npm test
2
3> myproject@1.0.0 test /home/matthew/Projects/myproject
4> jest __test__/ --coverage
5
6 FAIL __test__/components/issue.spec.js
7 ● Issue › should match the snapshot
8
9 expect(value).toMatchSnapshot()
10
11 Received value does not match stored snapshot 1.
12
13 - Snapshot
14 + Received
15
16 <div>
17 <h1>
18 - An Issue
19 + My Issue
20 </h1>
21 </div>
22
23 at Object.<anonymous> (__test__/components/issue.spec.js:18:20)
24 at Promise.resolve.then.el (node_modules/p-map/index.js:42:16)
25
26 Issue
27 ✓ should render (39ms)
28 ✕ should match the snapshot (25ms)
29
30Snapshot Summary
311 snapshot test failed in 1 test suite. Inspect your code changes or run with `npm test -- -u` to update them.
32
33Test Suites: 1 failed, 1 total
34Tests: 1 failed, 1 passed, 2 total
35Snapshots: 1 failed, 1 total
36Time: 3.717s
37Ran all test suites matching "__test__/".
38-----------------------------------------------------------|----------|----------|----------|----------|----------------|
39File | % Stmts | % Branch | % Funcs | % Lines |Uncovered Lines |
40-----------------------------------------------------------|----------|----------|----------|----------|----------------|
41All files | 96.3 | 50 | 100 | 96.15 | |
42 root | 100 | 100 | 100 | 100 | |
43 unknown | 100 | 100 | 100 | 100 | |
44 root/home/matthew/Projects/myproject/__test__/components | 100 | 100 | 100 | 100 | |
45 issue.spec.js | 100 | 100 | 100 | 100 | |
46 root/home/matthew/Projects/myproject/src/components | 94.44 | 50 | 100 | 94.12 | |
47 Issue.vue | 94.44 | 50 | 100 | 94.12 | 39 |
48-----------------------------------------------------------|----------|----------|----------|----------|----------------|

Jest has picked up on our changes and thrown an error. However, because we know the UI has changed, we're happy with this situation, so we can tell Jest to replace the prior snapshot with npm test -- -u as mentioned earlier:

1$ npm test -- -u
2
3> myproject@1.0.0 test /home/matthew/Projects/myproject
4> jest __test__/ --coverage "-u"
5
6 PASS __test__/components/issue.spec.js
7 Issue
8 ✓ should render (39ms)
9 ✓ should match the snapshot (14ms)
10
11Snapshot Summary
121 snapshot updated in 1 test suite.
13
14Test Suites: 1 passed, 1 total
15Tests: 2 passed, 2 total
16Snapshots: 1 updated, 1 total
17Time: 3.668s
18Ran all test suites matching "__test__/".
19-----------------------------------------------------------|----------|----------|----------|----------|----------------|
20File | % Stmts | % Branch | % Funcs | % Lines |Uncovered Lines |
21-----------------------------------------------------------|----------|----------|----------|----------|----------------|
22All files | 96.3 | 50 | 100 | 96.15 | |
23 root | 100 | 100 | 100 | 100 | |
24 unknown | 100 | 100 | 100 | 100 | |
25 root/home/matthew/Projects/myproject/__test__/components | 100 | 100 | 100 | 100 | |
26 issue.spec.js | 100 | 100 | 100 | 100 | |
27 root/home/matthew/Projects/myproject/src/components | 94.44 | 50 | 100 | 94.12 | |
28 Issue.vue | 94.44 | 50 | 100 | 94.12 | 39 |
29-----------------------------------------------------------|----------|----------|----------|----------|----------------|

Great, we now have a passing test suite again! That's all we need to make sure that any regressions in the generated HTML of a component get caught.

Of course, this won't help with the actual functionality of the component. However, Jest is pretty easy to use to write tests for the actual functionality of the application. If you prefer another testing framework, it's possible to do the same with them, although I will leave setting them up as an exercise for the reader.