DEV Community

Cover image for Writing tests for Vue.js Storybook
Kyle Welsby
Kyle Welsby

Posted on • Edited on

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Configure Babel by creating a babel.config.js file in the root of the project.

// babel.config.js
module.exports = {
  presets: [
    '@babel/preset-env'
  ]
}

Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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"
  },
  ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 )
================================================================================
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  })
})
Enter fullscreen mode Exit fullscreen mode

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');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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')
  })
})
Enter fullscreen mode Exit fullscreen mode

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')
    })
  })
})
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

🎉 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()
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
  })
})
Enter fullscreen mode Exit fullscreen mode

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()
  })
})
Enter fullscreen mode Exit fullscreen mode

Ensure to import mount from @vue/test-utils

// src/stories/Welcome.test.js (fragment)
import { shallowMount, mount } from "@vue/test-utils";
Enter fullscreen mode Exit fullscreen mode

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()
    })
  })
})
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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

Top comments (1)

Collapse
 
chengxi profile image
chengxi

sure, thanks very much