← 🏠 🚶Home

Test Driven Development with Vue

In this article lets see how we can apply test driven development in a vue project.

Setup

First lets create the project. I'm using @vue/cli for it.

npm install -g @vue/cli
# OR
yarn global add @vue/cli

then you can run

vue create my-app

It will ask which preset you want. You can go with the default, which is for vue 2 or you can select vue 3.

after project is created we will install additional dependencies for testing.

npm i -D jest @testing-library/vue @testing-library/jest-dom @testing-library/user-event babel-jest @vue/vue2-jest

for vue 3 run npm i -D jest @testing-library/vue@next @testing-library/jest-dom @testing-library/user-event babel-jest @vue/vue3-jest

We are using jest* as the test runner which also has the assertion and mocking functionalities. We also have dependencies

  • @testing-library/vue is for rendering the components in our test functions.
  • @testing-library/jest-dom is for dom releated matchers for jest
  • @testing-library/user-event is for making user actions on components, like clicking, typing, focusing etc

We will configure jest. We do this configuration in package.json

// package.json
  "jest": {
      // this is for making sure jest to re run the tests when the files with this extension updated
    "moduleFileExtensions": [
      "js",
      "vue"
    ],
    "transform": {
      ".*\\.(vue)$": "@vue/vue2-jest", // for vue3 project @vue/vue3-jest
      ".*\\.(js)$": "babel-jest"
    },
    // and we need to set testEnvironment after jest V27
    "testEnvironment": "jsdom"
  }

and also we add script for running the tests.

// package.json
  "scripts": {
    // add test script
    "test": "jest --watch"
  },

and we are going to use jest functions like describe, it and to not get warning about those from eslint, update eslint configuration in package.json as well

  "eslintConfig": {
    "root": true,
    "env": {
      "node": true,
      // as env, add jest and set it to true
      "jest": true
    },

The setup part is complete now.

Project

Lets have a simple component here. We will have a button in it and whenever we click to that button, it is going to be loading random user from this public api https://randomuser.me/

First lets have two terminal and run the project npm run serve in one of them and run the tests npm test on another one.

Jest is running in watch mode in our project. And jest watch mode is working based on git status. If there is no changed files, it does not run tests. You can make sure to run all test to run by hitting a in the test terminal.

Now lets add our component RandomUser.vue and corresponding test module RandomUser.spec.js

Jest automatically detects the test modules if the files have the extension *.test.js or *.spec.js.

and lets add our first test

// RandomUser.spec.js
import RandomUser from './RandomUser.vue';
import { render, screen } from '@testing-library/vue';
import "@testing-library/jest-dom";

describe('Random User', () => {
  it('has button to load random user', () => {
    render(RandomUser);
    const loadButton = screen.queryByRole('button', {
      name: 'Load Random User'
    });
    expect(loadButton).toBeInTheDocument();
  });
});

We are rendering the RandomUser component. And then we use screen's functions to query the elements we are looking for. doc

this first test is looking for a button on page. We are using the a11y roles here and as a text, we expect the button to have Load Random User. In the end, we expec this button to be in the document.

As soon as we save this module, jest is running the tests again. It will be ending up with failure.

 FAIL  src/RandomUser.spec.js
  Random User
    × has button to load random user (144 ms)

  ● Random User › has button to load random user

    expect(received).toBeInTheDocument()

    received value must be an HTMLElement or an SVGElement.
    Received has value: null

       9 |       name: 'Load Random User'
      10 |     });
    > 11 |     expect(loadButton).toBeInTheDocument();
         |                        ^
      12 |   });
      13 | });

Now lets fix this

<!-- RandomUser.vue -->
<template>
  <button>Load Random User</button>
</template>

Test is passing now.

Lets show this component in our application.

// main.js

// vue 2
import Vue from 'vue'
import RandomUser from './RandomUser.vue'

Vue.config.productionTip = false

new Vue({
  render: h => h(RandomUser),
}).$mount('#app')


// vue 3

import { createApp } from 'vue'
import RandomUser from './RandomUser.vue'

createApp(RandomUser).mount('#app')

Now you must be seeing a button on page.

Now we are going to click to this button and it will be making an api call to randomuser.me But first lets install a library for this api call.

npm i axios

Make sure you stop and start test and app consoles after installing a new dependency.

Lets use axios for http calls.

We are going to add our test for this requirement. But first lets see the returned object from randomuser api.

{
  "results": [
    {
      "gender": "female",
      "name": {
        "title": "Miss",
        "first": "Jennifer",
        "last": "Alvarez"
      },
      "location": {
        //
      },
      "email": "jennifer.alvarez@example.com",
      "login": {
         //
      },
      "dob": {
        "date": "1954-07-01T18:59:36.451Z",
        "age": 67
      },
      "registered": {
        "date": "2016-11-17T05:48:39.981Z",
        "age": 5
      },
      "phone": "07-9040-0066",
      "cell": "0478-616-061",
      "id": {
        "name": "TFN",
        "value": "531395478"
      },
      "picture": {
        "large": "https://randomuser.me/api/portraits/women/24.jpg",
        "medium": "https://randomuser.me/api/portraits/med/women/24.jpg",
        "thumbnail": "https://randomuser.me/api/portraits/thumb/women/24.jpg"
      },
      "nat": "AU"
    }
  ],
  "info": {
    //
  }
}

so the actual user object is in the results array. now lets add our test

// we need to import two packages.
// we will mock the
import axios from 'axios';
// and we will use this user-event to click the button.
import userEvent from '@testing-library/user-event';

// this test will be having async/await
it('displays title, first and lastname of loaded user from randomuser.me', async () => {
  render(RandomUser);
  const loadButton = screen.queryByRole('button', {
    name: 'Load Random User'
  });
  
  // we will click the button but our request must not be going
  // to the real server. we can't be sure how that request
  // ends up. So we will mock it. Lets make sure we set what
  // axios will return. 
  // lets define the mock function first
  // axios get, post ... functions are promise and here
  // we will mock success response by mockResolvedValue
  // and we will return the axios response object.
  // so we put the actual api response into data object here
  const mockApiCall = jest.fn().mockResolvedValue({
    data: {
      results: [
        {
          name: {
            title: 'Miss',
            first: 'Jennifer',
            last: 'Alvarez'
          }
        }
      ]
    }
  });
  // now lets assign this mock function to axios.get
  axios.get = mockApiCall;
  // then we can click the button
  userEvent.click(loadButton);
  // and we expect to see this text on screen.
  // this is dependent onto async operation to complete
  // so to wait that api call to finish, we use this findBy...
  const userInfo = await screen.findByText("Miss Jennifer Alvarez");
  expect(userInfo).toBeInTheDocument();
});

this test fails and you should be seeing a message like this

  ● Random User › displays title, first and lastname of loaded user from randomuser.me

    TestingLibraryElementError: Unable to find an element with the text: Miss Jennifer Alvarez. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

lets fix this.

// RandomUser.vue
<template>
  <div>
    <button @click="loadRandomUser">Load Random User</button>
    <h1 v-if="user">{{user.name.title}} {{user.name.first}} {{user.name.last}}</h1>
  </div>
</template>
<script>
// importing axios, we will make api call
import axios from 'axios';
export default {
// we keep user object in state
  data() {
    return {
      user: undefined
    };
  },
  methods: {
   // and this method will be loading the user from the api
    async loadRandomUser(){
      try {
        const response = await axios.get('https://randomuser.me/api');
        this.user = response.data.results[0];
      } catch (error) {/**/}
    }
  }
};
</script>

after these changes test will pass.

With mocking, we have a predictable behavior in our application. If we test this on browser, we can see in each click, we receive different users.

But the downside of mocking is, now our test is highly coupled with our implementation detail. If we decide to replace axios with fetch, then our test needs to be refactored accordingly.

lets do that.

The fetch is coming with the browser. So to use it in our component we don't need to install anything. But in our test environment, which is running in node, it doesn't have fetch in it. So using fetch in application will cause problem on test part. To resolve this lets install another package. This is only needed for test modules.

npm i -D whatwg-fetch

now lets import this one in our test and re-run tests.

// RandomUser.spec.js
import 'whatwg-fetch';

But other than this import, lets do nothing on test. But lets use fetch in our component.

// RandomUser.vue
  async loadRandomUser(){
    try {
      const response = await fetch('https://randomuser.me/api');
      const body = await response.json();
      this.user = body.results[0];
    } catch (error) {/**/}
  }

after these changes the tests are failing. But if we test this on browser, the user is properly loaded. So form user point of view, there is no difference. But since our test is coupled with axios usage, it is broken now. We can update our mock functions in test to make our test pass. Or we can resolve it without mocking.

We are going to use the library Mock Service Worker - MSW Lets install it

npm i -D msw

We are going to use it in our test module.

// RandomUser.spec.js
// lets import these two functions
import { setupServer } from "msw/node";
import { rest } from "msw";

it('displays title, first and lastname of loaded user from randomuser.me', async () => {
// here we will create a server
  const server = setupServer(
    // and this server is going to be processing the GET requests
    rest.get("https://randomuser.me/api", (req, res, ctx) => {
      // and here is the response it is returning back
      return res(ctx.status(200), ctx.json({
        results: [
          {
            name: {
              title: 'Miss',
              first: 'Jennifer',
              last: 'Alvarez'
            }
          }
        ]
      }));
    })
  );
  // then..
  server.listen();
  // so at this step we have a server
  // after this part we don't need to deal with axios or fetch
  // in this test function
  render(RandomUser);
  const loadButton = screen.queryByRole('button', {
    name: 'Load Random User'
  });
  userEvent.click(loadButton);
  const userInfo = await screen.findByText("Miss Jennifer Alvarez");
  expect(userInfo).toBeInTheDocument();
});

after this change, test must be passing. Now our test is not dependent onto the client we are using. We can go back and use axios again.

// RandomUser.vue
async loadRandomUser(){
  try {
    const response = await axios.get('https://randomuser.me/api')
    user = response.data.results[0];
  } catch (error) {
  }
}

Tests must be passing with this usage too.

The mocking is a very good technique in scenarios where external services are taking place. With mocking we are able to create a reliable test environment. But the down side of it, our tests are being highly coupled with our implementation. My choice is to avoid mocking if I can. And the msw library is great replacement for backend in client tests.

Resources

Github repo for this project can be found here

You can also check this video tutorial and this one about similar topic

If you would be interested in a full test driven development course for vue, you can check my course at udemy Vue with Test Driven Development

Vue with Test Driven Development

Vue with Test Driven Development

Articles

Basar Buyukkahraman

Başar Büyükkahraman