Writing a Vue component using TDD: a gentle introduction

astagi

Andrea Stagi

Posted on March 5, 2020

Writing a Vue component using TDD: a gentle introduction

In this tutorial we'll learn the basic concepts of Test Driven Development (TDD) building a simple Vue component with TypeScript, testing using Jest and setting up coverage and Continuous Integration.

Introduction

Test Driven Development (TDD) is a development process where you write tests before you write code. You first write a test that describes an expected behaviour and you run it, ensuring it fails, then you write the minimal code to make it pass. After that, if you need, you can refactor the code to make it right. You repeat all these steps for each feature you want to implement until you’re done. This process forces developers to write unit tests and think before writing code, releasing robust code.

It's time to start writing some code to create an image placeholder component that fetches images from LoremFlickr, a simple service to get random images specifying parameters like width, height, categories (comma separated values), filters.. inside a url, for example to get a 320x240 image from Brazil or Rio you can fetch https://loremflickr.com/320/240/brazil,rio

Despite there are a lot of options in LoremFlickr, in this tutorial we'll focus on developing a simple component to get an image from LoremFlickr only using width and height and filtering by categories.

https://loremflickr.com/<width>/<height>/<categories>
Enter fullscreen mode Exit fullscreen mode

Create your project

Using Vue CLI create vue-image-placeholder project

vue create vue-image-placeholder
Enter fullscreen mode Exit fullscreen mode

Choose Manually select features and select TypeScript and Unit testing options

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

Use the default settings and select Jest as testing framework.

🧹 Cleanup the project removing assets, components folders and App.vue inside src, we don't need them for this tutorial.

Write your first test

In tests/unit rename example.spec.ts with imageplaceholder.spec.ts and start writing your first test.

We expect our ImagePlaceholder component to render an <img> tag with src composed by width, height and images (categories) properties.

<ImagePlaceholder width=500 height=250 images="dog" />
Enter fullscreen mode Exit fullscreen mode

Should render

<img src="https://loremflickr.com/500/250/dog">
Enter fullscreen mode Exit fullscreen mode

Let's write our first test to check if ImagePlaceholder component with properties width: 500, height:200, images: 'newyork' renders an img with src=https://loremflickr.com/500/200/newyork.

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

describe('ImagePlaceholder.vue', () => {
  it('renders the correct url for New York images', () => {
    const wrapper = shallowMount(ImagePlaceholder, {
      propsData: { width: 500, height:200, images: 'newyork' }
    })
    expect(
      wrapper.findAll('img').at(0).attributes().src
    ).toEqual('https://loremflickr.com/500/200/newyork')
  })
})
Enter fullscreen mode Exit fullscreen mode

If we try to run tests with

yarn test:unit
Enter fullscreen mode Exit fullscreen mode

❌ Everything fails as expected, because ImagePlaceholder component does not exist.

To make tests pass you need to write the component ImagePlaceholder.vue

<template>
  <img :src="url">
</template>

<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';

@Component
export default class ImagePlaceholder extends Vue {

  @Prop({required: true}) readonly width!: number
  @Prop({required: true}) readonly height!: number
  @Prop({required: true}) readonly images!: string

  get url() {
    return `https://loremflickr.com/${this.width}/${this.height}/${this.images}`;
  }

}
</script>
Enter fullscreen mode Exit fullscreen mode

Save the file and run yarn test:unit again.

yarn run v1.19.2
$ vue-cli-service test:unit
 PASS  tests/unit/imageplaceholder.spec.ts
  ImagePlaceholder.vue
    ✓ renders the correct url for New York images (46ms)


Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.428s
Ran all test suites.
✨  Done in 2.40s.
Enter fullscreen mode Exit fullscreen mode

✅ Yay! Tests run without errors!

You've just created a minimal ImagePlaceholder component using TDD!
See it in action: copy and paste the following code in main.ts

import Vue from 'vue'
import ImagePlaceholder from './ImagePlaceholder.vue'

Vue.config.productionTip = false

new Vue({
  render: h => h(
    ImagePlaceholder,
    {
      props : {
        width: 500,
        height:200,
        images: 'newyork'
      }
    }),
}).$mount('#app')
Enter fullscreen mode Exit fullscreen mode

and run yarn serve!

Improve the component using TDD

Suppose you want to add a new feature to ImagePlaceholder component: use "random" category if images prop is not specified. With this feature

<ImagePlaceholder width=500 height=200 />
Enter fullscreen mode Exit fullscreen mode

should render

<img src="https://loremflickr.com/500/200/random">
Enter fullscreen mode Exit fullscreen mode

This is the behaviour expected in the following test

  it('renders the correct url for Random images if not specified', () => {
    const wrapper = shallowMount(ImagePlaceholder, {
      propsData: { width: 500, height:200 }
    })
    expect(
      wrapper.findAll('img').at(0).attributes().src
    ).toEqual('https://loremflickr.com/500/200/random')
  })
Enter fullscreen mode Exit fullscreen mode

❌ After running yarn test:unit you will get this error

  ● ImagePlaceholder.vue › renders the correct url for Random images if not specified

    expect(received).toEqual(expected) // deep equality

    Expected: "https://loremflickr.com/500/200/random"
    Received: "https://loremflickr.com/500/200/undefined"
Enter fullscreen mode Exit fullscreen mode

Following TDD, it's time to write some code again to make tests passing: now images prop should not be required anymore and "random" should be its default value.

  //...
  @Prop({required: false, default: 'random'}) readonly images!: string
  //...
Enter fullscreen mode Exit fullscreen mode

✅ Run tests again and they will pass as expected!

What about support square images and make height equal to width if not specified? Again write a failing test

  it('renders a square image if height is not specified', () => {
    const wrapper = shallowMount(ImagePlaceholder, {
      propsData: { width: 500 }
    })
    expect(
      wrapper.findAll('img').at(0).attributes().src
    ).toEqual('https://loremflickr.com/500/500/random')
  })
Enter fullscreen mode Exit fullscreen mode

And write the minimal code to make it pass.

@Component
export default class ImagePlaceholder extends Vue {

  @Prop({required: true}) readonly width!: number
  @Prop({required: false}) readonly height!: number
  @Prop({required: false, default: 'random'}) readonly images!: string

  get url(): string {
    let height = this.height;
    if (!this.height) {
      height = this.width;
    }
    return `https://loremflickr.com/${this.width}/${height}/${this.images}`
  }

}
Enter fullscreen mode Exit fullscreen mode

✅ Tests pass!

There's a test for this new feature, and the minimal code to make it passes. We can make some refactoring! 👨🏻‍💻

export default class ImagePlaceholder extends Vue {

  @Prop({required: true}) readonly width!: number
  @Prop({required: false}) readonly height!: number
  @Prop({required: false, default: 'random'}) readonly images!: string

  get url(): string {
    return `https://loremflickr.com/${this.width}/${this.height || this.width}/${this.images}`;
  }

}
Enter fullscreen mode Exit fullscreen mode

✅ Tests pass again! We’ve successfully refactored the code without affecting the output!

Iterate this process to implement anything you want! Remember: think about what you want, write a test first, make it fail and write the minimal code to make it pass! Then refactor your code if you need.

You can find the complete code on GitHub

Add code coverage

Code coverage is a measurement of how many lines, branches, statements of your code are executed while the automated tests are running. Apps with a high percentage of code covered has a lower chance of containing undetected bugs compared to apps with low test coverage.

Jest can generate code coverage easily without external tools. To enable this feature add some lines to jest.config.json file specifying which files will be covered

module.exports = {
  preset: '@vue/cli-plugin-unit-jest/presets/typescript-and-babel',
  collectCoverage: true,
  collectCoverageFrom: ["src/**/*.vue", "!**/node_modules/**"]
}
Enter fullscreen mode Exit fullscreen mode

Run again yarn test:unit and you'll get the coverage report before testing results.

----------------------|----------|----------|----------|----------|-------------------|
File                  |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
----------------------|----------|----------|----------|----------|-------------------|
All files             |      100 |      100 |      100 |      100 |                   |
 ImagePlaceholder.vue |      100 |      100 |      100 |      100 |                   |
----------------------|----------|----------|----------|----------|-------------------|
Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        5.688s
Ran all test suites.
✨  Done in 8.70s.
Enter fullscreen mode Exit fullscreen mode

⚠️ Remember to add /coverage folder generated by Jest to .gitignore.

Continuous Integration

Continuous Integration (CI) is a development practice where developers integrate code into a shared repository frequently, preferably several times a day. Each integration can then be verified by an automated build and automated tests. The goal is to build healthier software by developing and testing in smaller increments. This is where a continuous integration platform like TravisCI comes in.

We need also another useful service, Codecov, to monitor code coverage percentage.

TravisCI and Codecov are integrated with Github, you just need to signup and add the project to the services. Inside your code you need a special file, .travis.yml to activate CI and say to TravisCI how to execute builds:

language: node_js
node_js:
  - 10
before_script:
  - yarn add codecov
script:
  - yarn test:unit
after_script:
  codecov
Enter fullscreen mode Exit fullscreen mode

Following these steps TravisCI will

  • setup the environment (node_js 10)
  • install dependencies (before_script section)
  • execute tests with coverage (script section)
  • send coverage report to Codecov(after_script section)

Setup build

Now that we have our component ready, we need to setup the build process. In your package.json file modify the buildscript and remove the serve script.

  "scripts": {
    "build": "vue-cli-service build --target lib --name vue-image-placeholder src/main.ts",
    "test:unit": "vue-cli-service test:unit",
    "lint": "vue-cli-service lint"
  },
Enter fullscreen mode Exit fullscreen mode

With --target lib file main.ts must be changed accordingly to export your component

import ImagePlaceholder from './ImagePlaceholder.vue'

export default ImagePlaceholder
Enter fullscreen mode Exit fullscreen mode

Add a folder types with a file called index.d.ts inside, containing

declare module 'vue-image-placeholder' {
  const placeholder: any;
  export default placeholder;
}
Enter fullscreen mode Exit fullscreen mode

Add main and typings references to package.json

  "main": "./dist/vue-image-placeholder.common.js",
  "typings": "types/index.d.ts",
Enter fullscreen mode Exit fullscreen mode

You need also to disable automatic polyfill injection in babel.config.js

module.exports = {
  presets: [
    ['@vue/app', {
      useBuiltIns: false
    }]
  ]
}
Enter fullscreen mode Exit fullscreen mode

And remove test files from "include" section of tsconfig.json.

To build the library for production run

yarn build
Enter fullscreen mode Exit fullscreen mode
⠦  Building for production as library (commonjs,umd,umd-min)...

 DONE  Compiled successfully in 20857ms                                                               11:37:47 PM

  File                                     Size             Gzipped

  dist/vue-image-placeholder.umd.min.js    8.50 KiB         3.16 KiB
  dist/vue-image-placeholder.umd.js        42.33 KiB        11.76 KiB
  dist/vue-image-placeholder.common.js     41.84 KiB        11.60 KiB
Enter fullscreen mode Exit fullscreen mode

📦 The build is ready!

To play with it, install vue-image-placeholder in other apps locally using

yarn add ../vue-image-placeholder
Enter fullscreen mode Exit fullscreen mode

and use the component

<template>
  <div id="app">
    <h1>Welcome to the Vue Image Placeholder demo!</h1>
    <ImagePlaceholder width=500 />
  </div>
</template>

<script>
import ImagePlaceholder from 'vue-image-placeholder';

export default {
  name: 'App',
  components: {
    ImagePlaceholder
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

Final result

Here you can find the official repo of vue-image-placeholder.

Image by Arno Woestenburg

💖 💪 🙅 🚩
astagi
Andrea Stagi

Posted on March 5, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related