20180618 Mocks and Stubs: Testing API Requests with Vue

Almost all single page applications will make many calls to external services, so testing those parts of your applications is important.

I will discuss how to test API calls, specifically:

The source code for this project is available here.

We will start at the bottom of the test pyramid with some unit tests, and finish up with some e2e tests.

Setup

Install the vue-cli with npm install -g @vue/cli, and then run vue create api-tests. Select “Manually select features” and choose the following:

For unit testing, we want jest, and for e2e select cypress. After the installation finishes, cd api-tests and install Axios with npm install axios.

Unit Testing Axios with Jest

We will be using jsonplaceholder, a service which simulates a REST api. The endpoint is https://jsonplaceholder.typicode.com/posts/1 and the response looks like this:

{
  "userId": 1,
  "id": 1,
  "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
  "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
}

We will be doing TDD: write the test, watch it fails, and then make it pass.

We will see how to mock axios in two situations:

  1. A Vuex action, which makes an API call and commits the result
  2. An e2e test, which displays the result in a UI

Let’s start with the action test. Create the test file by running touch tests/unit/actions.spec.js. Before writing any code, run the test and watch it fail with npm run test:unit:

You should get:

FAIL  tests/unit/actions.spec.js
● Test suite failed to run

  Your test suite must contain at least one test.

Let’s add a test. In actions.spec.js add the following:

import { actions } from '../../src/store'

jest.mock('axios', () => {
  return {
    get: () => ({ data: { userId: 1 }})
  }
})


describe('getPost', () => {
  it('makes a request and commits the response', async () => {
    const store = { commit: jest.fn() }

    await actions.getPost(store)

    expect(store.commit).toHaveBeenCalledWith('SET_POST', { userId: 1 })
  }) 
})

Running this with npm run test:unit yields:

FAIL  tests/unit/actions.spec.js
  ● getPost › makes a request and commits the response

  ReferenceError: actions is not defined

As expected, the tests fails. We haven’t even created getPost yet, so let’s do so in src/store.js. We will also export it seperately to the default export new Vuex.Store:

import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'

Vue.use(Vuex)

export const actions = {
  async getPost(store) {
    const response = await axios.get('https://jsonplaceholder.typicode.com/posts/1') 

    store.commit('SET_POST', { userId: response.data.userId })
  }
}

export default new Vuex.Store({
  state: {

  },
  mutations: {

  },
  actions: actions
})

Now we can import { actions } in the spec:

import { actions } from '../../src/store'

jest.mock('axios', () => {
  return {
    get: () => ({ data: { userId: 1 }})
  }
})


describe('getPost', () => {
  it('makes a request and commits the response', async () => {
    const store = { commit: jest.fn() }

    await actions.getPost(store)

    expect(store.commit).toHaveBeenCalledWith('SET_POST', { userId: 1 })
  }) 
})

This gives us a new error:

FAIL  tests/unit/actions.spec.js
  ● getPost › makes a request and commits the response

  ReferenceError: store is not defined

     5 |     actions.getPost()
     6 |
  >  7 |     expect(store.commit).toHaveBeenCalledWith('SET_POST', { userId: 1 })

store is not defined. The goal of this test is simply to make the API call, and commit whatever response comes back, so we will we mock store.commit, and use Jest’s .toHaveBeenCalledWith matcher to make sure the response was committed with the correct mutation handler. We pass store as the first argument to getPost, to simulate how Vuex passes a reference to the store as the first argument to all actions. Update the test:

import { actions } from '../../src/store'

jest.mock('axios', () => {
  return {
    get: () => ({ data: { userId: 1 }})
  }
})


describe('getPost', () => {
  it('makes a request and commits the response', async () => {
    const store = { commit: jest.fn() }

    await actions.getPost(store)

    expect(store.commit).toHaveBeenCalledWith('SET_POST', { userId: 1 })
  }) 
})

jest.fn is just a mock function - it doesn’t actually do anything, but records useful data like how many times it was called, and with what arguments. The test now fails with different error:

FAIL  tests/unit/actions.spec.js
  ● getPost › makes a request and commits the response

  expect(jest.fn()).toHaveBeenCalledWith(expected)

  Expected mock function to have been called with:
    ["SET_POST", {"userId": 1}]
  But it was not called.

This is what we want. The test is failing for the right reason - a SET_POST mutation should have been committed, but was not. Update store.js to actually make the API call:

import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'

Vue.use(Vuex)

export const actions = {
  async getPost(store) {
    const response = await axios.get('https://jsonplaceholder.typicode.com/posts/1') 

    store.commit('SET_POST', { userId: response.data.userId })
  }
}

export default new Vuex.Store({
  state: {

  },
  mutations: {

  },
  actions: actions
})

Note we added async to the function, we we can use await on the axios API call. The test still fails with same error - we also need to prepend the action call in the test with await:

import { actions } from '../../src/store'

jest.mock('axios', () => {
  return {
    get: () => ({ data: { userId: 1 }})
  }
})


describe('getPost', () => {
  it('makes a request and commits the response', async () => {
    const store = { commit: jest.fn() }

    await actions.getPost(store)

    expect(store.commit).toHaveBeenCalledWith('SET_POST', { userId: 1 })
  }) 
})

Now we have two passing tests, including the default HelloWorld spec included in the project:

PASS  tests/unit/actions.spec.js
PASS  tests/unit/HelloWorld.spec.js

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.333s, estimated 2s
Ran all test suites.

This is not ideal, though - we are hitting a real network, which makes the unit test slow and prone to failure. Luckily, Jest let’s us mock dependencies, like axios, in a number of ways. Let’s see how to do so with jest.mock.

Mocking Axios in the Action spec

Jest provides no less that four different ways to mock classes and modules, In large projects, I use manual mocks by creating a __mocks__ folder on the same level as node_modules and exporting a mock axios module, however for the simple example I will use an ES6 class mock. I think both are fine, and have been tending towards this style as of late.

To mock axios using an ES6 class mock, all you need to do is call jest.mock('axios') and return a function with the desired implentation (since ES6 classes are really just functions under the hood). In this case, we want a get function that returns a userId: 1 object. Update actions.spec.js:

// ...

jest.mock('axios', () => {
  return {
    get: () => ({ data: { userId: 1 }})
  }
})

// ...

Easy. The test still passes, but now we are using a mock axios instead of a real network call. We should watch the test fail again, though, just to be should, so update the mock to return { userId: 2 } instead:

 FAIL  tests/unit/actions.spec.js
  ● getPost › makes a request and commits the response

expect(jest.fn()).toHaveBeenCalledWith(expected)

Expected mock function to have been called with:
  {"userId": 1}
as argument 2, but it was called with
  {"userId": 2}.

Looks good - the test is failing for the right reason. Revert the test, and let’s move on to writing an e2e test.

Stubbing Axios in a component lifecycle

Now we know how to test an action uses axios - how about in a component? In preparation for writing an e2e using Cypress, let’s see an example of a component that makes an API call in its created hook.

Open src/components/HelloWorld.vue, and delete all the existing markup - you should be left with this:

<template>
  <div class="hello"></div>
</template>

<script>
export default {
  name: 'HelloWorld'
}
</script>

<style scoped>
</style>

We want to import axios, and make an API request. The code will be similar to the code in getPost. Lastly, we will render the title of the post.

<template>
  <div class="hello">
    Title: {{ post.title }}
  </div>
</template>

<script>
import axios from 'axios'
export default {
  name: 'HelloWorld',

  data() {
    return {
      post: {}
    }
  },

  async created() {
    const response = await axios.get('https://jsonplaceholder.typicode.com/posts/1') 
    this.post = response.data
  }
}
</script>

<style scoped>
</style>

Run the application with npm run serve. Visiting localhost:8080 should show the post title on the screen:

Title: sunt aut facere repellat provident occaecati excepturi optio reprehenderit

Let’s update the default test vue-cli gave us in tests/e2e/specs/test.js:

// https://docs.cypress.io/api/introduction/api.html

describe('My First Test', () => {
  it('Visits the app root url', () => {
    cy.server()
    cy.route('https://jsonplaceholder.typicode.com/posts/1', {
      title: 'This is a stubbed title'
    })

    cy.visit('/')
    cy.contains('div', 'This is a stubbed title')
  })
})

Run the test with npm run e2e. Cypress has a great interface and is really easy to use. You should see:

Click ‘run’. A Chrome browser should open and if everything went well, you should see:

It works! However, this test suffers from the original problem we had in the unit test we wrote - it is using a real network call. We want to stub the network call, with a fake one, so we can consistently reproduce the same results without relying on a potentially flakey external API. To stub a response in Cypress, you need to do two things:

  1. Start a cy.server
  2. Provide a cy.route

cy.route takes several forms. The one we will use is

cy.route(url, response)

Update the test to use a stubbed response:

// https://docs.cypress.io/api/introduction/api.html

describe('My First Test', () => {
  it('Visits the app root url', () => {
    cy.server()
    cy.route('https://jsonplaceholder.typicode.com/posts/1', {
      title: 'This is a stubbed title'
    })

    cy.visit('/')
    cy.contains('div', 'This is a stubbed title')
  })
})

If you still have the Cypress server running, saving should automatically rerun the specs. Now we have a failure:

We can see on the right hand side that the stubbed response was rendered! Simply update the spec to assert the stubbed title is rendered and everything should be green again:

Conclusion and Improvements

We saw how to mock axios in a Vuex action spec, and how to stub the response using Cypress. With the advent of tools like Jest and Cypress, testing is extremely simple and actually makes development a lot more smooth one you are in the habit of writing tests.

Some improvements can be made, an are left an exercise:

The source code for this project is available here.