Writing tests for Vue.js Storybook
Over the last couple of weeks, I've found new joy with writing my Vue.js components within Storybook as a tool to visualise all the possible permutations of a given Component in isolation from the target application.
It's all fair game writing your code, hitting save and seeing the change in the browser and visually observing everything works as expected. That's not good enough! I want unit-tests to ensure my components functionality is what I expect. ✅
In this guide, I'll show you how to install Jest to your Storybook project and examples of tests for Vue.js components.
Getting started
If you already have Storybook and Vue.js installed to your project, please skip to Installing Jest.
Let's get you quickly started with Storybook and Vue.js by creating a new project folder where your stories will reside.
Make a new folder; here we'll call it design-system
but you can call it whatever you like.
mk ./design-system
cd ./design-system
Now we'll install our main dependencies Vue.js and Storybook.
note: My personal preference is the Single File Component style of Vue.js for ease of understanding between projects.
npm init -y # initialize a new package.json quicly
npm install --save vue
npm install --save-dev vue-loader vue-template-compiler @babel/core babel-core@^7.0.0-bridge.0 babel-loader babel-preset-vue
npx -p @storybook/cli sb init --type sfc_vue
Hooray! We've got Storybook installed with a couple of Vue.js examples to start.
Let's boot the Storybook server and see what we got.
npm run storybook
That is great and all, but now we'll want to set up Jest. 😄
Installing Jest
Let's get stuck right in and install all the dependencies required.
npm install --save-dev jest vue-jest babel-jest @babel/core @babel/preset-env @vue/test-utils
Configure Babel by creating a babel.config.js
file in the root of the project.
// babel.config.js
module.exports = { presets: ['@babel/preset-env']
}
Configuration for Jest will need to be added too by creating a jest.config.js
file in the root of the project.
// jest.config.js
module.exports = { moduleFileExtensions: ['js', 'vue', 'json'], transform: { '^.+\\.js$': 'babel-jest', '.*\\.(vue)$': 'vue-jest' }, collectCoverage: true, collectCoverageFrom: ['<rootDir>/src/**/*.vue'], transformIgnorePatterns: ["/node_modules/(?!@babel/runtime)"], coverageReporters: ["text-summary", "html", "lcov", "clover"]
}
Finally, we'll need to update the package.json
scripts to reference Jest as our test runner.
// package.json
{ "name": "storybook-vue", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest", "storybook": "start-storybook -p 6006", "build-storybook": "build-storybook" }, ...
}
Before we continue, let's give our installation a quick run to ensure everything is looking ok.
We'll have to run Jest with --passWithNoTests
as we haven't written any tests yet.
note: the double dashes --
on their own are intentional to allow the arguments to be passed through to the inner command.
npm run test -- --passWithNoTests
We should see the following output.
npm run test -- --passWithNoTests > storybook-vue@1.0.0 test ~/code/design-system
> jest "--passWithNoTests" No tests found, exiting with code 0 =============================== Coverage summary ===============================
Statements : Unknown% ( 0/0 )
Branches : Unknown% ( 0/0 )
Functions : Unknown% ( 0/0 )
Lines : Unknown% ( 0/0 )
================================================================================
Great!, everything looks like it's wired up ok for Jest to be happy, now let's write some tests. 🤖
Writing our first test
Given we set up the project fresh and ran the initialise command in Storybook, we should have some simple example stories waiting for us in src/stories
.
For example, our project structure would look something like this.
tree -I 'node_modules|coverage'
.
|-- babel.config.js
|-- jest.config.js
|-- package-lock.json
|-- package.json
`-- src `-- stories |-- 0-Welcome.stories.js |-- 1-Button.stories.js |-- MyButton.vue `-- Welcome.vue
2 directories, 8 files
Create a new file in the src/stories
directory called MyButton.test.js
so we can write our first tests for MyButton.vue
.
In this test file, we'll import the MyButton.vue
component and @vue/test-utils
.
// src/stories/MyButton.test.js
import Component from './MyButton.vue';
import { shallowMount } from "@vue/test-utils"; describe('MyButton', () => { let vm let wrapper beforeEach(() => { wrapper = shallowMount(Component) vm = wrapper.vm })
})
Looking at our MyButton.vue
file, we'll see in the <script>
block a method called onClick
.
// src/stories/MyButton.vue (fragment)
export default { name: 'my-button', methods: { onClick () { this.$emit('click'); } }
}
This method, when called, will emit a click
event to any parent consuming components. So testing this will require us to spy on $emit
, and we will expect $emit
to be called with click
.
Our test will look like the following.
// src/stories/MyButton.test.js (fragment)
describe('onClick', () => { it('emits click', () => { vm.$emit = jest.fn() vm.onClick() expect(vm.$emit).toHaveBeenCalledWith('click') })
})
Here's a full example of our MyButton.vue.js
test file.
// src/stories/MyButton.test.js
import { shallowMount } from "@vue/test-utils";
import Component from './MyButton.vue'; describe('MyButton', () => { let vm let wrapper beforeEach(() => { wrapper = shallowMount(Component) vm = wrapper.vm }) describe('onClick', () => { it('emits click', () => { vm.$emit = jest.fn() vm.onClick() expect(vm.$emit).toHaveBeenCalledWith('click') }) })
})
Brilliant! We can run our tests and see how we're doing.
npm run test > storybook-vue@1.0.0 test ~/code/design-system
> jest PASS src/stories/MyButton.test.js MyButton onClick ✓ emits click (15ms) =============================== Coverage summary ===============================
Statements : 25% ( 1/4 )
Branches : 100% ( 0/0 )
Functions : 33.33% ( 1/3 )
Lines : 25% ( 1/4 )
================================================================================
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 2.921s
Ran all test suites.
🎉 Congratulations you've just written our first test for our Storybook project!
... but what is that in the Coverage summary? 25% of the lines are covered? That has to be improved.
Improving code coverage
As we did with our first test, we'll create a new file for the other component Welcome.test.js
in the src/stories
directory.
The contents of Welcome.vue
is a little more involved with props and having to preventDefault
.
// src/stories/Welcome.vue
const log = () => console.log('Welcome to storybook!') export default { name: 'welcome', props: { showApp: { type: Function, default: log } }, methods: { onClick (event) { event.preventDefault() this.showApp() } }
}
Let's cover the natural part first, methods
as with the tests in MyButton.test.js
we can copy most of this code across.
As our code stipulates, we'll need to spy on the given property showApp
to ensure it is called and the event we provide will have to include preventDefault
.
// src/stories/Welcome.test.js (fragment)
describe('onClick', () => { it('calls showApp', () => { let showApp = jest.fn() wrapper.setProps({ showApp }) let event = { preventDefault: jest.fn() } vm.onClick(event) expect(showApp).toHaveBeenCalled() expect(event.preventDefault).toHaveBeenCalled() })
})
Testing props have a subtle difference to it as we need to fully mount the component to access the $options
where props
are defined.
// src/stories/Welcome.test.js (fragment)
describe("props.showApp", () => { it('logs message', () => { wrapper = mount(Component) vm = wrapper.vm let prop = vm.$options.props.showApp; let spy = jest.spyOn(console, 'log').mockImplementation() prop.default() expect(console.log).toHaveBeenCalledWith('Welcome to storybook!') spy.mockRestore() })
})
Ensure to import mount
from @vue/test-utils
// src/stories/Welcome.test.js (fragment)
import { shallowMount, mount } from "@vue/test-utils";
You would notice we're using jest.spyOn()
to mock the implementation of console.log
to allow us to assert .toHaveBeCalledWith
and then restore the console.log
to its initial application once our test has completed.
Here is a full example of the test file.
// src/stories/Welcome.test.js
import { shallowMount, mount } from "@vue/test-utils";
import Component from './Welcome.vue'; describe('Welcome', () => { let vm let wrapper beforeEach(() => { wrapper = shallowMount(Component) vm = wrapper.vm }) describe("props.showApp", () => { it('logs message', () => { wrapper = mount(Component) vm = wrapper.vm let prop = vm.$options.props.showApp; let spy = jest.spyOn(console, 'log').mockImplementation() prop.default() expect(console.log).toHaveBeenCalledWith('Welcome to storybook!') spy.mockRestore() }) }) describe('onClick', () => { it('calls showApp', () => { let showApp = jest.fn() wrapper.setProps({ showApp }) let event = { preventDefault: jest.fn() } vm.onClick(event) expect(showApp).toHaveBeenCalled() expect(event.preventDefault).toHaveBeenCalled() }) })
})
We can rerun our tests and fingers crossed the coverage should be vastly improved. 🤞
npm test > storybook-vue@1.0.0 test ~/code/design-system
> jest PASS src/stories/MyButton.test.js PASS src/stories/Welcome.test.js =============================== Coverage summary ===============================
Statements : 100% ( 4/4 )
Branches : 100% ( 0/0 )
Functions : 100% ( 3/3 )
Lines : 100% ( 4/4 )
================================================================================ Test Suites: 2 passed, 2 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 2.404s
Ran all test suites.
That is Awesome, well done! 🚀
Notes
With most code challenges, I usually battle through small problems along the way. Here I like to give credit to where I have found solutions to the issues I have experienced while getting the project setup.
Using Jest with Babel as documented required adding babel-core@7.0.0-bridge.0
to the development dependencies to ensure it works well with Babel 7.
You'll notice in the jest.config.js
I included a transformIgnorePatterns
definition. Although the current code doesn't demand too much from Core.js, I added this definition. It will save some headake later on in your development, avoiding the no descriptive SyntaxError: Unexpected identifier
issues.
Thank you for reading, I hope this helped you get your Vue.js Storybook project to the next level.
🙏