Vue.js test

Why is testing needed?

Testing can ensure code quality, prevent regression errors, and improve code maintainability. There are three main types of tests for Vue applications:

  • Unit testing: Test independent functions and components
  • Component Testing: Test the behavior and interaction of components
  • End-to-End Testing (E2E): Test the complete user flow

Test tools

Vitest is a fast unit testing framework that integrates perfectly with Vite.

npm install -D vitest @vue/test-utils

Configure Vitest

// vitest.config.js
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'

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

Unit testing basics

Test pure functions

// utils/math.js
export function add(a, b) {
  return a + b
}

export function multiply(a, b) {
  return a * b
}
// utils/math.test.js
import { describe, it, expect } from 'vitest'
import { add, multiply } from './math'

describe('Math Utils', () => {
  it('should add two numbers', () => {
    expect(add(1, 2)).toBe(3)
    expect(add(-1, 1)).toBe(0)
  })
  
  it('should multiply two numbers', () => {
    expect(multiply(2, 3)).toBe(6)
    expect(multiply(0, 5)).toBe(0)
  })
})

Test combined functions

// composables/useCounter.js
import { ref } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  function increment() {
    count.value++
  }
  
  function decrement() {
    count.value--
  }
  
  function reset() {
    count.value = initialValue
  }
  
  return {
    count,
    increment,
    decrement,
    reset
  }
}
// composables/useCounter.test.js
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('should initialize with default value', () => {
    const { count } = useCounter()
    expect(count.value).toBe(0)
  })
  
  it('should initialize with custom value', () => {
    const { count } = useCounter(10)
    expect(count.value).toBe(10)
  })
  
  it('should increment count', () => {
    const { count, increment } = useCounter()
    increment()
    expect(count.value).toBe(1)
  })
  
  it('should decrement count', () => {
    const { count, decrement } = useCounter(5)
    decrement()
    expect(count.value).toBe(4)
  })
  
  it('should reset count', () => {
    const { count, increment, reset } = useCounter(0)
    increment()
    increment()
    reset()
    expect(count.value).toBe(0)
  })
})

Component testing

Test simple components

<!-- components/Counter.vue -->
<script setup>
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}
</script>

<template>
  <div>
    <p>Count: {{ count }}</p>
    <button @click="increment">Increment</button>
  </div>
</template>
// components/Counter.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'

describe('Counter Component', () => {
  it('should render initial count', () => {
    const wrapper = mount(Counter)
    expect(wrapper.text()).toContain('Count: 0')
  })
  
  it('should increment count when button is clicked', async () => {
    const wrapper = mount(Counter)
    const button = wrapper.find('button')
    
    await button.trigger('click')
    expect(wrapper.text()).toContain('Count: 1')
    
    await button.trigger('click')
    expect(wrapper.text()).toContain('Count: 2')
  })
})

Test Props

<!-- components/Greeting.vue -->
<script setup>
defineProps({
  name: {
    type: String,
    required: true
  },
  age: Number
})
</script>

<template>
  <div>
    <h1>Hello, {{ name }}!</h1>
    <p v-if="age">You are {{ age }} years old.</p>
  </div>
</template>
// components/Greeting.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Greeting from './Greeting.vue'

describe('Greeting Component', () => {
  it('should render name prop', () => {
    const wrapper = mount(Greeting, {
      props: {
        name: 'Alice'
      }
    })
    expect(wrapper.text()).toContain('Hello, Alice!')
  })
  
  it('should render age when provided', () => {
    const wrapper = mount(Greeting, {
      props: {
        name: 'Bob',
        age: 25
      }
    })
    expect(wrapper.text()).toContain('You are 25 years old.')
  })
  
  it('should not render age when not provided', () => {
    const wrapper = mount(Greeting, {
      props: {
        name: 'Charlie'
      }
    })
    expect(wrapper.text()).not.toContain('years old')
  })
})

Test Emits

<!-- components/CustomButton.vue -->
<script setup>
const emit = defineEmits(['click', 'doubleClick'])

function handleClick() {
  emit('click', 'Button clicked')
}

function handleDoubleClick() {
  emit('doubleClick', 'Button double clicked')
}
</script>

<template>
  <button 
    @click="handleClick"
    @dblclick="handleDoubleClick"
  >
    <slot>Click me</slot>
  </button>
</template>
// components/CustomButton.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import CustomButton from './CustomButton.vue'

describe('CustomButton Component', () => {
  it('should emit click event', async () => {
    const wrapper = mount(CustomButton)
    await wrapper.trigger('click')
    
    expect(wrapper.emitted()).toHaveProperty('click')
    expect(wrapper.emitted('click')).toHaveLength(1)
    expect(wrapper.emitted('click')[0]).toEqual(['Button clicked'])
  })
  
  it('should emit doubleClick event', async () => {
    const wrapper = mount(CustomButton)
    await wrapper.trigger('dblclick')
    
    expect(wrapper.emitted()).toHaveProperty('doubleClick')
    expect(wrapper.emitted('doubleClick')[0]).toEqual(['Button double clicked'])
  })
  
  it('should render slot content', () => {
    const wrapper = mount(CustomButton, {
      slots: {
        default: 'Custom Text'
      }
    })
    expect(wrapper.text()).toBe('Custom Text')
  })
})

Test asynchronous operations

<!-- components/UserProfile.vue -->
<script setup>
import { ref, onMounted } from 'vue'

const user = ref(null)
const loading = ref(true)
const error = ref(null)

async function fetchUser() {
  try {
    const response = await fetch('/api/user')
    user.value = await response.json()
  } catch (err) {
    error.value = err.message
  } finally {
    loading.value = false
  }
}

onMounted(() => {
  fetchUser()
})
</script>

<template>
  <div>
    <div v-if="loading">Loading...</div>
    <div v-else-if="error">Error: {{ error }}</div>
    <div v-else>
      <h2>{{ user.name }}</h2>
      <p>{{ user.email }}</p>
    </div>
  </div>
</template>
// components/UserProfile.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import UserProfile from './UserProfile.vue'

describe('UserProfile Component', () => {
  beforeEach(() => {
    global.fetch = vi.fn()
  })
  
  it('should show loading state initially', () => {
    const wrapper = mount(UserProfile)
    expect(wrapper.text()).toContain('Loading...')
  })
  
  it('should display user data after loading', async () => {
    global.fetch.mockResolvedValue({
      json: async () => ({
        name: 'John Doe',
        email: 'john@example.com'
      })
    })
    
    const wrapper = mount(UserProfile)
    await flushPromises()
    
    expect(wrapper.text()).toContain('John Doe')
    expect(wrapper.text()).toContain('john@example.com')
  })
  
  it('should display error message on failure', async () => {
    global.fetch.mockRejectedValue(new Error('Network error'))
    
    const wrapper = mount(UserProfile)
    await flushPromises()
    
    expect(wrapper.text()).toContain('Error: Network error')
  })
})

Test Pinia Store

// stores/counter.js
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  
  function increment() {
    count.value++
  }
  
  function decrement() {
    count.value--
  }
  
  return { count, increment, decrement }
})
// stores/counter.test.js
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from './counter'

describe('Counter Store', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })
  
  it('should initialize with count 0', () => {
    const store = useCounterStore()
    expect(store.count).toBe(0)
  })
  
  it('should increment count', () => {
    const store = useCounterStore()
    store.increment()
    expect(store.count).toBe(1)
  })
  
  it('should decrement count', () => {
    const store = useCounterStore()
    store.count = 5
    store.decrement()
    expect(store.count).toBe(4)
  })
})

Testing Best Practices

1. Test user behavior, not implementation details

// ❌ 测试实现细节
it('should call increment method', () => {
  const wrapper = mount(Counter)
  const spy = vi.spyOn(wrapper.vm, 'increment')
  wrapper.find('button').trigger('click')
  expect(spy).toHaveBeenCalled()
})

// ✅ 测试用户行为
it('should increase count when button is clicked', async () => {
  const wrapper = mount(Counter)
  await wrapper.find('button').trigger('click')
  expect(wrapper.text()).toContain('Count: 1')
})

2. Use data test ID

<template>
  <button data-testid="increment-btn" @click="increment">
    Increment
  </button>
</template>
it('should increment when button is clicked', async () => {
  const wrapper = mount(Counter)
  await wrapper.find('[data-testid="increment-btn"]').trigger('click')
  expect(wrapper.text()).toContain('Count: 1')
})

3. Mock dependencies

// Mock API 调用
vi.mock('./api', () => ({
  fetchUser: vi.fn(() => Promise.resolve({ name: 'Test User' }))
}))

// Mock 路由
const mockRouter = {
  push: vi.fn()
}

const wrapper = mount(Component, {
  global: {
    mocks: {
      $router: mockRouter
    }
  }
})

Test coverage

// package.json
{
  "scripts": {
    "test": "vitest",
    "test:coverage": "vitest --coverage"
  }
}
npm run test:coverage

End-to-end testing (E2E)

Use Playwright or Cypress for E2E testing:

// e2e/login.spec.js (Playwright)
import { test, expect } from '@playwright/test'

test('user can login', async ({ page }) => {
  await page.goto('http://localhost:3000')
  
  await page.fill('[data-testid="username"]', 'testuser')
  await page.fill('[data-testid="password"]', 'password123')
  await page.click('[data-testid="login-btn"]')
  
  await expect(page.locator('text=Welcome')).toBeVisible()
})

Summarize

  • Use Vitest for unit testing and component testing
  • Test user behavior, not implementation details
  • use@vue/test-utilsTest Vue components
  • Mock external dependencies and API calls
  • Pursue reasonable test coverage
  • Verify the complete process using E2E testing

Next step

Next, learn Server-Side Rendering (SSR) to learn how to implement server-side rendering.