loading...
Cover image for Writing tests for Vue.js Storybook

Writing tests for Vue.js Storybook

kylewelsby profile image Kyle Welsby Updated on ・6 min read

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

Screenshot 2019-10-19 20.50.02.png

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.
🙏

Posted on by:

kylewelsby profile

Kyle Welsby

@kylewelsby

Code Wrangler, Travel & Food Photographer, and Maker of http://soulectiontracklists.com . From Great Britain 🇬🇧

Discussion

markdown guide