最近在项目里引入了Vue Test Utils做组件单元测试,踩了不少坑。从环境配置到异步测试,从组件挂载到状态更新,从mock依赖到快照测试,每个环节都有让人头疼的地方。今天把这些坑整理出来,分享给同样在做Vue单元测试的朋友,希望大家能少走弯路。

先说明一下,我们的项目是Vue 2 + Vue CLI 3 + Jest + Vue Test Utils 1.x的技术栈,下面的坑都是基于这个技术栈的,其他技术栈可能会有差异,但思路是相通的。

一、环境配置的坑

坑1:babel配置不对,import报错

刚开始配置的时候,跑测试一直报"SyntaxError: Unexpected token import",明明Vue CLI已经配置了babel,但测试就是不生效。查了半天发现,Jest默认不读babel.config.js,需要在jest.config.js里单独配置babel transform,或者用babel-jest。

解决方法是在jest.config.js里配置transform:

module.exports = {
  transform: {
    '^.+\\.js$': 'babel-jest',
    '^.+\\.vue$': 'vue-jest'
  }
}

同时确保babel.config.js里的presets包含@babel/preset-env,并且targets配置正确。这个坑看起来简单,但刚开始的时候真的卡了很久,因为前端开发平时很少直接接触Jest的配置,容易忽略。

坑2:.vue文件无法解析

另一个常见的坑是Jest不认识.vue文件,报"Cannot find module './Component.vue'"。这是因为Jest默认只能处理.js文件,需要配置vue-jest来处理.vue单文件组件。

解决方法是安装vue-jest,然后在jest.config.js里配置transform,把.vue文件交给vue-jest处理。同时还要配置moduleFileExtensions,把vue加进去:

module.exports = {
  moduleFileExtensions: ['js', 'json', 'vue'],
  transform: {
    '^.+\\.vue$': 'vue-jest'
  }
}

这个坑也是新手必踩的,配置好了之后.vue文件就能正常被Jest解析了。

坑3:CSS和静态资源导入报错

组件里如果import了CSS文件或者图片等静态资源,Jest跑测试的时候会报错,因为Jest不认识这些非JS文件。解决方法是用moduleNameMapper把这些文件mock掉:

module.exports = {
  moduleNameMapper: {
    '\\.(css|less|scss)$': '<rootDir>/tests/unit/styleMock.js',
    '\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/tests/unit/fileMock.js'
  }
}

styleMock.js里就导出一个空对象,fileMock.js里导出一个测试文件名。这样Jest遇到CSS和图片的时候,就会用mock代替,不会报错了。

二、组件挂载的坑

坑4:shallowMount和mount搞不清

Vue Test Utils提供了两种挂载方式:mount和shallowMount。刚开始用的时候搞不清区别,一律用mount,结果测试一个组件的时候,它的所有子组件也都被渲染了,测试变得又慢又复杂,子组件的行为还会干扰父组件的测试。

后来才明白:mount会完整渲染组件及其所有子组件,适合集成测试;shallowMount只渲染当前组件,子组件会被stub掉,适合单元测试。大部分组件单元测试应该用shallowMount,这样测试更纯粹、更快、更稳定,不会被子组件干扰。只有在测试组件和子组件的交互、做集成测试的时候,才用mount。

坑5:props传了但组件没收到

有时候挂载组件的时候传了props,但组件里就是收不到,或者收到的是undefined。查了半天发现,是propsData的写法不对。在Vue Test Utils里,传props要用propsData,不是props:

// 错误写法
const wrapper = shallowMount(Component, {
  props: { title: 'test' }
})

// 正确写法
const wrapper = shallowMount(Component, {
  propsData: { title: 'test' }
})

这个坑很隐蔽,因为Vue组件里props是关键字,很容易顺手就写成props了,但Vue Test Utils挂载选项里传props要用propsData,写错了不会报错,只是组件收不到props,排查起来很头疼。

坑6:组件里用了this.$router/$route,挂载报错

如果组件里用到了this.$router或者this.$route,直接挂载会报错,说$router或者$route是undefined。因为测试环境里没有Vue Router的实例。解决方法是用mocks选项mock掉$router和$route:

const wrapper = shallowMount(Component, {
  mocks: {
    $router: { push: jest.fn() },
    $route: { path: '/test', query: { id: '1' } }
  }
})

如果组件里还用到了$store(Vuex)、$emit(这个不用mock,默认有)、$refs、$nextTick等,也要根据情况mock或者处理。mocks是Vue Test Utils里非常常用的功能,几乎所有用到Vue插件的组件都需要mocks。

三、异步和状态更新的坑

坑7:修改了data,但DOM没更新

这是最常见的坑之一。在测试里修改了组件的data或者props,然后去断言DOM内容,结果发现DOM还是旧的,断言失败。原因是Vue的DOM更新是异步的,修改data之后,DOM不会立刻更新,要等下一个tick。

解决方法是用async/await配合wrapper.vm.$nextTick():

test('updates DOM when data changes', async () => {
  const wrapper = shallowMount(Component)
  wrapper.setData({ count: 1 })
  await wrapper.vm.$nextTick()
  expect(wrapper.text()).toContain('1')
})

或者用await wrapper.setData(),setData本身返回Promise,await之后DOM就更新了。总之,只要涉及到状态修改后的DOM断言,一定要记得await nextTick,否则断言的是旧的DOM。

坑8:触发了事件,但回调没执行

有时候用wrapper.trigger('click')触发了点击事件,但组件里的点击回调好像没执行,状态没变化。原因可能有两个:一是事件触发后状态更新是异步的,需要await nextTick;二是事件绑定在子元素上,trigger要在对应的元素上触发,而不是在根元素上。

解决方法:

// 触发后await nextTick
await wrapper.find('.btn').trigger('click')
await wrapper.vm.$nextTick()

// 在正确的元素上触发事件
wrapper.find('.submit-btn').trigger('click') // 对
wrapper.trigger('click') // 错,根元素上可能没绑点击事件

另外,如果事件是用v-on:click.native绑定在子组件上的,trigger可能不生效,因为shallowMount把子组件stub了,native事件可能不会触发。这种情况需要用mount,或者手动调用组件的方法。

坑9:setProps后组件没反应

用setProps修改props后,有时候组件好像没反应,DOM没更新。原因和修改data一样,props更新也是异步的,需要await nextTick。另外,如果props是对象或者数组,setProps的时候要传新的引用,不能只修改对象的属性,因为Vue的响应式系统检测不到对象属性的修改(Vue 2的限制)。

解决方法:

// 正确:传新的对象引用
await wrapper.setProps({ user: { ...wrapper.props('user'), name: 'new' } })

// 错误:只修改属性
wrapper.props('user').name = 'new' // 不会触发更新

这个坑是Vue 2响应式系统的老问题了,在测试里也会遇到,记住修改对象和数组的时候要传新引用。

四、Mock和依赖的坑

坑10:mock了axios,但请求还是发出去了

组件里用了axios发请求,测试的时候mock了axios,但发现请求还是发出去了,或者mock不生效。原因可能是mock的方式不对,或者组件里import axios的方式和mock的方式不匹配。

如果组件里是import axios from 'axios',可以用jest.mock('axios')来mock:

import axios from 'axios'
jest.mock('axios')
axios.get.mockResolvedValue({ data: { list: [] } })

如果组件里是用了封装好的request模块(比如@/utils/request),那就要mock这个模块,而不是mock axios本身,因为组件依赖的是封装后的模块。mock的路径要和组件里import的路径完全一致,否则mock不生效。

坑11:Vuex的store怎么mock

如果组件里用到了Vuex的mapState、mapGetters、mapActions,测试的时候需要mock store。有两种方式:一是用mocks.mock掉$store,二是创建一个真实的store实例传进去。

简单的场景用mocks就够了:

const wrapper = shallowMount(Component, {
  mocks: {
    $store: {
      state: { user: { name: 'test' } },
      getters: { isAdmin: true },
      commit: jest.fn(),
      dispatch: jest.fn()
    }
  }
})

如果组件和store的交互比较复杂,或者测试需要验证store的状态变化,可以创建真实的store:

import Vuex from 'vuex'
import { createLocalVue } from '@vue/test-utils'

const localVue = createLocalVue()
localVue.use(Vuex)
const store = new Vuex.Store({
  state: { count: 0 },
  mutations: { increment: state => state.count++ }
})
const wrapper = shallowMount(Component, { localVue, store })

用localVue是为了不污染全局Vue,每个测试用例创建独立的Vue实例和store,避免测试之间互相干扰。

坑12:第三方组件无法stub

用shallowMount的时候,子组件会被自动stub,但有些第三方组件(比如Element UI的组件)stub之后行为不对,导致测试失败。比如el-input,stub之后v-model不生效,触发input事件也没反应。

解决方法是用stubs选项指定哪些组件不stub,或者用mount完整渲染:

// 指定某些组件不stub
const wrapper = shallowMount(Component, {
  stubs: {
    'el-input': false // false表示不stub,真实渲染
  }
})

或者直接用mount,让所有子组件真实渲染。但mount会让测试变慢变复杂,要权衡。对于依赖第三方组件行为的测试,只能用mount或者手动stub成行为类似的组件。

五、快照测试的坑

坑13:快照总是失败,更新太频繁

快照测试(snapshot)看起来很美好,自动对比组件渲染结果,但实际用起来发现快照总是失败,因为只要组件的任何一点渲染变化(比如加了个class、改了个文本、子组件变了),快照就会失败,需要频繁更新快照,最后大家都不管了,直接--updateSnapshot,快照测试就形同虚设了。

我的建议是:不要对复杂组件做快照测试,只对简单的、渲染结果稳定的纯展示组件做快照。复杂组件的行为用具体的断言来验证,而不是靠快照。另外,shallowMount配合快照比较合适,因为子组件被stub了,渲染结果更稳定,不会因子组件变化而频繁失败。

坑14:快照里有乱七八糟的内容

有时候快照里会出现一些莫名其妙的内容,比如undefined、[object Object]、或者很长的函数源码。原因通常是组件里渲染了未定义的变量,或者把对象/函数直接渲染到了模板里。这其实是组件本身的bug,快照测试帮你发现了问题。解决方法是检查组件模板,确保渲染的变量都有定义,对象和数组要访问具体属性而不是直接渲染。

六、一些实用建议

踩了这么多坑,总结一些实用建议:

  1. 从简单组件开始写测试。不要一上来就给最复杂的业务组件写测试,先从纯展示组件、工具函数开始,熟悉API和测试思路,再逐步覆盖复杂组件。
  1. 优先测试行为,而不是实现细节。测试应该关注组件的行为(输入什么、输出什么、用户交互后发生了什么),而不是组件内部的实现细节(比如data里有什么变量、methods里有什么函数)。测试行为的测试更稳定,重构组件的时候不容易挂。
  1. 善用Vue Test Utils的API。setData、setProps、trigger、find、text、html、attributes、emitted,这些API要熟练掌握,能大大提高写测试的效率。尤其是emitted,可以验证组件是否触发了正确的事件和参数,非常实用。
  1. 测试用例要独立。每个测试用例之间不要有依赖,不要共享状态。用beforeEach创建新的wrapper,用afterEach销毁wrapper,避免测试之间互相干扰。
  1. 不要追求100%覆盖率。单元测试不是覆盖率越高越好,有些代码(比如模板里的简单绑定、第三方库的调用)测试价值不高,硬写测试反而增加维护成本。重点覆盖核心业务逻辑、复杂交互、边界条件,这些地方最容易出bug,测试价值最高。
  1. 测试也是代码,要维护。测试代码和业务代码一样,需要review、重构、维护。不要写一次性的测试,写完就不管了。业务代码变更的时候,对应的测试也要同步更新,保持测试的有效性。

七、写在最后

Vue Test Utils是Vue单元测试的官方工具,功能很强大,但刚开始用的时候确实容易踩坑,尤其是从没有写过测试的前端开发者,会觉得测试又难写又没用。但坚持写下来,会发现测试带来的价值很大:重构的时候有底气,改bug的时候能验证,新人接手的时候能通过测试理解组件行为。

这篇文章整理了我在实际项目中踩过的一些坑,可能不够全面,但都是真实遇到的问题,希望能给同样在做Vue单元测试的朋友一些参考。测试这件事,入门难,但一旦养成习惯,就会发现它是保证代码质量的重要手段,值得投入时间去学习和实践。

最后想说,前端单元测试在国内还不是很普及,很多项目都没有测试,或者测试覆盖率很低。但随着前端工程化的发展,项目越来越复杂,测试的重要性会越来越凸显。早点开始写测试,早点养成测试驱动开发的习惯,对个人技术成长和项目质量都有好处。希望越来越多的前端开发者能重视测试,写出更健壮、更可维护的代码。