Snapshot test your Vue components with Jest
Published by Matthew Daly 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>67<script>8export default {9 data () {10 return {}11 }12}13</script>1415<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'34const Constructor = Vue.extend(Issue)5const vm = new Constructor().$mount()67describe('Issue', () => {8 it('should render', () => {9 expect(vm.$el.querySelector('h1').textContent).toEqual('An Issue')10 });1112 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 test23> myproject@1.0.0 test /home/matthew/Projects/myproject4> jest __test__/ --coverage56 PASS __test__/components/issue.spec.js7 Issue8 ✓ should render (46ms)9 ✓ should match the snapshot (14ms)1011Snapshot Summary12 › 1 snapshot written in 1 test suite.1314Test Suites: 1 passed, 1 total15Tests: 2 passed, 2 total16Snapshots: 1 added, 1 total17Time: 8.264s18Ran 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 Summary2 › 1 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 test23> myproject@1.0.0 test /home/matthew/Projects/myproject4> jest __test__/ --coverage56 PASS __test__/components/issue.spec.js7 Issue8 ✓ should render (40ms)9 ✓ should match the snapshot (12ms)1011Test Suites: 1 passed, 1 total12Tests: 2 passed, 2 total13Snapshots: 1 passed, 1 total14Time: 3.554s15Ran 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 test23> myproject@1.0.0 test /home/matthew/Projects/myproject4> jest __test__/ --coverage56 FAIL __test__/components/issue.spec.js (5.252s)7 ● Issue › should render89 expect(received).toEqual(expected)1011 Expected value to equal:12 "An Issue"13 Received:14 "My Issue"1516 at Object.<anonymous> (__test__/components/issue.spec.js:9:52)17 at Promise.resolve.then.el (node_modules/p-map/index.js:42:16)1819 ● Issue › should match the snapshot2021 expect(value).toMatchSnapshot()2223 Received value does not match stored snapshot 1.2425 - Snapshot26 + Received2728 <div>29 <h1>30 - An Issue31 + My Issue32 </h1>33 </div>3435 at Object.<anonymous> (__test__/components/issue.spec.js:13:20)36 at Promise.resolve.then.el (node_modules/p-map/index.js:42:16)3738 Issue39 ✕ should render (48ms)40 ✕ should match the snapshot (25ms)4142Snapshot Summary43 › 1 snapshot test failed in 1 test suite. Inspect your code changes or run with `npm test -- -u` to update them.4445Test Suites: 1 failed, 1 total46Tests: 2 failed, 2 total47Snapshots: 1 failed, 1 total48Time: 7.082s49Ran 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>67<script>8export default {9 props: {10 issue: Object11 },12 data () {13 return {}14 }15}16</script>1718<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'34const Constructor = Vue.extend(Issue)5const issue = {6 name: 'My Issue'7}8const vm = new Constructor({9 propsData: { issue: issue }10}).$mount()1112describe('Issue', () => {13 it('should render', () => {14 expect(vm.$el.querySelector('h1').textContent).toEqual('My Issue')15 });1617 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 test23> myproject@1.0.0 test /home/matthew/Projects/myproject4> jest __test__/ --coverage56 FAIL __test__/components/issue.spec.js7 ● Issue › should match the snapshot89 expect(value).toMatchSnapshot()1011 Received value does not match stored snapshot 1.1213 - Snapshot14 + Received1516 <div>17 <h1>18 - An Issue19 + My Issue20 </h1>21 </div>2223 at Object.<anonymous> (__test__/components/issue.spec.js:18:20)24 at Promise.resolve.then.el (node_modules/p-map/index.js:42:16)2526 Issue27 ✓ should render (39ms)28 ✕ should match the snapshot (25ms)2930Snapshot Summary31 › 1 snapshot test failed in 1 test suite. Inspect your code changes or run with `npm test -- -u` to update them.3233Test Suites: 1 failed, 1 total34Tests: 1 failed, 1 passed, 2 total35Snapshots: 1 failed, 1 total36Time: 3.717s37Ran 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 -- -u23> myproject@1.0.0 test /home/matthew/Projects/myproject4> jest __test__/ --coverage "-u"56 PASS __test__/components/issue.spec.js7 Issue8 ✓ should render (39ms)9 ✓ should match the snapshot (14ms)1011Snapshot Summary12 › 1 snapshot updated in 1 test suite.1314Test Suites: 1 passed, 1 total15Tests: 2 passed, 2 total16Snapshots: 1 updated, 1 total17Time: 3.668s18Ran 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.