Test Driven Development with Vue

basarbk

Basar Buyukkahraman

Posted on December 27, 2021

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

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

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"
  }

Enter fullscreen mode Exit fullscreen mode

and also we add script for running the tests.

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

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
    },

Enter fullscreen mode Exit fullscreen mode

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

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

Now lets fix this

<!-- RandomUser.vue -->
<template>
  <button>Load Random User</button>
</template>
Enter fullscreen mode Exit fullscreen mode

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')

Enter fullscreen mode Exit fullscreen mode

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

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": {
    //
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

// RandomUser.spec.js
import 'whatwg-fetch';
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

💖 💪 🙅 🚩
basarbk
Basar Buyukkahraman

Posted on December 27, 2021

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

Sign up to receive the latest update from our blog.

Related

Test Driven Development with Vue
vue Test Driven Development with Vue

December 27, 2021