DEV Community

loading...
Cover image for Vue component TDD with Jest and Storybook

Vue component TDD with Jest and Storybook

heyshadowsmith profile image Shadow Smith ・14 min read

In this article, I'm going to show you how to build a custom Vue button component in isolation using Test Driven Development (TDD).

Just a heads up, this guide assumes you have used Vue and Jest before and at least know what Test Driven Development is, so keep that in mind if you feel lost.

Overview of what you're building

The button component you are going to be building will have a default and primary style, take 2 props, and emit a click event—all of which will have tests written before each of the component features are even created.

Let's get started.

Setting up your Vue project

Open your terminal and navigate to where you want this project to be stored and do the following commands.

vue create storybook-tdd

Pick Manually select features

? Please pick a preset:
  default (babel, eslint)
> Manually select features

Check Babel, Linter / Formatter, and Unit Testing

? Check the features needed for your project:
 (*) Babel                              
 ( ) TypeScript                          
 ( ) Progressive Web App (PWA) Support   
 ( ) Router                              
 ( ) Vuex                                
 ( ) CSS Pre-processors                  
 (*) Linter / Formatter                  
 (*) Unit Testing                       
 ( ) E2E Testing

Pick ESLint with error prevention only

? Pick a linter / formatter config:
> ESLint with error prevention only
  ESLint + Airbnb config
  ESLint + Standard config
  ESLint + Prettier

Pick Lint on save

? Pick additional lint features:
 (*) Lint on save
 ( ) Lint and fix on commit

Pick Jest

? Pick a unit testing solution:
  Mocha + Chai
> Jest

Pick In package.json

? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.?
  In dedicated config files
> In package.json

If you want to save this as a preset, you can here.

Save this as a preset for future projects?

And once you have answered that question, creation of your storybook-tdd project will begin.

Adding Storybook

Storybook is a tool used to develop User Interface components in isolation, and if done correctly, can also act as an interactive documentation for your components at the same time.

Storybook gives you the ability to build components without focusing on the exact implementation of the components but rather their different states, styles, and functionalities.

So let's move into our Vue project and add Storybook with this command.

cd storybook-tdd && npx -p @storybook/cli sb init --type vue

Setting up your TDDButton component TDD environment

First thing's first, open your project in your code editor by typing code . in your terminal.

Create a file called TDDButton.vue in your src/components/ directory and add the following code.

    <template>
    </template>

Open up the example.spec.js file in your test/unit/ directory and delete everything inside except for these top 2 lines.

    import { shallowMount } from '@vue/test-utils'
    import HelloWorld from '@/components/HelloWorld.vue'

Change the example.spec.js file's name to TDDButton.spec.js for consistency and change the HelloWorld component import to your TDDButton.

    import { shallowMount } from '@vue/test-utils'
    import TDDButton from '@/components/TDDButton.vue'

Setting up Storybook for your TDDButton

Delete everything inside of your projects stories/ directory.

Create a file called TDDButton.stories.js in your stories/ directory.

This is going to be where we visually develop the TDDComponent's different styles.

Add the following to your TDDButton.stories.js file.

    // Adding your TDDButton component
    import TDDButton from '../src/components/TDDButton.vue'

    // Adding your TDDButton component to your Storybook sandbox
    export default {
        title: 'TDDButton',
        component: TDDButton
    }

    // Adding a Default style'd component to your Storybook sandbox's TDDButton
    export const Default = () => ({
      components: { TDDButton },
      template: '<TDDButton />'
    })

Now that is finished, run the following command in your terminal to launch your Storybook sandbox at http://localhost:6006.

npm run storybook

Once you run that command, your Storybook sandbox should automatically open, and you will see your TDDButton with a Default "Story" in the sidebar to the left.

However, everything is and should be blank right now, but you are going to be fleshing all of this out next.

Let's get started.

Writing your 1st test

From here on, you are going to be using the test runner, Jest, along with Vue Test Utils to move through the "Write Test > See Tests Fail > Write Code > Pass Tests > Refactor" Test Driven Development process.

So let's keep moving.

Understanding what Vue Test Utils is

Vue Test Utils is the official unit testing utility library for Vue, and it is absolutely vital when building Vue components using Test Driven Development.

Therefore, we will be using it a lot throughout the remainder of this article, so I recommend pulling up the Vue Test Utils documentation as you follow along from here on.

Mounting and destroying your component

Before you can write your first test, you need to mount your TDDButton component to create a wrapper that contains the fully mounted and rendered component.

In order to keep your tests fast, you need to mount your component before each test and destroy the component after.

You can do this by utilizing Jest's Setup and Teardown helper functions beforeEach() and afterEach(), so go ahead and initialize our wrapper variable and set up our Jest helper functions.

    import { shallowMount } from '@vue/test-utils'
    import TDDButton from '@/components/TDDButton.vue'

    // Initalizing wrapper variable
    let wrapper = null

    // Jest's beforeEach helper function
    beforeEach(() => {})

    // Jest's afterEach helper function
    afterEach(() => {})

Now to mount your component, you will use the shallowMount function imported from @vue/test-utils on line 1.

ShallowMount is a Vue Test Utils function that allows you to mount and render just the component you imported with its child components stubbed, so the mount and render doesn't fail.

There is also a Mount function which mounts and renders your imported component and its children components, but this is unfavorable for Unit Testing because it opens up the possibility for your component's children to affect the outcome of your tests.

So now to mount your TDDButton component before every test, add wrapper = shallowMount(TDDButton) inside of your beforeEach() function's callback like so.

    import { shallowMount } from '@vue/test-utils'
    import TDDButton from '@/components/TDDButton.vue'

    // Initalizing wrapper variable
    let wrapper = null

    // Mount the component to make a wrapper before each test
    beforeEach(() => {
        wrapper = shallowMount(TDDButton)
    })

    // Jest's afterEach helper function
    afterEach(() => {})

And to destroy your TDDButton component after every test, add wrapper.destroy() inside of your afterEach() function's callback like this.

    import { shallowMount } from '@vue/test-utils'
    import TDDButton from '@/components/TDDButton.vue'

    // Initalizing wrapper variable
    let wrapper = null

    // Mount the component to make a wrapper before each test
    beforeEach(() => {
        wrapper = shallowMount(TDDButton)
    })

    // Destroy the component wrapper after each test
    afterEach(() => {
        wrapper.destory()
    })

Conducting our first TDD feedback loop

Now that your component is mounted and ready for testing, the first test you need to write is to check if the component's name is "TDDButton".

To do this, you will need to use Vue Test Utils name() method.

This is pretty straightforward, but if you need it, here's the documentation page for this method.

    // ...continuation of your TDDButton.spec.js file

    describe('TDDButton', () => {

        // Checking if the component's name is 'TDDButton'
        it('Named TDDButton', () => {
            expect(wrapper.name()).toBe('TDDButton')
        })

    }

Now that you have written your first test, run npm run test:unit in your terminal to watch your test fail.

Writing the bare minimum to pass the test

Now to pass your simple test, all you have to do is name your TDDButton component by adding the following to the bottom of your TDDButton.vue file.

    <template>
    </template>

    // Adding a name to your TDDButton component
    <script>
    export default {
      name: 'TDDButton'
    }
    </script>

Now if you run npm run test:unit again, you will see it pass.

Congratulations! You just completed your first Vue component TDD feedback loop!

Now keep going.

Testing if your TDDButton component is a button

Now you need to test if your TDDButton is actually rendering a <button> element.

To do this, you will need to use the Vue Test Utils contains() method.

This is also pretty straightforward, but if you need it, here's the documentation page for this method as well.

    // ...continuation of your TDDButton.spec.js file

    describe('TDDButton', () => {

        // Checking if the component's name is 'TDDButton'
        it('Named TDDButton', () => {
            expect(wrapper.name()).toBe('TDDButton')
        })

        // Checking if the component contains a 'button' element
        it('Contains a button element', () => {
            expect(wrapper.contains('button')).toBe(true)
        })

    }

Now run npm run test:unit and watch the test fail.

Passing the button element test

Now to pass this test, you have to add a <button> element to your TDDButton component like so.

    <template>
        // Adding a 'button' element
        <button></button>
    </template>

    // Adding a name to your TDDButton component
    <script>
    export default {
      name: 'TDDButton'
    }
    </script>

Now if you run npm run test:unit, you will see it pass.

Writing a label prop test

For your TDDButton component, you want the user of the component to be able to use a label prop to set the text on the button.

To do this, you will want to test if your TDDButton component's text equals a String that is passed to it through a label prop.

In order to write this test, you have to use the Vue Test Utils setProps() method to pass props to your mounted component.

Here's the documentation page for that method, and here's how you would write the test for that.

    // ...continuation of your TDDButton.spec.js file

    describe('TDDButton', () => {

        // Checking if the component's name is 'TDDButton'
        it('Named TDDButton', () => {
            expect(wrapper.name()).toBe('TDDButton')
        })

        // Checking if the component contains a 'button' element
        it('Contains a button element', () => {
            expect(wrapper.contains('button')).toBe(true)
        })

        // Checking if the component renders the label on the 'button' element
        it('Renders button text using a label prop', () => {
            wrapper.setProps({ label: 'Call to action' })
            expect(wrapper.text()).toBe('Call to action')
      })

    }

And you guessed it, when you run npm run test:unit the test will fail, but that's what we want to see!

Passing the label prop test

Now to pass this test it takes 2 steps, but I want you to run a test after the 1st step to illustrate the power of TDD.

The 1st thing you need to do is give your TDDButton component the ability to receive a label prop.

Here's how you do that.

    <template>
        // Adding a 'button' element
        <button></button>
    </template>

    // Adding a name to your TDDButton component
    <script>
    export default {
      name: 'TDDButton',
        // Adding 'label' prop
        props: ['label']
    }
    </script>

Now if you run npm run test:unit, you will see it will fail because the label prop's value isn't being used as the <button> element's label.

Here's how you fix that.

    <template>
        // Passing the 'label' prop's value to the 'button' element
        <button>{{ label }}</button>
    </template>

    // Adding a name to your TDDButton component
    <script>
    export default {
      name: 'TDDButton',
        // Adding 'label' prop
        props: ['label']
    }
    </script>

Now if you run npm run test:unit, it will pass.

Updating our Storybook sandbox

Now if you run npm run storybook in your terminal, you will see that there is a <button> element without a label.

However, now that you have given your TDDButton component the ability to receive a label as a prop, we can update this in our Storybook sandbox.

To do this, go to your TDDButton.stories.js file and add a label prop with the value Default to your story like so.

    // Adding your TDDButton component
    import TDDButton from '../src/components/TDDButton.vue'

    // Adding your TDDButton component to your Storybook sandbox
    export default {
        title: 'TDDButton',
        component: TDDButton
    }

    // Adding a Default style'd component to your Storybook sandbox's TDDButton
    export const Default = () => ({
      components: { TDDButton },
        // Adding the 'label' prop to our Default style'd component
      template: '<TDDButton label="Default" />'
    })

Once you do this, you will see that the text "Default" has been added to your Default style'd TDDButton in your Storybook sandbox.

Writing a default button styles test

Now for your TDDButton, you want 2 different styles, your custom default styles and a primary style.

In order to test for default button styles, you will need to test if your TDDButton component has a default TDDButton class on the <button> element.

Here's how you write the test for that.

    // ...continuation of your TDDButton.spec.js file

    describe('TDDButton', () => {

        // Checking if the component's name is 'TDDButton'
        it('Named TDDButton', () => {
            expect(wrapper.name()).toBe('TDDButton')
        })

        // Checking if the component contains a 'button' element
        it('Contains a button element', () => {
            expect(wrapper.contains('button')).toBe(true)
        })

        // Checking if the component renders the label on the 'button' element
        it('Renders button text using a label prop', () => {
        wrapper.setProps({ label: 'Call to action' })
        expect(wrapper.text()).toBe('Call to action')
      })

        // Checking if the component has the default 'TDDButton' class
        it('Has default button styles', () => {
        expect(wrapper.classes('TDDButton')).toBe(true)
      })

    }

Now run npm run test:unit to see the test fail.

Passing the default button styles test

Now to pass this test, you need to add a TDDButton class to your TDDButton's <button> element.

Even though this will not cause your test to fail, you will want to also add the default button styles to the TDDButton class during this step, so here's how.

    <template>
        // Adding the 'TDDButton' class to the 'button' element
        <button class="TDDButton">{{ label }}</button>
    </template>

    // Adding a name to your TDDButton component
    <script>
    export default {
      name: 'TDDButton',
        // Adding 'label' prop
        props: ['label']
    }
    </script>

    // Adding the default styles to the 'TDDButton' class 
    <style>
    .TDDButton {
      all: unset;
      font-family: sans-serif;
      padding: .5rem 1rem;
      border-radius: .25rem;
      cursor: pointer;
      background: lightgray;
    }
    </style>

Now run npm run test:unit to see the test pass and then run npm run storybook to see your TDDButton component's updated default styles.

Writing a primary styles test

For your TDDButton component, you also want to give the users of the component the ability to pass the value primary to a type prop to change its styles.

To write this test, you will need to draw from the experience you gained from writing the "label prop test" and the "default styles test" because this test passes a type prop to add a primary class to your TDDButton component's <button> element.

Here's how to write this test.

    // ...continuation of your TDDButton.spec.js file

    describe('TDDButton', () => {

        // Checking if the component's name is 'TDDButton'
        it('Named TDDButton', () => {
            expect(wrapper.name()).toBe('TDDButton')
        })

        // Checking if the component contains a 'button' element
        it('Contains a button element', () => {
            expect(wrapper.contains('button')).toBe(true)
        })

        // Checking if the component renders the label on the 'button' element
        it('Renders button text using a label prop', () => {
        wrapper.setProps({ label: 'Call to action' })
        expect(wrapper.text()).toBe('Call to action')
      })

        // Checking if the component has the default 'TDDButton' class
        it('Has default button styles', () => {
        expect(wrapper.classes('TDDButton')).toBe(true)
      })

        // Checking if the component has the 'primary' class when 'primary'
        // is the value of the 'type' propery
        it('Has primary styles', () => {
        wrapper.setProps({ type: 'primary' })
        expect(wrapper.classes('primary')).toBe(true)
      })

    }

Run npm run test:unit, and it will fail.

Passing the primary button styles test

Now to pass this test, you need to add a type prop to your TDDButton component that also conditionally adds the type prop's value to the <button>'s class list.

While you do this, you will also add styles to the primary class, so you can add the variation to your Storybook sandbox.

So here's how you do all of that.

    <template>
        // Adding the type prop's value to the class list of the 'button' element
        <button class="TDDButton" :class="type">{{ label }}</button>
    </template>

    // Adding a name to your TDDButton component
    <script>
    export default {
      name: 'TDDButton',
        // Adding 'label' prop
        props: ['label', 'type']
    }
    </script>

    <style>
    .TDDButton {
      all: unset;
      font-family: sans-serif;
      padding: .5rem 1rem;
      border-radius: .25rem;
      cursor: pointer;
      background: lightgray;
    }

    // Adding the primary styles to the 'primary' class
    .primary {
      background: deeppink;
      color: white;
    }
    </style>

Once you're done with that, run npm run test:unit to see the test pass, but if you run npm run storybook to see your TDDButton component's primary styles, you will notice that nothing has changed.

Let's fix that.

Adding your TDDButton's primary style to Storybook

Now to shift gears a bit, you are going to want to document the different styles of your TDDButton component in your Storybook sandbox.

If you recall, you added this bit of code to your TDDButton.stories.js file near the beginning of this article that was responsible for setting up the default style of your TDDButton component in your Storybook sandbox.

    // Adding your TDDButton component
    import TDDButton from '../src/components/TDDButton.vue'

    // Adding your TDDButton component to your Storybook sandbox
    export default {
        title: 'TDDButton',
        component: TDDButton
    }

    // Adding a Default style'd component to your Storybook sandbox's TDDButton
    export const Default = () => ({
      components: { TDDButton },
      template: '<TDDButton label="Default" />'
    })

To add your TDDButton's primary style, you simply need to:

  • Clone the bit of code where you are "Adding the Default style'd component"
  • Change the exported const name to Primary
  • Pass the value Primary to the label prop
  • And then pass the value primary to a type prop

Here's what your TDDButton.stories.js file should like when you are done.

    // Adding your TDDButton component
    import TDDButton from '../src/components/TDDButton.vue'

    // Adding your TDDButton component to your Storybook sandbox
    export default {
        title: 'TDDButton',
        component: TDDButton
    }

    // Adding a Default style'd component to your Storybook sandbox's TDDButton
    export const Default = () => ({
      components: { TDDButton },
      template: '<TDDButton label="Default" />'
    })

    // Adding a Primary style'd component to your Storybook sandbox's TDDButton
    export const Primary = () => ({
      components: { TDDButton },
      template: '<TDDButton label="Primary" type="primary" />'
    });

Once you have finished this, run npm run storybook, and you will see a new "Story" in the left sidebar called Primary that has a version of your TDDButton component with your primary styles.

Writing a click listener test

Finally, since your TDDButton component is a button, you will want to test if it emits a click event.

In order to write this test, you will need to use the Vue Test Utils trigger() method to virtually click your TDDButton during your test and then listen for a click event to be emitted.

Here's the documentation page for the trigger method, and here's how you write this test.

    // ...continuation of your TDDButton.spec.js file

    describe('TDDButton', () => {

        // Checking if the component's name is 'TDDButton'
        it('Named TDDButton', () => {
            expect(wrapper.name()).toBe('TDDButton')
        })

        // Checking if the component contains a 'button' element
        it('Contains a button element', () => {
            expect(wrapper.contains('button')).toBe(true)
        })

        // Checking if the component renders the label on the 'button' element
        it('Renders button text using a label prop', () => {
        wrapper.setProps({ label: 'Call to action' })
        expect(wrapper.text()).toBe('Call to action')
      })

        // Checking if the component has the default 'TDDButton' class
        it('Has default button styles', () => {
        expect(wrapper.classes('TDDButton')).toBe(true)
      })

        // Checking if the component has the 'primary' class when 'primary'
        // is the value of the 'type' propery
        it('Has primary styles', () => {
        wrapper.setProps({ type: 'primary' })
        expect(wrapper.classes('primary')).toBe(true)
      })

        // Checking if a 'click' event is emitted when the component is clicked
        it('Emits a click event when clicked', () => {
        wrapper.trigger('click')
        expect(wrapper.emitted('click')).toBeTruthy()
      })

    }

Now if you run npm run test:unit, this will of course fail.

Passing the click listener test

In order to pass this test, you have to add a @click listener on your TDDButton's <button> element that emits a click event.

Here's how to do this.

    <template>
        // Adding the '@click' event listener that emits a 'click' event
        <button class="TDDButton" :class="type" @click="$emit('click')">{{ label }}</button>
    </template>

    // Adding a name to your TDDButton component
    <script>
    export default {
      name: 'TDDButton',
        // Adding 'label' prop
        props: ['label', 'type']
    }
    </script>

    <style>
    .TDDButton {
      all: unset;
      font-family: sans-serif;
      padding: .5rem 1rem;
      border-radius: .25rem;
      cursor: pointer;
      background: lightgray;
    }

    // Adding the primary styles to the 'primary' class
    .primary {
      background: deeppink;
      color: white;
    }
    </style>

Now if you run npm run test:unit, you will see that this test passes.

Congratulations! You have learned the basics of building custom Vue components in isolation using Test Driven Development (TDD).

Conclusion

Vue components are simple in concept.

They are small, modular, reusable User Interface building blocks that unlock the ability to rapidly create robust application front ends.

However, in order to build a component design system that works correctly every time, a contract of expected behaviors must be enforced for every component in the entire system.

For instance, in order for a user to accomplish a specific task, they must interact with components X, Y, and Z, and those components must correctly do their jobs in order to satisfy the users' expectations.

If they fail our users, we fail our users, and Test Driven Development is one of the best ways to ensure that our components don't let our users down and bugs don't run rampant in our software.

With all of this said, Test Driven Development does slow down the development process, so if you or your team is crunched for time and need to move fast, it may not be best for you, but if you value stability, it is definitely worth it.

You will always get faster with practice.

Discussion (4)

pic
Editor guide
Collapse
mwiest profile image
mwiest

Seems like the storybook and the jest tests are two completely different things. Why is this article about both of them and gives the impression that they work together somehow. Or do they??

Collapse
heyshadowsmith profile image
Shadow Smith Author

Great question!

Originally, this article was called "Isolated Vue component TDD with Jest and Storybook", and I think it may have lost context when I removed the word "Isolated".

It is true that you can build components with TDD with just Jest and Vue Test Utils without Storybook, but I added it to this article as an introduction to building/documenting your components in an isolated environment as you move through the TDD process.

For instance, if you had a component that had a bunch of different states based on more props than just a simple button would have, it would be useful to go through a TDD round when updating a component and then update your Storybook stories to reflect everything you test for.

Collapse
sanwil9 profile image
Steven Sanders

Great quick intro to using storybook and jest!

Collapse
heyshadowsmith profile image
Shadow Smith Author

Thanks, my friend!