Skip to main content

Vue.js Component Testing

Introduction

Component testing is a critical part of building reliable Vue.js applications. In this guide, we'll explore how to test Vue components effectively, ensuring they behave correctly in isolation and as part of your larger application.

Component testing sits between unit testing (testing small, isolated functions) and end-to-end testing (testing the entire application). It focuses on verifying that components render correctly, respond to user interactions appropriately, and emit the expected events.

Why Test Components?

Before diving into how to test components, let's understand why it's important:

  1. Confidence in changes: Tests help ensure that changes don't break existing functionality
  2. Documentation: Tests serve as living documentation of how components should behave
  3. Better design: Testing often leads to more modular, loosely coupled components
  4. Regression prevention: Tests catch bugs before they reach production

Setting Up the Testing Environment

Prerequisites

To test Vue components, you'll need:

  • A Vue.js project
  • Testing libraries: Vue Test Utils, Jest (or Vitest)

Installing Dependencies

If you're using Vue CLI, you can add the testing libraries when creating your project. If you need to add them to an existing project:

bash
# If using Jest
npm install --save-dev @vue/test-utils jest @vue/vue3-jest @babel/preset-env babel-jest

# If using Vitest (recommended for Vue 3)
npm install --save-dev @vue/test-utils vitest

Configuration

Create or update your Jest configuration in jest.config.js:

javascript
module.exports = {
moduleFileExtensions: ['js', 'vue'],
transform: {
'^.+\\.vue$': '@vue/vue3-jest',
'^.+\\.js$': 'babel-jest'
},
testEnvironment: 'jsdom'
}

Or for Vitest in vite.config.js:

javascript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true
}
})

Writing Your First Component Test

Let's start with a simple counter component:

html
<!-- Counter.vue -->
<template>
<div>
<p data-testid="count">Count: {{ count }}</p>
<button @click="increment">Increment</button>
</div>
</template>

<script>
export default {
data() {
return {
count: 0
}
},
methods: {
increment() {
this.count += 1
}
}
}
</script>

Now let's write a test for this component:

javascript
// Counter.spec.js
import { mount } from '@vue/test-utils'
import Counter from '@/components/Counter.vue'

describe('Counter.vue', () => {
it('increments count when button is clicked', async () => {
const wrapper = mount(Counter)

// Check the initial count
expect(wrapper.find('[data-testid="count"]').text()).toContain('Count: 0')

// Click the button
await wrapper.find('button').trigger('click')

// Assert that count is incremented
expect(wrapper.find('[data-testid="count"]').text()).toContain('Count: 1')
})
})

This test verifies that:

  1. The component initially displays a count of 0
  2. When the button is clicked, the count increases to 1

Core Concepts in Component Testing

Mounting Components

Vue Test Utils provides two main ways to render components:

  • mount(): Creates a fully rendered component with all child components
  • shallowMount(): Renders only the component itself, stubbing out child components
javascript
// Full mounting (includes child components)
const wrapper = mount(ParentComponent)

// Shallow mounting (stubs child components)
const wrapper = shallowMount(ParentComponent)

Finding Elements

You can find elements in your mounted component using various selectors:

javascript
// By CSS selector
wrapper.find('button')

// By component name
wrapper.findComponent(ChildComponent)

// By data attribute (recommended)
wrapper.find('[data-testid="submit-button"]')

Using data-testid attributes is considered a best practice as it separates your CSS styling from your testing selectors.

Interacting with Components

You can simulate user interactions:

javascript
// Click events
await wrapper.find('button').trigger('click')

// Input events
await wrapper.find('input').setValue('New value')

// Form submission
await wrapper.find('form').trigger('submit')

Testing Props

Let's test a component that receives props:

html
<!-- Greeting.vue -->
<template>
<div>
<h1>Hello, {{ name }}</h1>
</div>
</template>

<script>
export default {
props: {
name: {
type: String,
required: true
}
}
}
</script>

Test for this component:

javascript
import { mount } from '@vue/test-utils'
import Greeting from '@/components/Greeting.vue'

describe('Greeting.vue', () => {
it('renders the correct name', () => {
const name = 'Vue Developer'
const wrapper = mount(Greeting, {
props: {
name
}
})

expect(wrapper.text()).toContain(`Hello, ${name}`)
})
})

Testing Emitted Events

Let's test a component that emits events:

html
<!-- SearchInput.vue -->
<template>
<div>
<input
v-model="searchText"
@input="emitSearch"
placeholder="Search..."
data-testid="search-input"
/>
</div>
</template>

<script>
export default {
data() {
return {
searchText: ''
}
},
methods: {
emitSearch() {
this.$emit('search', this.searchText)
}
}
}
</script>

Test for this component:

javascript
import { mount } from '@vue/test-utils'
import SearchInput from '@/components/SearchInput.vue'

describe('SearchInput.vue', () => {
it('emits search event when input changes', async () => {
const wrapper = mount(SearchInput)
const input = wrapper.find('[data-testid="search-input"]')

// Set the input value
await input.setValue('test query')

// Check that the correct event was emitted
expect(wrapper.emitted()).toHaveProperty('search')
expect(wrapper.emitted().search[0]).toEqual(['test query'])
})
})

Testing Advanced Component Features

Testing Computed Properties

html
<!-- ProductPrice.vue -->
<template>
<div>
<p data-testid="price">{{ formattedPrice }}</p>
</div>
</template>

<script>
export default {
props: {
price: {
type: Number,
required: true
}
},
computed: {
formattedPrice() {
return `$${this.price.toFixed(2)}`
}
}
}
</script>

Test:

javascript
import { mount } from '@vue/test-utils'
import ProductPrice from '@/components/ProductPrice.vue'

describe('ProductPrice.vue', () => {
it('formats the price correctly', () => {
const wrapper = mount(ProductPrice, {
props: {
price: 19.99
}
})

expect(wrapper.find('[data-testid="price"]').text()).toBe('$19.99')
})
})

Testing Vuex Store Interactions

When your component uses Vuex, you can provide a mock store:

html
<!-- UserProfile.vue -->
<template>
<div>
<h1 data-testid="username">{{ username }}</h1>
<button @click="logout">Logout</button>
</div>
</template>

<script>
import { mapState, mapActions } from 'vuex'

export default {
computed: {
...mapState('user', ['username'])
},
methods: {
...mapActions('user', ['logout'])
}
}
</script>

Test:

javascript
import { mount } from '@vue/test-utils'
import UserProfile from '@/components/UserProfile.vue'
import { createStore } from 'vuex'

describe('UserProfile.vue', () => {
it('displays username from store and calls logout action', async () => {
const logoutMock = jest.fn()

// Create mock store
const store = createStore({
modules: {
user: {
namespaced: true,
state: {
username: 'testuser'
},
actions: {
logout: logoutMock
}
}
}
})

const wrapper = mount(UserProfile, {
global: {
plugins: [store]
}
})

// Check username from store is displayed
expect(wrapper.find('[data-testid="username"]').text()).toBe('testuser')

// Click logout button
await wrapper.find('button').trigger('click')

// Verify logout action was called
expect(logoutMock).toHaveBeenCalled()
})
})

Testing Vue Router Components

For components that use Vue Router:

html
<!-- NavLink.vue -->
<template>
<router-link :to="destination" data-testid="nav-link">
{{ label }}
</router-link>
</template>

<script>
export default {
props: {
destination: {
type: String,
required: true
},
label: {
type: String,
required: true
}
}
}
</script>

Test:

javascript
import { mount } from '@vue/test-utils'
import NavLink from '@/components/NavLink.vue'
import { createRouter, createWebHistory } from 'vue-router'

describe('NavLink.vue', () => {
it('renders router-link with correct props', () => {
// Create mock router
const router = createRouter({
history: createWebHistory(),
routes: [{ path: '/dashboard', name: 'Dashboard', component: { template: '<div></div>' } }]
})

const wrapper = mount(NavLink, {
props: {
destination: '/dashboard',
label: 'Go to Dashboard'
},
global: {
plugins: [router]
}
})

expect(wrapper.find('[data-testid="nav-link"]').text()).toBe('Go to Dashboard')
expect(wrapper.find('[data-testid="nav-link"]').attributes('href')).toBe('/dashboard')
})
})

Testing Strategies and Best Practices

Component Testing Pyramid

Testing Dos and Don'ts

Do:

  • Test component outputs, not implementation details
  • Use data-testid attributes for selecting elements
  • Keep tests focused and concise
  • Test edge cases and error states
  • Use shallow mounting when possible for faster tests

Don't:

  • Test the framework itself (Vue's reactivity system)
  • Write brittle tests tied to implementation details
  • Over-mock dependencies
  • Duplicate the component's logic in tests

Snapshot Testing

Snapshot testing captures the output of a component and compares it against a saved "snapshot":

javascript
import { mount } from '@vue/test-utils'
import Button from '@/components/Button.vue'

describe('Button.vue', () => {
it('renders correctly', () => {
const wrapper = mount(Button, {
props: {
label: 'Submit',
theme: 'primary'
}
})

expect(wrapper.html()).toMatchSnapshot()
})
})

Snapshots are useful for:

  • Detecting unexpected UI changes
  • Testing primarily presentational components
  • Quick regression testing

However, use them sparingly as they can become maintenance-heavy.

Real-World Example: Testing a Todo Component

Let's write tests for a more complete todo component:

html
<!-- TodoItem.vue -->
<template>
<div class="todo-item" :class="{ completed: todo.completed }">
<input
type="checkbox"
:checked="todo.completed"
@change="toggleComplete"
data-testid="todo-checkbox"
/>
<span data-testid="todo-text">{{ todo.text }}</span>
<button @click="$emit('remove')" data-testid="remove-button">
Delete
</button>
</div>
</template>

<script>
export default {
props: {
todo: {
type: Object,
required: true
}
},
methods: {
toggleComplete() {
this.$emit('toggle', {
...this.todo,
completed: !this.todo.completed
})
}
}
}
</script>

Comprehensive tests:

javascript
import { mount } from '@vue/test-utils'
import TodoItem from '@/components/TodoItem.vue'

describe('TodoItem.vue', () => {
const createWrapper = (props = {}) => {
return mount(TodoItem, {
props: {
todo: { id: 1, text: 'Learn Vue Testing', completed: false },
...props
}
})
}

it('renders todo text correctly', () => {
const wrapper = createWrapper()
expect(wrapper.find('[data-testid="todo-text"]').text()).toBe('Learn Vue Testing')
})

it('applies completed class when todo is completed', () => {
const wrapper = createWrapper({
todo: { id: 1, text: 'Learn Vue Testing', completed: true }
})
expect(wrapper.classes()).toContain('completed')
})

it('emits toggle event when checkbox is clicked', async () => {
const wrapper = createWrapper()
await wrapper.find('[data-testid="todo-checkbox"]').setValue(true)

// Check that the toggle event was emitted with the updated todo
expect(wrapper.emitted()).toHaveProperty('toggle')
expect(wrapper.emitted().toggle[0][0]).toEqual({
id: 1,
text: 'Learn Vue Testing',
completed: true
})
})

it('emits remove event when delete button is clicked', async () => {
const wrapper = createWrapper()
await wrapper.find('[data-testid="remove-button"]').trigger('click')

// Check that the remove event was emitted
expect(wrapper.emitted()).toHaveProperty('remove')
})
})

This test suite covers multiple aspects of the component:

  1. Correct rendering of content
  2. Conditional class application
  3. Event emissions for both toggle and remove actions

Testing Async Components

When testing components with asynchronous behavior:

html
<!-- UserList.vue -->
<template>
<div>
<h1>Users</h1>
<p v-if="loading" data-testid="loading">Loading users...</p>
<ul v-else>
<li v-for="user in users" :key="user.id" data-testid="user-item">
{{ user.name }}
</li>
</ul>
<p v-if="error" data-testid="error">{{ error }}</p>
</div>
</template>

<script>
import { fetchUsers } from '@/api'

export default {
data() {
return {
users: [],
loading: false,
error: null
}
},
async mounted() {
this.loading = true
try {
this.users = await fetchUsers()
} catch (err) {
this.error = 'Failed to load users'
} finally {
this.loading = false
}
}
}
</script>

Testing async behavior:

javascript
import { mount, flushPromises } from '@vue/test-utils'
import UserList from '@/components/UserList.vue'
import { fetchUsers } from '@/api'

// Mock the API module
jest.mock('@/api', () => ({
fetchUsers: jest.fn()
}))

describe('UserList.vue', () => {
it('shows loading state then renders users', async () => {
// Setup mock to return sample users
fetchUsers.mockResolvedValue([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
])

const wrapper = mount(UserList)

// Should show loading initially
expect(wrapper.find('[data-testid="loading"]').exists()).toBe(true)

// Wait for promises to resolve
await flushPromises()

// Loading should be gone
expect(wrapper.find('[data-testid="loading"]').exists()).toBe(false)

// User list should be rendered
const userItems = wrapper.findAll('[data-testid="user-item"]')
expect(userItems).toHaveLength(2)
expect(userItems[0].text()).toBe('Alice')
expect(userItems[1].text()).toBe('Bob')
})

it('shows error message when API fails', async () => {
// Setup mock to throw an error
fetchUsers.mockRejectedValue(new Error('API error'))

const wrapper = mount(UserList)

// Wait for promises to resolve
await flushPromises()

// Should show error message
expect(wrapper.find('[data-testid="error"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="error"]').text()).toBe('Failed to load users')
})
})

This demonstrates how to test:

  1. Loading states
  2. Successful data fetching and rendering
  3. Error handling

Summary

In this guide, we've covered:

  • Setting up a Vue component testing environment
  • Writing basic component tests
  • Testing props, events, and computed properties
  • Testing components that interact with Vuex and Vue Router
  • Best practices and strategies for effective testing
  • Testing async components and handling API calls

By following these practices, you can build a robust test suite that catches bugs early and provides confidence when refactoring or adding new features to your Vue applications.

Additional Resources and Exercises

Resources

  1. Vue Test Utils Documentation
  2. Jest Documentation
  3. Vue Testing Handbook

Practice Exercises

  1. Basic Component Test: Create a simple form component with validation and write tests for:

    • Initial state
    • Form submission with valid data
    • Form submission with invalid data
    • Validation error messages
  2. Component with Vuex: Create a shopping cart component that interacts with a Vuex store. Write tests that verify:

    • Products are displayed correctly
    • Adding/removing items works
    • The total price is calculated correctly
  3. Async Component: Build a component that loads data from an API and displays it with pagination. Test:

    • Loading states
    • Successful data display
    • Error handling
    • Pagination controls

By completing these exercises, you'll build confidence in testing a variety of Vue component patterns that you'll encounter in real-world applications.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)