Vue.js Unit Testing
Introduction
Unit testing is a critical part of modern web development that helps ensure your code works as expected and remains functional as your application evolves. In Vue.js applications, unit testing focuses on testing individual components in isolation to verify that each piece of your application behaves correctly.
In this guide, we'll explore how to write effective unit tests for Vue.js components using popular tools like Jest and Vue Test Utils. By the end, you'll have the knowledge needed to implement a solid testing strategy for your Vue.js projects.
Why Unit Test Vue.js Components?
Unit testing Vue.js components provides several important benefits:
- Catch bugs early: Identify and fix issues before they reach production
- Refactoring confidence: Update code with confidence, knowing tests will catch regressions
- Documentation: Tests serve as working documentation of how components should behave
- Better component design: Testing encourages more modular, loosely-coupled components
Testing Tools and Setup
Required Tools
To get started with Vue.js unit testing, we'll need:
- Jest: A delightful JavaScript testing framework
- Vue Test Utils: The official testing library for Vue.js
- vue-jest: A Jest transformer for Vue single-file components
Setting Up Your Testing Environment
If you created your project using Vue CLI, you can add the testing libraries with:
vue add unit-jest
For manual setup in an existing project:
npm install --save-dev jest @vue/test-utils vue-jest babel-jest
Next, configure Jest in your package.json
or in a separate jest.config.js
file:
// jest.config.js
module.exports = {
preset: '@vue/cli-plugin-unit-jest',
transform: {
'^.+\\.vue$': 'vue-jest',
'^.+\\.jsx?$': 'babel-jest'
},
testMatch: [
'**/tests/unit/**/*.spec.[jt]s?(x)',
'**/__tests__/*.[jt]s?(x)'
]
}
Writing Your First Vue.js Unit Test
Let's start with a simple Vue component and write tests for it:
The Component
<!-- Counter.vue -->
<template>
<div>
<p class="counter">Count: {{ count }}</p>
<button @click="increment">Increment</button>
<button @click="decrement">Decrement</button>
</div>
</template>
<script>
export default {
name: 'Counter',
data() {
return {
count: 0
}
},
methods: {
increment() {
this.count += 1
},
decrement() {
this.count -= 1
}
}
}
</script>
The Test File
// Counter.spec.js
import { shallowMount } from '@vue/test-utils'
import Counter from '@/components/Counter.vue'
describe('Counter.vue', () => {
let wrapper
beforeEach(() => {
// Create a fresh wrapper before each test
wrapper = shallowMount(Counter)
})
test('renders initial count of 0', () => {
const counterText = wrapper.find('.counter').text()
expect(counterText).toContain('Count: 0')
})
test('increments count when increment button is clicked', async () => {
await wrapper.find('button:first-child').trigger('click')
const counterText = wrapper.find('.counter').text()
expect(counterText).toContain('Count: 1')
})
test('decrements count when decrement button is clicked', async () => {
await wrapper.find('button:nth-child(2)').trigger('click')
const counterText = wrapper.find('.counter').text()
expect(counterText).toContain('Count: -1')
})
})
Running the Tests
Add a script to your package.json
:
"scripts": {
"test:unit": "jest"
}
Then run:
npm run test:unit
You should see output confirming that all tests have passed.
Understanding Vue Test Utils
Vue Test Utils provides special methods for testing Vue components. Here are some key concepts:
Mounting Components
There are two main ways to mount components:
// Full mounting - renders the component with all its children
const wrapper = mount(YourComponent)
// Shallow mounting - renders the component but stubs its children
const wrapper = shallowMount(YourComponent)
Use shallowMount
when you want to isolate the component being tested from its children.
Finding Elements
You can find elements within the mounted component:
// Find by CSS selector
const button = wrapper.find('button')
const submitButton = wrapper.find('[data-test="submit"]')
// Find all matching elements
const items = wrapper.findAll('li')
Triggering Events
Simulate user interactions:
// Click a button
await wrapper.find('button').trigger('click')
// Input text
await wrapper.find('input').setValue('new value')
Testing Props
You can pass props when mounting:
const wrapper = shallowMount(YourComponent, {
props: {
title: 'Hello World',
items: ['Apple', 'Banana', 'Cherry']
}
})
// Check that props are rendered
expect(wrapper.text()).toContain('Hello World')
Testing Component Logic
Let's look at more advanced testing scenarios:
Testing Computed Properties
<!-- FullName.vue -->
<template>
<div>
<p>{{ fullName }}</p>
</div>
</template>
<script>
export default {
name: 'FullName',
props: {
firstName: {
type: String,
required: true
},
lastName: {
type: String,
required: true
}
},
computed: {
fullName() {
return `${this.firstName} ${this.lastName}`
}
}
}
</script>
Test:
// FullName.spec.js
import { shallowMount } from '@vue/test-utils'
import FullName from '@/components/FullName.vue'
describe('FullName.vue', () => {
test('computes fullName correctly', () => {
const wrapper = shallowMount(FullName, {
props: {
firstName: 'John',
lastName: 'Doe'
}
})
expect(wrapper.vm.fullName).toBe('John Doe')
expect(wrapper.text()).toContain('John Doe')
})
})
Testing Methods
<!-- TodoList.vue -->
<template>
<div>
<input v-model="newTodo" @keyup.enter="addTodo" />
<ul>
<li v-for="(todo, index) in todos" :key="index">
{{ todo }}
<button @click="removeTodo(index)">Remove</button>
</li>
</ul>
</div>
</template>
<script>
export default {
name: 'TodoList',
data() {
return {
newTodo: '',
todos: []
}
},
methods: {
addTodo() {
if (this.newTodo.trim()) {
this.todos.push(this.newTodo)
this.newTodo = ''
}
},
removeTodo(index) {
this.todos.splice(index, 1)
}
}
}
</script>
Test:
// TodoList.spec.js
import { shallowMount } from '@vue/test-utils'
import TodoList from '@/components/TodoList.vue'
describe('TodoList.vue', () => {
let wrapper
beforeEach(() => {
wrapper = shallowMount(TodoList)
})
test('adds a todo when addTodo is called', async () => {
wrapper.vm.newTodo = 'Learn Vue Testing'
wrapper.vm.addTodo()
expect(wrapper.vm.todos).toContain('Learn Vue Testing')
expect(wrapper.vm.newTodo).toBe('') // Input is cleared
expect(wrapper.findAll('li').length).toBe(1)
})
test('does not add empty todos', () => {
wrapper.vm.newTodo = ' '
wrapper.vm.addTodo()
expect(wrapper.vm.todos.length).toBe(0)
})
test('removes a todo when removeTodo is called', async () => {
// Set up some todos
wrapper.vm.todos = ['Todo 1', 'Todo 2', 'Todo 3']
await wrapper.vm.$nextTick()
// Check we have 3 todos
expect(wrapper.findAll('li').length).toBe(3)
// Remove the second todo
await wrapper.findAll('button')[1].trigger('click')
// Check the todo was removed
expect(wrapper.vm.todos.length).toBe(2)
expect(wrapper.vm.todos).toEqual(['Todo 1', 'Todo 3'])
expect(wrapper.findAll('li').length).toBe(2)
})
})
Testing Component Lifecycle and Async Operations
Vue components often perform asynchronous operations, especially during lifecycle hooks.
Testing API Calls
Let's create a component that fetches data:
<!-- UserList.vue -->
<template>
<div>
<h1>Users</h1>
<div v-if="loading">Loading...</div>
<div v-else-if="error">{{ error }}</div>
<ul v-else>
<li v-for="user in users" :key="user.id">
{{ user.name }}
</li>
</ul>
</div>
</template>
<script>
import axios from 'axios'
export default {
name: 'UserList',
data() {
return {
users: [],
loading: true,
error: null
}
},
async created() {
try {
const response = await axios.get('https://jsonplaceholder.typicode.com/users')
this.users = response.data
} catch (err) {
this.error = 'Failed to load users'
} finally {
this.loading = false
}
}
}
</script>
Test with mocked API:
// UserList.spec.js
import { shallowMount, flushPromises } from '@vue/test-utils'
import UserList from '@/components/UserList.vue'
import axios from 'axios'
// Mock axios
jest.mock('axios')
describe('UserList.vue', () => {
test('displays loading state initially', () => {
const wrapper = shallowMount(UserList)
expect(wrapper.text()).toContain('Loading...')
})
test('displays users when API call succeeds', async () => {
// Mock successful API response
const mockUsers = [
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' }
]
axios.get.mockResolvedValue({ data: mockUsers })
const wrapper = shallowMount(UserList)
// Wait for promises to resolve
await flushPromises()
// Check that loading is done
expect(wrapper.vm.loading).toBe(false)
// Check that users are displayed
const items = wrapper.findAll('li')
expect(items.length).toBe(2)
expect(items[0].text()).toBe('John Doe')
expect(items[1].text()).toBe('Jane Smith')
})
test('displays error when API call fails', async () => {
// Mock failed API response
axios.get.mockRejectedValue(new Error('API error'))
const wrapper = shallowMount(UserList)
// Wait for promises to resolve
await flushPromises()
// Check that error message is displayed
expect(wrapper.vm.loading).toBe(false)
expect(wrapper.vm.error).toBe('Failed to load users')
expect(wrapper.text()).toContain('Failed to load users')
})
})
Testing Vuex Integration
Components often interact with Vuex store. Here's how to test them:
Component with Vuex Store
<!-- ProductList.vue -->
<template>
<div>
<h1>Products</h1>
<button @click="loadProducts">Load Products</button>
<ul>
<li v-for="product in products" :key="product.id">
{{ product.name }} - ${{ product.price }}
</li>
</ul>
</div>
</template>
<script>
export default {
name: 'ProductList',
computed: {
products() {
return this.$store.state.products
}
},
methods: {
loadProducts() {
this.$store.dispatch('fetchProducts')
}
}
}
</script>
Test with mocked store:
// ProductList.spec.js
import { shallowMount, createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'
import ProductList from '@/components/ProductList.vue'
// Create a local Vue instance
const localVue = createLocalVue()
localVue.use(Vuex)
describe('ProductList.vue', () => {
let actions
let state
let store
beforeEach(() => {
actions = {
fetchProducts: jest.fn()
}
state = {
products: [
{ id: 1, name: 'Product 1', price: 9.99 },
{ id: 2, name: 'Product 2', price: 14.99 }
]
}
store = new Vuex.Store({
state,
actions
})
})
test('renders products from store', () => {
const wrapper = shallowMount(ProductList, {
localVue,
store
})
const items = wrapper.findAll('li')
expect(items.length).toBe(2)
expect(items[0].text()).toContain('Product 1')
expect(items[0].text()).toContain('$9.99')
})
test('dispatches fetchProducts action when button is clicked', async () => {
const wrapper = shallowMount(ProductList, {
localVue,
store
})
await wrapper.find('button').trigger('click')
expect(actions.fetchProducts).toHaveBeenCalled()
})
})
Testing Vue Router Integration
Testing components that use Vue Router requires mocking router functionality:
<!-- UserProfile.vue -->
<template>
<div>
<h1>User Profile</h1>
<p>User ID: {{ $route.params.id }}</p>
<button @click="goToHome">Back to Home</button>
</div>
</template>
<script>
export default {
name: 'UserProfile',
methods: {
goToHome() {
this.$router.push('/')
}
}
}
</script>
Test:
// UserProfile.spec.js
import { shallowMount, createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router'
import UserProfile from '@/components/UserProfile.vue'
const localVue = createLocalVue()
localVue.use(VueRouter)
describe('UserProfile.vue', () => {
test('displays the user ID from route params', () => {
const router = new VueRouter()
// Mock the $route object
const mockRoute = {
params: {
id: '123'
}
}
const wrapper = shallowMount(UserProfile, {
localVue,
router,
mocks: {
$route: mockRoute
}
})
expect(wrapper.text()).toContain('User ID: 123')
})
test('navigates to home when button is clicked', async () => {
const router = new VueRouter()
const mockRouter = {
push: jest.fn()
}
const wrapper = shallowMount(UserProfile, {
localVue,
mocks: {
$route: {
params: { id: '123' }
},
$router: mockRouter
}
})
await wrapper.find('button').trigger('click')
expect(mockRouter.push).toHaveBeenCalledWith('/')
})
})
Best Practices for Vue.js Unit Testing
To make your tests more effective and maintainable:
- Test one thing per test: Each test should verify a single behavior
- Use data attributes for testing: Add
data-test
attributes to make selectors more resilienthtml<button data-test="submit-button">Submit</button>
jswrapper.find('[data-test="submit-button"]')
- Mock external dependencies: Always mock API calls, routers, and stores
- Prefer shallow mounting: Use
shallowMount
rather thanmount
to isolate component behavior - Snapshot testing: Use sparingly for UI testing, as snapshots can break easily
- Clean up after tests: Reset mocks and wrappers between tests
Testing Component Edge Cases
Good tests also consider edge cases:
test('handles empty arrays properly', () => {
const wrapper = shallowMount(ItemList, {
props: {
items: []
}
})
expect(wrapper.find('.empty-message').exists()).toBe(true)
expect(wrapper.find('.empty-message').text()).toBe('No items found')
})
test('handles error states properly', () => {
const wrapper = shallowMount(UserForm, {
data() {
return {
error: 'Invalid email address'
}
}
})
expect(wrapper.find('.error').exists()).toBe(true)
expect(wrapper.find('.error').text()).toContain('Invalid email')
})
Test Coverage
To ensure your tests are thorough, you can measure test coverage using Jest:
npm test -- --coverage
This generates a report showing which parts of your code are tested and which aren't.
Summary
In this guide, we've learned:
- How to set up a Vue.js unit testing environment with Jest and Vue Test Utils
- Writing tests for basic Vue component features
- Testing computed properties and methods
- Handling asynchronous operations in tests
- Testing Vuex and Vue Router integration
- Best practices for writing maintainable tests
Unit testing Vue.js components might seem like extra work initially, but the confidence it provides when refactoring or adding features makes it well worth the investment. Start with simple tests for your core components, and gradually build up your test suite as your application grows.
Additional Resources
Here are some resources to deepen your understanding of Vue.js unit testing:
Exercises
- Write tests for a form component that validates user input
- Create tests for a component that uses Vue lifecycle hooks
- Test a component that renders different UI based on user permissions
- Write tests for a custom directive that you've created
- Implement tests for a complex component that uses both Vuex and Vue Router
Happy testing!
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)