Unit Testing in Vue.js

from theory into practice

Andrew Beng

github.com/andrewbeng89
andrewbeng89.me

My Homeland

My home today

These are testing times... for software development

Integration
Unit
E2E

Testing Pyramid

Q: What is Unit Testing?

A: Testing individual software units in isolation.

Software Unit

the smallest, testable part of a software

Functions as units

n! = 1.2.3.4...(n-2).(n-1).n
n! = n.(n-1)!

Functions as units

/** The factorial! 😂 function
  * i.e. the product of all positive integers
  * less than or equal to n
  */
const factorial = n => {
  if (n === 1) {
    return 1;
  }
  return n * factorial(n - 1);
};

This is a unit

components/MyUiComponent.vue

This is also a unit

<template>
    <!-- Vue template markup -->
</template>

<script>
export default Vue.extend({
    // Vue component logic
    props: { /* some props */ },
    methods: { /* some methods */ }
});
</script>

<style>
// Vue component styling
</style>
const state = { myList: [] };

const addListItem = (
  state, listItem
) => {
  state.myList = [
      ...state.myList,
      listItem
  ];
};

const mutations = { addListItem };

export default {
  state,
  mutations,
  namespaced: true
};
store/list.js (vuex module)

What about this?

Mid-to-high complexity Vue.js components typically look this...
<template>
    <div>
        <button @click="fetchList" />
    </div>
</template>

<script>
export default Vue.extend({
    // Vue component logic
    props: { /* some props */ },
    methods: {
        ...mapActions("list", [
            "fetchList"
        ]
    }
});
</script>

<style>
// Vue component styling
</style>
mapping getters/actions from a vuex store

Don't unit test DEPENDENCIES

  • vuex store functions ❌
  • UI library, e.g. Vuetify ❌
  • API modules ❌
  • ...anything producing side-effects

Why

 

 

When
How

Benefits of Unit Testing?

  • Maintainability
  • Modularity
  • Efficiency
  • Better debugging experience
  • Predictable code

Why
 

How

 

When
 

[Test] - Code - Test - Repeat

  • Part of the development process
    • Writing tests against acceptance criteria
  • Zero-config setup with vue-cli-3
  • Code-reviewable tests
  • Automated unit testing hooks
    • pre-commit
    • pre-build (CI/CD)

Why
When

How

 


How

An approach

  • White-box testing
  • Simple assertion testing
  • Isolated test environment
  • Tooling
    • Framework: Jest, Mocha
    • Runner: Karma, Ava
    • Assertion: Chai
  • Minimal mocking

An approach

  • White-box testing
  • Simple assertion testing
  • Isolated test environment
  • Tooling
    • Framework: Jest, Mocha
    • Runner: Karma, Ava
    • Assertion: Chai
  • Minimal mocking

White Box Testing

test input
assert output
{ application code (units, mocks) }
approach used for unit testing

Black Box Testing

test input
assert output
{ application code }
running software (staging env.)
approach used for E2E testing

Grey Box Testing

test input
assert output
{ application code }
running software (staging env.)
approach used for integration testing

An approach

  • White-box testing
  • Simple assertion testing
  • Isolated test environment
  • Tooling
    • Framework: Jest, Mocha
    • Runner: Karma, Ava
    • Assertion: Chai
  • Minimal mocking

Simple Assertions

/** The factorial! 😂 function
  * i.e. the product of all positive integers
  * less than or equal to n
  */
const factorial = n => {
  if (n === 1) {
    return 1;
  }
  return n * factorial(n - 1);
};

// Assertion that factorial! is working
it("Should return the nth factorial", () => {
  expect(factorial(5)).toBe(120);
  expect(factorial(10)).toBe(3628800);
});
input
output
assert
execute

Vuex Store Assertions

// A simple vuex store: list.js
const state = { list: [] };

const mutations = {
  addItem: (state, item) => {
    state.list = [...state.list, item]
  }
};

const getters = {
  getItem: state => index =>
    state.list[index]
};

export default {
  state,
  mutations,
  getters
};

// main.js
const store = new Vuex.Store(list);
dynamic getter

Vuex Store Assertions

<!-- Vue component using the mapped getter -->
<template>
  <div>
    <span>
        The first item is {{ getItem(0) }}
    </span>
    <button @click="addItem({ foo: 'bar' })">
      Add Item
    </button>
  </div>
</template>
<script>
import Vue from "vue";
import { mapGetters, mapMutations } from "vuex"

export default Vue.extend({
  computed: {
    ...mapGetters(["getItem"])
  },
  methods: {
    ...mapMutations(["addItem"])
  }
});
</script>
curried function is mapped to a dynamic getter

Vuex Store Assertions

// Assertion testing list.spec.js
import listStore from "./list";

const {
 mutations, getters
} = listStore;

it(
  "Should add an item to the list",
  () => {
    const state = { list: [] };
    const item = { foo: "bar" };
    const expectedState = {
      list: [{
        foo: "bar"
      }]
    };

    mutations
      .addItem(state, item);
    expect(state)
      .toEqual(expectedState);
  }
);
test input
expected output
code execution
assertion

Vuex Store Assertions

// Assertion testing list.spec.js
import listStore from "./list";

const {
 mutations, getters
} = listStore;

it(
  "Should add an item to the list",
  () => {
    const state = { list: [] };
    const item = { foo: "bar" };
    const expectedState = {
      list: [{
        foo: "bar"
      }]
    };

    mutations
      .addItem(state, item);
    expect(state)
      .toEqual(expectedState);
  }
);

it(
  "Should get an item from the list",
  () => {
    const state = {
      list: [{ foo: "bar" }]
    };
    const expectedItem = {
      foo: "bar"
    };

    const item = getters
      .getItem(state)(0);
    expect(item)
      .toEqual(expectedItem);
  }
);
dynamic getter
test input
expected output
assertion

Asserting Vuex Actions

// A simple vuex store: list.js
const state = { list: [], status: "" };
const mutations = {
    addItem, setStatus,
    setItems, addItems
};
const actions = {
  fetchItems: async ({ commit }, page) => {
    try {
      commit("setStatus", "loading");
      const { data } = await axios
        .get(`https://...?page=${page}`);
      const action = page
        ? "setItems"
        : "addItems";
      commit(action, data);
      commit("setStatus", "success");
    } catch (error) {
      console.error(error);
      commit("setStatus", error.message);
    }
  }
};
export default { ... };

Challenges with actions

  • Asynchronous running
  • External dependencies (axios)
  • Side effects on "commit" or "dispatch" objects
  • > Complexity than getters/mutations
    • Business logic
    • Error handling
    • Use other units - mutations

Jest to the rescue...

// Mock axios with jest
jest.mock("axios");
const actions = require("./list").actions;

it(
  "Should add items to an empty list",
  async () => {
    const items = mockItems();
    // Mock-resolve an axios request
    axios.get
      .mockResolvedValue({ data: items });
    // Mock the commit function
    const commit = jest.fn();
    
    // Call fetchItems with page = 0
    await actions.fetchItems({ commit }, 0);
    expect(commit)
      .toHaveBeenNthCalledWith(1, "setStatus", "loading");
    expect(commit)
      .toHaveBeenNthCalledWith(2, "setItems", items);
    expect(commit)
      .toHaveBeenNthCalledWith(3, "setStatus", "success");
  }
);
it(
  "Should add items to a populated list",
  async () => { ... }
);

it(
  "Should set an error status if the API errors",
  async () => { ... }
);

An approach

  • White-box testing
  • Simple assertion testing
  • Isolated test environment
  • Tooling
    • Framework: Jest, Mocha
    • Runner: Karma, Ava Jest
    • Assertion: Chai Jest
  • Minimal mocking

Unit Testing  with
vue-test-utils

  • Shallow mounting
    • Wrapper, LocalVue
  • Component contract
  • Selectors
  • Snapshot testing

A Wrapper is an object that contains a mounted component or vnode and methods to test the component or vnode.

Vue Test Utils tests Vue components by mounting them in isolation, mocking the necessary inputs ...

MyComponent.vue
Child.vue
Child.vue
Grandchild.vue
Grandchild.vue
mount(MyComponent)
Rendered JSDom Tree

mount vs shallowMount

MyComponent.vue
<child-stub />
<child-stub />
Rendered JSDom Tree
shallowMount(MyComponent)

Isolated component unit testing

... we recommend writing tests that assert your component's public interface ...

test input
assert output
{ shallowMount
(MyComponent) }
  • props
  • user interaction
  • events
  • rendered UI

Component Interface

  • store???
  • mutations???
  • actions???

Contract

Non-contract

MyComponent.vue

<template>
  <div>
    <span>
      The first item is {{ getItem(0) }}
    </span>
    <button id="btn" @click="onClick">
      Click Me!
    </button>
    <button
      @click="addItem({ foo: 'bar' })"
    >
      Add Item
    </button>
    <pre>{{ secret }}</pre>
  </div>
</template>

Contract

Non-contract

MyComponent.vue

<script>
import Vue from "vue";
import { mapGetters, mapMutations } from "vuex";

export default Vue.extend({
  props: {
    secret: {
      type: String,
      required: false,
      default: "No secret..."
    }
  },
  computed: {
    ...mapGetters(["getItem"])
  },
  methods: {
    ...mapMutations(["addItem"]),
    onClick() {
      this.$emit("my-event");
    }
  }
});
</script>

Contract

Non-contract

MyComponent.spec.js

import {
  shallowMount, createLocalVue
} from "@vue/test-utils";
import Vuex from "vuex";
import list from "@/store/list";
import MyComponent from "./MyComponent.vue";

const localVue = createLocalVue();
localVue.use(Vuex);
const store = new Vuex.Store({
  ...list,
  state: {
    ...list.state,
    list: [{ foo: "bar" }]
  }
});

MyComponent.spec.js

it("renders props.secret when passed", () => {
  const secret = "my secret message";
  const wrapper = shallowMount(
    MyComponent, {
      propsData: { secret },
      store,
      localVue
    }
  );
  expect(wrapper.find("pre").text())
    .toMatch(secret);
});
test input
assertion
selector

MyComponent.spec.js

it(
  "emits 'my-event' if btn is clicked",
  () => {
    const wrapper = shallowMount(
      MyComponent,
      {
        store,
        localVue
      }
    );
    const btn = wrapper.find("#btn");
    btn.trigger("click");
    expect(
      wrapper.emitted()["my-event"].length
    ).toBe(1);
  }
);
test input
assertion

snapshots!

it(
  "prints the first item, two buttons, and 'secret'",
  () => {
    const wrapper = shallowMount(
      MyComponent,
      {
        store,
        localVue
      }
    );
    expect(wrapper.element)
      .toMatchSnapshot();
  }
);

snapshots!

exports[`MyComponent.vue prints the first item, two buttons, and 'secret' 1`] = `
<div>
  <span>
     The first item is {
  "foo": "bar"
} 
  </span>
   
  <button
    id="btn"
  >
    
    Click Me!
  
  </button>
   
  <button>
    
    Add Item
  
  </button>
   
  <pre>
    No secret...
  </pre>
</div>
`;
  • Coverage for "free"
  • Identify rendering regressions
  • "Signs-off" UI components
  • Large snapshot files
  • False positives/negatives
  • Deterministic testing

Wrapping it up

  • Unit test NOW!
  • Contracts > Coverage
  • Integration, E2E testing?

References

Q&A

one more thing...

a note on integration testing

Keep integration tests minimal

  • Hard to define consistently
    • Units working together?
    • Units working with API?
    • Units working in browser?
  • Extremely hard to setup
    • e.g. Vue + axios: conflicting jest-environment (jsdom vs. node)
  • ⚠️ May drastically slow development progress

Keeping unit & integration tests separate with jest

// package.json
"scripts": {
  ...
  "test:integration":
    "vue-cli-service test:unit --testMatch \"**/src/**/*.int.(js)\""
}

Vuex + axios Integration 

/**
 * @jest-environment node
 */
import listStore from "./list";

const { actions } = listStore;

describe(
  "Integration of API with list store",
  () => {
    it("the API fetches items", async () => {
      const commit = jest.fn();
      await actions.fetchItems({ commit }, 0);
      expect(commit).toHaveBeenCalledTimes(3);
    });
  }
);
assert commits
execute request
node environment to execute axios request

Vuex + .vue Integration 

describe("MyComponent.vue", () => {
  const secret = "my secret message";
  const wrapper = mount(MyComponent, {
    propsData: { secret },
    store,
    localVue
  });

  it(`
    fetches and sets 10 items to the list,
    and renders the span
  `, async () => {
    const fetchButton = wrapper.find("#fetch");
    const items = mockItems();
    // Mock-resolve an axios request
    axios.get.mockResolvedValue({ data: items });
    fetchButton.trigger("click");

    await localVue.nextTick();
    expect(store.state.list).toHaveLength(10);
  });
});
assert mutated list's length
user interaction

Parent + Child Integration 

describe("App.vue", () => {
  const wrapper = mount(App, {
    store,
    localVue
  });

  it(`
    sets a secret when 'btn' is clicked
  `, () => {
    const btn = wrapper.find("#btn");
    btn.trigger("click");
    expect(wrapper.vm.secret).not.toBe("");
  });
});
assert updated data property
user interaction
Made with Slides.com