Factorial
May 7, 2019 by Andrew Beng

Unit Testing in Vue.js: From Theory into Practice

An approach to Unit Testing in Vue.js projects

Unittesting
Vue.js
meetup
guidance

First presented at Hamburg Vue.js Meetup #5

Testing Times

Quality assurance for software products and services typically spans multiple teams from project owners and managed to designers, and even dedicated QA teams. The standardised adoption of CI/CD pipelines to deliver software has allowed automated testing to become part of day-to-day DevOps.
Automated software testing can generally be categorised into three groups, visually represented by the Testing Pyramid;

  1. Unit Testing
  2. Integration Testing
  3. End-to-end Testing

The layers of the pryramid visually quantify the proportion of each type of test should be covered within a software project.

Test-driven Development (TDD) is a software development process which is focused on QA and Unit Testing from the moment a developer writes their first line of code. In its "purest" form, TDD always begins with writing a unit test, and ends when the software written against the test passes the test.

What is Unit Testing anyway?

Unit Testing has a very simple definition:

Testing individual software units in isolation.

Going further, a software unit can be defined as:

The smallest, testable part of a software

In the world of Functional Programming (FP), pure functions are the undisputed units to write tests for. Consider, for example, the corresponding term:

1
2
3
4
5
6
7
8
9
10
/** The factorial! function
* i.e. the product of all positive integers * less than or equal to n
* n!=1.2.3.4...(n−2).(n−1).n
* n!=n.(n−1)!
*/
const factorial = n => {
  if (n === 1) {
return 1; }
  return n * factorial(n - 1);
};

A Vuex store module may similarly composed of different mutation and/or getter functions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const state = { myList: [] };
const addListItem = (
  state, listItem
) => {
  state.myList = [
      ...state.myList,
listItem ];
};
const mutations = { addListItem };
export default {
  state,
mutations,
  namespaced: true
};

Things get a little more interesting once components are added to the equation:

1
2
3
4
5
6
7
8
9
10
11
12
13
<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>

But what about more complex (and typical) Vue.js components which map Vuex functionality:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<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>

In this scenario, the last two words in the Unit Testing definition plays its crucial role; in isolation. Testing the fetchList click handler against this component would not be necessary as the actualy implementation would already have been tested when the Vuex store was created.

The general rule of "do not test dependencies and dependants" can be applied when unit testing mid-to-high complexity Vue.js components:

  • Avoid directly test Vuex store functions
  • Avoid testing UI/CSS libraries, e.g. Vuetify, material-web-components
  • Avoid testing API modules
  • Avoid testing ... anything producing side-effects

Tried-and-tested

As developers, and part of a wider product development team, the benefits of adopting Unit Testing as part of the development process are wide ranging. From the perspective of a developer, a TDD approach with an emphasis on unit testing generally results in code which is more modular (complex architectures composed from units), predictable, and ultimately maintainable. Similarly, from a product lifecycle perspective, creating unit test scripts early in the sprint enables the developer to clarify any vague acceptance criteria with the product owner before implementing features.

A familiar TDD cycle may look something like this:

  1. Write a failing test against the acceptance criteria (clarify if necessary)
  2. Implement the feature in code
  3. Run the code against the test
  4. Repeat steps 2-3 until all tests scenarios for the unit pass

With vue-cli 3 , setting up a new or existing Vue.js project for unit testing is a breeze with a single entered in the terminal (or graphically with the Vue CLI UI):

1
 $ vue add @vue/unit-jest

Other automated development tooling including git pre-commit hooks and pre-build scripts for CI/CD allow developers to take their minds off when and how to run unit tests, focusing their efforts instead on delivering reliable features and enhancements.

A 5-point Approach

Moving towards a TDD-based process may seem daunting, but thinking about an approach with these general points could be beneficial:

1. Apply white-box testing

White-box testing involves isolating software units in a testing environment and testing provided input against some expected output. In contrast to black-box testing, where the implementation details of the software tested are opaque, white-box testing requires transparent knowledge of the units implementation. This becomes aparent when unit testing more complex parts of a Vue.js app, for instance an action function in a Vuex store. The presentation about visually describes the difference between white-box, black-box (E2E) and grey-box (integration) testing.

2. Validate scenarios using simple assertions

The saying "simpler is better" applies to the approach of asserting input against output. If a function is expected return an array with three strings, the following assertion is something to avoid:

1
2
3
4
expect(array).toHaveLength(3);
expect(array[0]).toBe("quick");
expect(array[1]).toBe("brown");
expext(array[2]).toBe("fox");

This assertion is direct and understandable:

1
  expect(array).toEqual(["quick", "brwon", "fox"]);

With the previous example of the factorial function, the assertion below using Jest verifies whether the function works in finding the 5th and 10th factorial expressions:

1
2
3
4
  it("Should return the nth factorial", () => {
  expect(factorial(5)).toBe(120);
  expect(factorial(10)).toBe(3628800);
});

The presentation and accopanying repository provides more examples in creating asserions for Vuex mutations/getters and actions (with Jest mocks).

3. Ensure tests are run in an isolated environment (i.e.tests software/system)

4. Pick the appropriate (i.e. well supported) tooling

  • framework/runner/assertion library

Many frameworks provide officially supported unit testing frameworks where developers do not have to worry much about configuration. With vue-test-utils, out-of-the-box unit testing is provided with Jest serving not only as the test framework, but also the runner and assertion library.

With vue-test-utils , individual Vue components are mounted in isolation to a JSDOM environment via a wrapper based API with the shallowMount method. Unlike mount, shallowMount stubs a component's children components rather than the entire subtree.

When unit testing Vue components, deciding which areas of code should be covered by tests can be complicated:

we recommend writing tests that assert your component's public interface ... A component's interface comprises several inputs and their corresponding outputs:

  • Inputs: props, user interaction, Vuex store
  • Outputs: events, rendered/updated UI, mutations, actions

A subjective but practical approach to minimise duplication when testing would be to exclude testing of any of the Vuex related features (e.g. mapActions or mapMutations ) within a .vue SFC component, as coverage should have been provided when the store by the component's contract.

Snapshot testing may also be a quick way of providing plenty of coverage with very few lines of test code. However, the following caveats need to be considered:

  • Large snapshot files generated
  • False positives/negatives
  • Deterministic testing

5. Use minimal mocking

While Jest provides an excellent solution for mocking functions, modules and dependencies, relying on many mocks in a single unit signs is a likely indication of tight coupling and over-complexity of the code, and indication often described as a "code smell". In these cases, the software could likely be broken down into smaller units.

Wrapping it up!

What this presentation and article hopes to inspire is that unit testing (with Vue.js and in general) is an easy process to adopt within a development team. Start (unit) testing NOW! When coupled with sound software design principles (like SOLID), coding and testing software becomes a question of which parts of the codebase should be covered, rather than how many lines.

Beyond unit testing, the topic of integration testing still holds many unanswered questions; from experience, most bugs encountered in the wild are the result of integration related issues (e.g. changed API assumptions/requirements). However, setting up good integration tests is orders of magnitude more complex than unit testing. The last section of the presentation covers some basic aspects of integration testing within Vue.js apps (Vuex + .vue integration and Parent-Child integration).

References

Contact

Address
Factorial GmbH
Kirchentwiete 37-39
22765 Hamburg