個人主頁:Guiat
歸屬專欄:Vue
文章目錄
- 1. Vue 單元測試簡介
- 1.1 為什么需要單元測試
- 1.2 測試工具介紹
- 2. 環境搭建
- 2.1 安裝依賴
- 2.2 配置 Jest
- 3. 編寫第一個測試
- 3.1 組件示例
- 3.2 編寫測試用例
- 3.3 運行測試
- 4. Vue Test Utils 核心 API
- 4.1 掛載組件
- 4.2 常用斷言和操作
- 5. 測試組件交互
- 5.1 測試用戶輸入
- 5.2 測試 Props 和自定義事件
- 6. 模擬依賴
- 6.1 模擬 API 請求
- 6.2 模擬 Vuex
- 7. 測試 Vue Router
- 7.1 模擬 Vue Router
- 7.2 測試路由組件
- 8. 快照測試
- 8.1 基本快照測試
- 8.2 更新快照
- 9. 測試覆蓋率
- 9.1 理解覆蓋率指標
- 9.2 覆蓋率報告
- 10. 測試最佳實踐
- 10.1 組織測試
- 10.2 測試原則
- 10.3 常見測試場景對比
- 11. 持續集成中的測試
- 11.1 配置 CI 流程
- 11.2 測試報告整合
- 12. 測試驅動開發 (TDD) 與 Vue
- 12.1 TDD 流程
- 12.2 TDD 示例
- 13. 常見問題與解決方案
- 13.1 異步測試問題
- 13.2 復雜 DOM 結構查找問題
- 13.3 模擬復雜的 Vuex Store
正文
1. Vue 單元測試簡介
單元測試是確保代碼質量和可維護性的重要手段,在 Vue 應用開發中,Jest 和 Vue Test Utils 是最常用的測試工具組合。
1.1 為什么需要單元測試
- 提早發現 bug,減少線上問題
- 重構代碼時提供安全保障
- 作為代碼的活文檔,幫助理解組件功能
- 促進更好的代碼設計和模塊化
1.2 測試工具介紹
- Jest: Facebook 開發的 JavaScript 測試框架,提供斷言庫、測試運行器和覆蓋率報告
- Vue Test Utils: Vue.js 官方的單元測試實用工具庫,提供掛載組件和與之交互的方法
2. 環境搭建
2.1 安裝依賴
# 使用 Vue CLI 創建項目時選擇單元測試
vue create my-project# 或在現有項目中安裝
npm install --save-dev jest @vue/test-utils vue-jest babel-jest
2.2 配置 Jest
在 package.json
中添加 Jest 配置:
{"jest": {"moduleFileExtensions": ["js","vue"],"transform": {"^.+\\.vue$": "vue-jest","^.+\\.js$": "babel-jest"},"moduleNameMapper": {"^@/(.*)$": "<rootDir>/src/$1"},"testMatch": ["**/tests/unit/**/*.spec.[jt]s?(x)"],"collectCoverage": true,"collectCoverageFrom": ["src/**/*.{js,vue}","!src/main.js","!src/router/index.js","!**/node_modules/**"]}
}
3. 編寫第一個測試
3.1 組件示例
假設有一個簡單的計數器組件 Counter.vue
:
<template><div><span class="count">{{ count }}</span><button @click="increment">增加</button><button @click="decrement">減少</button></div>
</template><script>
export default {data() {return {count: 0}},methods: {increment() {this.count += 1},decrement() {this.count -= 1}}
}
</script>
3.2 編寫測試用例
創建 tests/unit/Counter.spec.js
文件:
import { mount } from '@vue/test-utils'
import Counter from '@/components/Counter.vue'describe('Counter.vue', () => {it('初始計數為0', () => {const wrapper = mount(Counter)expect(wrapper.find('.count').text()).toBe('0')})it('點擊增加按鈕后計數加1', async () => {const wrapper = mount(Counter)await wrapper.findAll('button').at(0).trigger('click')expect(wrapper.find('.count').text()).toBe('1')})it('點擊減少按鈕后計數減1', async () => {const wrapper = mount(Counter)await wrapper.findAll('button').at(1).trigger('click')expect(wrapper.find('.count').text()).toBe('-1')})
})
3.3 運行測試
npm run test:unit
4. Vue Test Utils 核心 API
4.1 掛載組件
// 完全掛載組件及其子組件
const wrapper = mount(Component, {propsData: { /* 組件 props */ },data() { /* 覆蓋組件數據 */ },mocks: { /* 模擬全局對象 */ },stubs: { /* 替換子組件 */ }
})// 只掛載當前組件,不渲染子組件
const wrapper = shallowMount(Component, options)
4.2 常用斷言和操作
// 查找元素
wrapper.find('div') // CSS 選擇器
wrapper.find('.class-name')
wrapper.find('[data-test="id"]')
wrapper.findComponent(ChildComponent)// 檢查內容和屬性
expect(wrapper.text()).toContain('Hello')
expect(wrapper.html()).toContain('<div>')
expect(wrapper.attributes('id')).toBe('my-id')
expect(wrapper.classes()).toContain('active')// 觸發事件
await wrapper.find('button').trigger('click')
await wrapper.find('input').setValue('new value')// 訪問組件實例
console.log(wrapper.vm.count) // 訪問數據
wrapper.vm.increment() // 調用方法// 更新組件
await wrapper.setProps({ color: 'red' })
await wrapper.setData({ count: 5 })
5. 測試組件交互
5.1 測試用戶輸入
假設有一個表單組件 Form.vue
:
<template><form @submit.prevent="submitForm"><input v-model="username" data-test="username" /><input type="password" v-model="password" data-test="password" /><button type="submit" data-test="submit">登錄</button><p v-if="error" data-test="error">{{ error }}</p></form>
</template><script>
export default {data() {return {username: '',password: '',error: ''}},methods: {submitForm() {if (!this.username || !this.password) {this.error = '用戶名和密碼不能為空'return}this.$emit('form-submitted', {username: this.username,password: this.password})this.error = ''}}
}
</script>
測試代碼 Form.spec.js
:
import { mount } from '@vue/test-utils'
import Form from '@/components/Form.vue'describe('Form.vue', () => {it('提交空表單時顯示錯誤信息', async () => {const wrapper = mount(Form)await wrapper.find('[data-test="submit"]').trigger('click')expect(wrapper.find('[data-test="error"]').text()).toBe('用戶名和密碼不能為空')})it('表單正確提交時觸發事件', async () => {const wrapper = mount(Form)await wrapper.find('[data-test="username"]').setValue('user1')await wrapper.find('[data-test="password"]').setValue('pass123')await wrapper.find('form').trigger('submit')expect(wrapper.emitted('form-submitted')).toBeTruthy()expect(wrapper.emitted('form-submitted')[0][0]).toEqual({username: 'user1',password: 'pass123'})})
})
5.2 測試 Props 和自定義事件
假設有一個展示商品的組件 ProductItem.vue
:
<template><div class="product-item"><h3>{{ product.name }}</h3><p>{{ product.price }}元</p><button @click="addToCart">加入購物車</button></div>
</template><script>
export default {props: {product: {type: Object,required: true}},methods: {addToCart() {this.$emit('add-to-cart', this.product.id)}}
}
</script>
測試代碼 ProductItem.spec.js
:
import { mount } from '@vue/test-utils'
import ProductItem from '@/components/ProductItem.vue'describe('ProductItem.vue', () => {const product = {id: 1,name: '測試商品',price: 99}it('正確渲染商品信息', () => {const wrapper = mount(ProductItem, {propsData: { product }})expect(wrapper.find('h3').text()).toBe('測試商品')expect(wrapper.find('p').text()).toBe('99元')})it('點擊按鈕觸發加入購物車事件', async () => {const wrapper = mount(ProductItem, {propsData: { product }})await wrapper.find('button').trigger('click')expect(wrapper.emitted('add-to-cart')).toBeTruthy()expect(wrapper.emitted('add-to-cart')[0]).toEqual([1])})
})
6. 模擬依賴
6.1 模擬 API 請求
假設有一個使用 axios 獲取用戶數據的組件 UserList.vue
:
<template><div><div v-if="loading">加載中...</div><ul v-else><li v-for="user in users" :key="user.id" data-test="user">{{ user.name }}</li></ul><div v-if="error" data-test="error">{{ error }}</div></div>
</template><script>
import axios from 'axios'export default {data() {return {users: [],loading: true,error: null}},created() {this.fetchUsers()},methods: {async fetchUsers() {try {this.loading = trueconst response = await axios.get('/api/users')this.users = response.data} catch (error) {this.error = '獲取用戶列表失敗'} finally {this.loading = false}}}
}
</script>
測試代碼 UserList.spec.js
:
import { mount, flushPromises } from '@vue/test-utils'
import UserList from '@/components/UserList.vue'
import axios from 'axios'// 模擬 axios
jest.mock('axios')describe('UserList.vue', () => {it('成功獲取用戶列表', async () => {// 設置 axios.get 的模擬返回值axios.get.mockResolvedValue({data: [{ id: 1, name: '用戶1' },{ id: 2, name: '用戶2' }]})const wrapper = mount(UserList)// 等待異步操作完成await flushPromises()// 斷言加載狀態消失expect(wrapper.find('div').text()).not.toBe('加載中...')// 斷言用戶列表已渲染const users = wrapper.findAll('[data-test="user"]')expect(users).toHaveLength(2)expect(users.at(0).text()).toBe('用戶1')expect(users.at(1).text()).toBe('用戶2')})it('獲取用戶列表失敗', async () => {// 設置 axios.get 模擬拋出錯誤axios.get.mockRejectedValue(new Error('API 錯誤'))const wrapper = mount(UserList)// 等待異步操作完成await flushPromises()// 斷言顯示錯誤信息expect(wrapper.find('[data-test="error"]').text()).toBe('獲取用戶列表失敗')})
})
6.2 模擬 Vuex
假設有一個使用 Vuex 的計數器組件 VuexCounter.vue
:
<template><div><span data-test="count">{{ count }}</span><button @click="increment">增加</button><button @click="decrement">減少</button></div>
</template><script>
import { mapState, mapActions } from 'vuex'export default {computed: {...mapState(['count'])},methods: {...mapActions(['increment', 'decrement'])}
}
</script>
測試代碼 VuexCounter.spec.js
:
import { mount, createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'
import VuexCounter from '@/components/VuexCounter.vue'// 創建擴展的 Vue 實例
const localVue = createLocalVue()
localVue.use(Vuex)describe('VuexCounter.vue', () => {let storelet actionslet statebeforeEach(() => {// 設置模擬的 state 和 actionsstate = {count: 5}actions = {increment: jest.fn(),decrement: jest.fn()}// 創建模擬的 storestore = new Vuex.Store({state,actions})})it('從 store 渲染計數', () => {const wrapper = mount(VuexCounter, {store,localVue})expect(wrapper.find('[data-test="count"]').text()).toBe('5')})it('調度 increment action', async () => {const wrapper = mount(VuexCounter, {store,localVue})await wrapper.findAll('button').at(0).trigger('click')expect(actions.increment).toHaveBeenCalled()})it('調度 decrement action', async () => {const wrapper = mount(VuexCounter, {store,localVue})await wrapper.findAll('button').at(1).trigger('click')expect(actions.decrement).toHaveBeenCalled()})
})
7. 測試 Vue Router
7.1 模擬 Vue Router
使用 Vue Router 的導航組件 Navigation.vue
:
<template><nav><router-link to="/" data-test="home">首頁</router-link><router-link to="/about" data-test="about">關于</router-link><button @click="goToContact" data-test="contact">聯系我們</button></nav>
</template><script>
export default {methods: {goToContact() {this.$router.push('/contact')}}
}
</script>
測試代碼 Navigation.spec.js
:
import { mount, createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router'
import Navigation from '@/components/Navigation.vue'const localVue = createLocalVue()
localVue.use(VueRouter)describe('Navigation.vue', () => {it('點擊按鈕進行路由導航', async () => {const router = new VueRouter()// 監視 router.push 方法router.push = jest.fn()const wrapper = mount(Navigation, {localVue,router})await wrapper.find('[data-test="contact"]').trigger('click')expect(router.push).toHaveBeenCalledWith('/contact')})
})
7.2 測試路由組件
假設有一個根據路由參數顯示內容的組件 UserDetails.vue
:
<template><div><h1 data-test="user-name">{{ userName }}</h1></div>
</template><script>
export default {data() {return {userName: ''}},created() {// 根據路由參數獲取用戶名this.userName = `用戶 ${this.$route.params.id}`}
}
</script>
測試代碼 UserDetails.spec.js
:
import { mount, createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router'
import UserDetails from '@/components/UserDetails.vue'const localVue = createLocalVue()
localVue.use(VueRouter)describe('UserDetails.vue', () => {it('根據路由參數顯示用戶名', () => {// 創建帶有初始路由和參數的路由實例const router = new VueRouter()// 創建帶有模擬路由的組件const wrapper = mount(UserDetails, {localVue,mocks: {$route: {params: {id: '42'}}}})expect(wrapper.find('[data-test="user-name"]').text()).toBe('用戶 42')})
})
8. 快照測試
快照測試可以確保組件 UI 不會意外改變。
8.1 基本快照測試
import { mount } from '@vue/test-utils'
import MessageDisplay from '@/components/MessageDisplay.vue'describe('MessageDisplay.vue', () => {it('渲染的 UI 與上次快照匹配', () => {const wrapper = mount(MessageDisplay, {propsData: {message: '歡迎使用 Vue!'}})expect(wrapper.html()).toMatchSnapshot()})
})
8.2 更新快照
當組件合法變更后,需要更新快照:
# 更新所有快照
jest --updateSnapshot# 更新特定測試的快照
jest --updateSnapshot -t 'MessageDisplay'
9. 測試覆蓋率
9.1 理解覆蓋率指標
Jest 提供四種覆蓋率指標:
- 語句覆蓋率(Statements): 程序中執行到的語句比例
- 分支覆蓋率(Branches): 程序中執行到的分支比例(if/else)
- 函數覆蓋率(Functions): 被調用過的函數比例
- 行覆蓋率(Lines): 程序中執行到的行數比例
9.2 覆蓋率報告
執行帶覆蓋率報告的測試:
jest --coverage
典型的覆蓋率報告輸出:
-----------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------------------|---------|----------|---------|---------|-------------------
All files | 85.71 | 83.33 | 85.71 | 85.71 | components/Counter.vue| 100.00 | 100.00 | 100.00 | 100.00 | components/Form.vue | 71.42 | 66.67 | 71.42 | 71.42 | 15-18
-----------------------|---------|----------|---------|---------|-------------------
10. 測試最佳實踐
10.1 組織測試
- 按照組件結構組織測試文件
- 為每個組件創建單獨的測試文件
- 使用清晰的測試描述和分組
describe('組件名', () => {describe('功能1', () => {it('子功能 A', () => { /* ... */ })it('子功能 B', () => { /* ... */ })})describe('功能2', () => {it('子功能 C', () => { /* ... */ })it('子功能 D', () => { /* ... */ })})
})
10.2 測試原則
- 測試行為而非實現: 關注組件的輸出而非內部工作方式
- 使用數據屬性標記測試元素: 使用
data-test
屬性標記用于測試的元素 - 一個測試只測一個行為: 每個測試只斷言一個行為
- 避免過度模擬: 盡量減少模擬的數量
- 編寫可維護的測試: 測試代碼應該和產品代碼一樣重視質量
10.3 常見測試場景對比
graph TDA[測試場景] --> B[組件渲染]A --> C[用戶交互]A --> D[API 調用]A --> E[Vuex 整合]A --> F[路由功能]B --> B1[使用 mount() 測試完整渲染]B --> B2[使用 shallowMount() 測試隔離組件]B --> B3[使用 toMatchSnapshot() 測試 UI 穩定性]C --> C1[使用 trigger() 測試點擊事件]C --> C2[使用 setValue() 測試表單輸入]C --> C3[使用 emitted() 測試自定義事件]D --> D1[使用 jest.mock() 模擬 axios]D --> D2[測試加載狀態]D --> D3[測試成功/失敗處理]E --> E1[模擬 Vuex store]E --> E2[測試 getter 計算屬性]E --> E3[驗證 actions 被正確調度]F --> F1[使用 mocks 模擬 $route]F --> F2[驗證 router.push 調用]F --> F3[測試基于路由的組件行為]
11. 持續集成中的測試
11.1 配置 CI 流程
在 GitHub Actions 中設置 Vue 測試的 .github/workflows/test.yml
配置:
name: Unit Testson:push:branches: [ main ]pull_request:branches: [ main ]jobs:test:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v2- name: Use Node.jsuses: actions/setup-node@v2with:node-version: '14'- name: Install dependenciesrun: npm ci- name: Run testsrun: npm run test:unit- name: Upload coverageuses: codecov/codecov-action@v1with:token: ${{ secrets.CODECOV_TOKEN }}
11.2 測試報告整合
將 Jest 測試報告整合到 CI 系統,使用 JUnit 格式:
// package.json
{"jest": {"reporters": ["default",["jest-junit", {"outputDirectory": "./test-results/jest","outputName": "results.xml"}]]}
}
12. 測試驅動開發 (TDD) 與 Vue
12.1 TDD 流程
- 編寫失敗的測試: 先編寫測試,驗證未實現的功能
- 編寫最少的代碼使測試通過: 實現功能使測試通過
- 重構代碼: 優化實現,保持測試通過
12.2 TDD 示例
假設我們要開發一個待辦事項組件,先編寫測試:
// TodoList.spec.js
import { mount } from '@vue/test-utils'
import TodoList from '@/components/TodoList.vue'describe('TodoList.vue', () => {it('顯示待辦事項列表', () => {const wrapper = mount(TodoList, {propsData: {todos: [{ id: 1, text: '學習 Vue', done: false },{ id: 2, text: '學習單元測試', done: true }]}})const items = wrapper.findAll('[data-test="todo-item"]')expect(items).toHaveLength(2)expect(items.at(0).text()).toContain('學習 Vue')expect(items.at(1).text()).toContain('學習單元測試')expect(items.at(1).classes()).toContain('completed')})it('添加新的待辦事項', async () => {const wrapper = mount(TodoList)await wrapper.find('[data-test="new-todo"]').setValue('新任務')await wrapper.find('form').trigger('submit')expect(wrapper.findAll('[data-test="todo-item"]')).toHaveLength(1)expect(wrapper.find('[data-test="todo-item"]').text()).toContain('新任務')})
})
然后實現組件:
<template><div><form @submit.prevent="addTodo"><input v-model="newTodo" data-test="new-todo" /><button type="submit">添加</button></form><ul><li v-for="todo in allTodos" :key="todo.id" :class="{ completed: todo.done }"data-test="todo-item">{{ todo.text }}</li></ul></div>
</template><script>
export default {props: {todos: {type: Array,default: () => []}},data() {return {newTodo: '',localTodos: []}},computed: {allTodos() {return [...this.todos, ...this.localTodos]}},methods: {addTodo() {if (this.newTodo.trim()) {this.localTodos.push({id: Date.now(),text: this.newTodo,done: false})this.newTodo = ''}}}
}
</script><style scoped>
.completed {text-decoration: line-through;
}
</style>
13. 常見問題與解決方案
13.1 異步測試問題
問題: 測試未等待組件更新就進行斷言
解決方案: 使用 await
和 nextTick
// 錯誤示例
wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('已更新') // 可能失敗// 正確示例
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('已更新') // 已等待更新
13.2 復雜 DOM 結構查找問題
問題: 難以準確定位要測試的元素
解決方案: 使用 data-test
屬性標記測試元素
<template><div><h1 data-test="title">標題</h1><p data-test="content">內容</p></div>
</template>
// 使用 data-test 屬性查找元素
wrapper.find('[data-test="title"]')
13.3 模擬復雜的 Vuex Store
問題: 大型應用中 Store 結構復雜
解決方案: 只模擬測試需要的部分
const store = new Vuex.Store({modules: {user: {namespaced: true,state: { name: 'Test User' },getters: {fullName: () => 'Test User Full'},actions: {login: jest.fn()}},// 其他模塊可以省略}
})
結語
感謝您的閱讀!期待您的一鍵三連!歡迎指正!