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, AvaJest -
Assertion: ChaiJest
- Framework: 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
- Software Testing Fundamentals
- Testing Vue.js Applications
- Vue.js Testing Workshop by Edd Yerburgh
- JavaScript Scene
- Companion repo
- More complex scenarios
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
Unit Testing in Vue.js
By Andrew Beng
Unit Testing in Vue.js
A deep dive into unit testing methodology in Vue.js projects
- 7,420