教程

起步

安装

快速尝鲜 Vue Test Utils 的办法就是克隆我们的 demo 仓库再加上基本的设置和依赖安装。

git clone https://github.com/vuejs/vue-test-utils-getting-started
cd vue-test-utils-getting-started
npm install

如果你已经有一个通过 Vue CLI 创建的工程并想支持其测试,则可以运行:

# unit testing
vue add @vue/unit-jest

# or:
vue add @vue/unit-mocha

# end-to-end
vue add @vue/e2e-cypress

# or:
vue add @vue/e2e-nightwatch

你会发现该工程包含了一个简单的组件 counter.js

// counter.js

export default {
  template: `
    <div>
      <span class="count">{{ count }}</span>
      <button @click="increment">Increment</button>
    </div>
  `,

  data() {
    return {
      count: 0
    }
  },

  methods: {
    increment() {
      this.count++
    }
  }
}

挂载组件

Vue Test Utils 通过将它们隔离挂载,然后模拟必要的输入 (prop、注入和用户事件) 和对输出 (渲染结果、触发的自定义事件) 的断言来测试 Vue 组件。

被挂载的组件会返回到一个包裹器内,而包裹器会暴露很多封装、遍历和查询其内部的 Vue 组件实例的便捷的方法。

你可以通过 mount 方法来创建包裹器。让我们创建一个名叫 test.js 的文件:

// test.js

// 从测试实用工具集中导入 `mount()` 方法
// 同时导入你要测试的组件
import { mount } from '@vue/test-utils'
import Counter from './counter'

// 现在挂载组件,你便得到了这个包裹器
const wrapper = mount(Counter)

// 你可以通过 `wrapper.vm` 访问实际的 Vue 实例
const vm = wrapper.vm

// 在控制台将其记录下来即可深度审阅包裹器
// 我们对 Vue Test Utils 的探索也由此开始
console.log(wrapper)

测试组件渲染出来的 HTML

现在我们已经有了这个包裹器,我们能做的第一件事就是认证该组件渲染出来的 HTML 符合预期。

import { mount } from '@vue/test-utils'
import Counter from './counter'

describe('Counter', () => {
  // 现在挂载组件,你便得到了这个包裹器
  const wrapper = mount(Counter)

  it('renders the correct markup', () => {
    expect(wrapper.html()).toContain('<span class="count">0</span>')
  })

  // 也便于检查已存在的元素
  it('has a button', () => {
    expect(wrapper.contains('button')).toBe(true)
  })
})

现在运行 npm test 进行测试。你应该看得到测试通过。

模拟用户交互

当用户点击按钮的时候,我们的计数器应该递增。为了模拟这一行为,我们首先需要通过 wrapper.find() 定位该按钮,此方法返回一个该按钮元素的包裹器。然后我们能够通过对该按钮包裹器调用 .trigger() 来模拟点击。

it('button click should increment the count', () => {
  expect(wrapper.vm.count).toBe(0)
  const button = wrapper.find('button')
  button.trigger('click')
  expect(wrapper.vm.count).toBe(1)
})

为了测试计数器中的文本是否已经更新,我们需要了解 nextTick

使用 nextTick 与 await

任何导致操作 DOM 的改变都应该在断言之前 await nextTick 函数。因为 Vue 会对未生效的 DOM 进行批量异步更新,避免因数据反复变化而导致不必要的渲染。

你可以阅读Vue 文档了解更多关于异步指更新的信息。

在更新响应式 property 之后,我们可以直接 await 类似 triggertrigger.vm.$nextTick 方法,等待 Vue 完成 DOM 更新。在这个计数器的示例中,设置 count property 会在运行下一个 tick 之后引发 DOM 变化。

我们看看如何在测试中撰写一个 async 函数并 await trigger()

it('button click should increment the count text', async () => {
  expect(wrapper.text()).toContain('0')
  const button = wrapper.find('button')
  await button.trigger('click')
  expect(wrapper.text()).toContain('1')
})

trigger 返回一个可以像上述示例一样被 await 或像普通 Promise 回调一样被 then 链式调用的 Promise。诸如 trigger 的方法内部仅仅返回 Vue.nextTick。你可以在测试异步组件了解更多。

当你在测试代码中使用 nextTick 时,请注意任何在其内部被抛出的错误可能都不会被测试运行器捕获,因为其内部使用了 Promise。关于这个问题有两个建议:要么你可以在测试的一开始将 Vue 的全局错误处理器设置为 done 回调,要么你可以在调用 nextTick 时不带参数让其作为一个 Promise 返回:

// 错误不会被捕获
it('will time out', done => {
  Vue.nextTick(() => {
    expect(true).toBe(false)
    done()
  })
})

// 接下来的三项测试都会如预期工作
it('will catch the error using done', done => {
  Vue.config.errorHandler = done
  Vue.nextTick(() => {
    expect(true).toBe(false)
    done()
  })
})

it('will catch the error using a promise', () => {
  return Vue.nextTick().then(function() {
    expect(true).toBe(false)
  })
})

it('will catch the error using async/await', async () => {
  await Vue.nextTick()
  expect(true).toBe(false)
})

接下来

常用技巧

明白要测试的是什么

对于 UI 组件来说,我们不推荐一味追求行级覆盖率,因为它会导致我们过分关注组件的内部实现细节,从而导致琐碎的测试。

取而代之的是,我们推荐把测试撰写为断言你的组件的公共接口,并在一个黑盒内部处理它。一个简单的测试用例将会断言一些输入 (用户的交互或 prop 的改变) 提供给某组件之后是否导致预期结果 (渲染结果或触发自定义事件)。

比如,对于每次点击按钮都会将计数加一的 Counter 组件来说,其测试用例将会模拟点击并断言渲染结果会加 1。该测试并没有关注 Counter 如何递增数值,而只关注其输入和输出。

该提议的好处在于,即便该组件的内部实现已经随时间发生了改变,只要你的组件的公共接口始终保持一致,测试就可以通过。

这个话题的细节在 Matt O'Connell 一份非常棒的演讲中有更多的讨论。

浅渲染

在测试用例中,我们通常希望专注在一个孤立的单元中测试组件,避免对其子组件的行为进行间接的断言。

额外的,对于包含许多子组件的组件来说,整个渲染树可能会非常大。重复渲染所有的子组件可能会让我们的测试变慢。

Vue Test Utils 允许你通过 shallowMount 方法只挂载一个组件而不渲染其子组件 (即保留它们的存根):

import { shallowMount } from '@vue/test-utils'

const wrapper = shallowMount(Component)
wrapper.vm // 挂载的 Vue 实例

生命周期钩子

在使用 mountshallowMount 方法时,你可以期望你的组件响应 Vue 所有生命周期事件。但是请务必注意的是,除非使用 Wrapper.destory(),否则 beforeDestroydestroyed 将不会触发

此外组件在每个测试规范结束时并不会被自动销毁,并且将由用户来决定是否要存根或手动清理那些在测试规范结束前继续运行的任务 (例如 setInterval 或者 setTimeout)。

使用 nextTick 编写异步测试代码 (新)

默认情况下 Vue 会异步地批量执行更新 (在下一轮 tick),以避免不必要的 DOM 重绘或者是观察者计算 (查看文档 了解更多信息)。

这意味着你在更新会引发 DOM 变化的属性后必须等待一下。你可以使用 Vue.nextTick()

it('updates text', async () => {
  const wrapper = mount(Component)
  await wrapper.trigger('click')
  expect(wrapper.text()).toContain('updated')
  await wrapper.trigger('click')
  wrapper.text().toContain('some different text')
})

// 或者你不希望使用 async/await
it('render text', done => {
  const wrapper = mount(TestComponent)
  wrapper.trigger('click').then(() => {
    wrapper.text().toContain('updated')
    wrapper.trigger('click').then(() => {
      wrapper.text().toContain('some different text')
      done()
    })
  })
})

可以在测试异步行为了解更多。

断言触发的事件

每个挂载的包裹器都会通过其背后的 Vue 实例自动记录所有被触发的事件。你可以用 wrapper.emitted() 方法取回这些事件记录。

wrapper.vm.$emit('foo')
wrapper.vm.$emit('foo', 123)

/*
`wrapper.emitted()` 返回以下对象:
{
  foo: [[], [123]]
}
*/

然后你可以基于这些数据来设置断言:

// 断言事件已经被触发
expect(wrapper.emitted().foo).toBeTruthy()

// 断言事件的数量
expect(wrapper.emitted().foo.length).toBe(2)

// 断言事件的有效数据
expect(wrapper.emitted().foo[1]).toEqual([123])

你也可以调用 wrapper.emittedByOrder() 获取一个按触发先后排序的事件数组。

从子组件触发事件

你可以通过访问子组件实例来触发一个自定义事件

待测试的组件

<template>
  <div>
    <child-component @custom="onCustom" />
    <p v-if="emitted">Emitted!</p>
  </div>
</template>

<script>
  import ChildComponent from './ChildComponent'

  export default {
    name: 'ParentComponent',
    components: { ChildComponent },
    data() {
      return {
        emitted: false
      }
    },
    methods: {
      onCustom() {
        this.emitted = true
      }
    }
  }
</script>

测试代码

import { mount } from '@vue/test-utils'
import ParentComponent from '@/components/ParentComponent'
import ChildComponent from '@/components/ChildComponent'

describe('ParentComponent', () => {
  it("displays 'Emitted!' when custom event is emitted", () => {
    const wrapper = mount(ParentComponent)
    wrapper.find(ChildComponent).vm.$emit('custom')
    expect(wrapper.html()).toContain('Emitted!')
  })
})

操作组件状态

你可以在包裹器上用 setDatasetProps 方法直接操作组件状态:

wrapper.setData({ count: 10 })

wrapper.setProps({ foo: 'bar' })

仿造 Prop

你可以使用 Vue 在内置 propsData 选项向组件传入 prop:

import { mount } from '@vue/test-utils'

mount(Component, {
  propsData: {
    aProp: 'some value'
  }
})

你也可以用 wrapper.setProps({}) 方法更新这些已经挂载的组件的 prop:

想查阅所有选项的完整列表,请移步该文档的挂载选项章节。

仿造 Transitions

尽管在大多数情况下使用 await Vue.nextTick() 效果很好,但是在某些情况下还需要一些额外的工作。这些问题将在 vue-test-utils 移出 beta 版本之前解决。其中一个例子是 Vue 提供的带有 <transition> 包装器的单元测试组件。

<template>
  <div>
    <transition>
      <p v-if="show">Foo</p>
    </transition>
  </div>
</template>

<script>
export default {
  data() {
    return {
      show: true
    }
  }
}
</script>

您可能想编写一个测试用例来验证是否显示了文本 Foo ,在将 show 设置为 false 时,不再显示文本 Foo。测试用例可以这么写:

test('should render Foo, then hide it', async () => {
  const wrapper = mount(Foo)
  expect(wrapper.text()).toMatch(/Foo/)

  wrapper.setData({
    show: false
  })
  await wrapper.vm.$nextTick()

  expect(wrapper.text()).not.toMatch(/Foo/)
})

实际上,尽管我们调用了 setData 方法,然后等待 nextTick 来确保 DOM 被更新,但是该测试用例仍然失败了。这是一个已知的问题,与 Vue 中 <transition> 组件的实现有关,我们希望在 1.0 版之前解决该问题。在目前情况下,有一些解决方案:

使用 transitionStub

const transitionStub = () => ({
  render: function(h) {
    return this.$options._renderChildren
  }
})

test('should render Foo, then hide it', async () => {
  const wrapper = mount(Foo, {
    stubs: {
      transition: transitionStub()
    }
  })
  expect(wrapper.text()).toMatch(/Foo/)

  wrapper.setData({
    show: false
  })
  await wrapper.vm.$nextTick()

  expect(wrapper.text()).not.toMatch(/Foo/)
})

上面的代码重写了 <transition> 组件的默认行为,并在在条件发生变化时立即呈现子元素。这与 Vue 中 <transition> 组件应用 CSS 类的实现是相反的。

避免 setData

另一种选择是通过编写两个测试来简单地避免使用 setData,这要求我们在使用 mount 或者 shallowMount 时需要指定一些选项:

test('should render Foo', async () => {
  const wrapper = mount(Foo, {
    data() {
      return {
        show: true
      }
    }
  })

  expect(wrapper.text()).toMatch(/Foo/)
})

test('should not render Foo', async () => {
  const wrapper = mount(Foo, {
    data() {
      return {
        show: false
      }
    }
  })

  expect(wrapper.text()).not.toMatch(/Foo/)
})

应用全局的插件和混入

有些组件可能依赖一个全局插件或混入 (mixin) 的功能注入,比如 vuexvue-router

如果你在为一个特定的应用撰写组件,你可以在你的测试入口处一次性设置相同的全局插件和混入。但是有些情况下,比如测试一个可能会跨越不同应用共享的普通的组件套件的时候,最好还是在一个更加隔离的设置中测试你的组件,不对全局的 Vue 构造函数注入任何东西。我们可以使用 createLocalVue 方法来存档它们:

import { createLocalVue, mount } from '@vue/test-utils'

// 创建一个扩展的 `Vue` 构造函数
const localVue = createLocalVue()

// 正常安装插件
localVue.use(MyPlugin)

// 在挂载选项中传入 `localVue`
mount(Component, {
  localVue
})

注意有些插件会为全局的 Vue 构造函数添加只读属性,比如 Vue Router。这使得我们无法在一个 localVue 构造函数上二次安装该插件,或伪造这些只读属性。

仿造注入

另一个注入 prop 的策略就是简单的仿造它们。你可以使用 mocks 选项:

import { mount } from '@vue/test-utils'

const $route = {
  path: '/',
  hash: '',
  params: { id: '123' },
  query: { q: 'hello' }
}

mount(Component, {
  mocks: {
    // 在挂载组件之前
    // 添加仿造的 `$route` 对象到 Vue 实例中
    $route
  }
})

存根组件

你可以使用 stubs 选项覆写全局或局部注册的组件:

import { mount } from '@vue/test-utils'

mount(Component, {
  // 将会把 globally-registered-component 解析为
  // 空的存根
  stubs: ['globally-registered-component']
})

处理路由

因为路由需要在应用的全局结构中进行定义,且引入了很多组件,所以最好集成到 end-to-end 测试。对于依赖 vue-router 功能的独立的组件来说,你可以使用上面提到的技术仿造它们。

探测样式

当你的测试运行在 jsdom 中时,只能探测到内联样式。

测试键盘、鼠标等其它 DOM 事件

触发事件

Wrapper 暴露了一个 trigger 方法。它可以用来触发 DOM 事件。

const wrapper = mount(MyButton)

wrapper.trigger('click')

你应该注意到了,find 方法也会返回一个 Wrapper。假设 MyComponent 包含一个按钮,下面的代码会点击这个按钮。

const wrapper = mount(MyComponent)

wrapper.find('button').trigger('click')

选项

trigger 方法接受一个可选的 options 对象。这个 options 对象里的属性会被添加到事件中。

注意其目标不能被添加到 options 对象中。

const wrapper = mount(MyButton)

wrapper.trigger('click', { button: 0 })

鼠标点击示例

待测试的组件

<template>
  <div>
    <button class="yes" @click="callYes">Yes</button>
    <button class="no" @click="callNo">No</button>
  </div>
</template>

<script>
  export default {
    name: 'YesNoComponent',

    props: {
      callMe: {
        type: Function
      }
    },

    methods: {
      callYes() {
        this.callMe('yes')
      },
      callNo() {
        this.callMe('no')
      }
    }
  }
</script>

测试

import YesNoComponent from '@/components/YesNoComponent'
import { mount } from '@vue/test-utils'
import sinon from 'sinon'

describe('Click event', () => {
  it('Click on yes button calls our method with argument "yes"', () => {
    const spy = sinon.spy()
    const wrapper = mount(YesNoComponent, {
      propsData: {
        callMe: spy
      }
    })
    wrapper.find('button.yes').trigger('click')

    spy.should.have.been.calledWith('yes')
  })
})

键盘示例

待测试的组件

这个组件允许使用不同的按键将数量递增/递减。

<template>
  <input type="text" @keydown.prevent="onKeydown" v-model="quantity" />
</template>

<script>
  const KEY_DOWN = 40
  const KEY_UP = 38
  const ESCAPE = 27

  export default {
    data() {
      return {
        quantity: 0
      }
    },

    methods: {
      increment() {
        this.quantity += 1
      },
      decrement() {
        this.quantity -= 1
      },
      clear() {
        this.quantity = 0
      },
      onKeydown(e) {
        if (e.keyCode === ESCAPE) {
          this.clear()
        }
        if (e.keyCode === KEY_DOWN) {
          this.decrement()
        }
        if (e.keyCode === KEY_UP) {
          this.increment()
        }
        if (e.key === 'a') {
          this.quantity = 13
        }
      }
    },

    watch: {
      quantity: function(newValue) {
        this.$emit('input', newValue)
      }
    }
  }
</script>

Test

import QuantityComponent from '@/components/QuantityComponent'
import { mount } from '@vue/test-utils'

describe('Key event tests', () => {
  it('Quantity is zero by default', () => {
    const wrapper = mount(QuantityComponent)
    expect(wrapper.vm.quantity).toBe(0)
  })

  it('Up arrow key increments quantity by 1', () => {
    const wrapper = mount(QuantityComponent)
    wrapper.trigger('keydown.up')
    expect(wrapper.vm.quantity).toBe(1)
  })

  it('Down arrow key decrements quantity by 1', () => {
    const wrapper = mount(QuantityComponent)
    wrapper.vm.quantity = 5
    wrapper.trigger('keydown.down')
    expect(wrapper.vm.quantity).toBe(4)
  })

  it('Escape sets quantity to 0', () => {
    const wrapper = mount(QuantityComponent)
    wrapper.vm.quantity = 5
    wrapper.trigger('keydown.esc')
    expect(wrapper.vm.quantity).toBe(0)
  })

  it('Magic character "a" sets quantity to 13', () => {
    const wrapper = mount(QuantityComponent)
    wrapper.trigger('keydown', {
      key: 'a'
    })
    expect(wrapper.vm.quantity).toBe(13)
  })
})

限制

点后面的按键名 keydown.up 会被翻译成一个 keyCode。这些被支持的按键名有:

key name key code
enter 13
esc 27
tab 9
space 32
delete 46
backspace 8
insert 45
up 38
down 40
left 37
right 39
end 35
home 36
pageup 33
pagedown 34

测试异步行为

在编写测试代码时你将会遇到两种异步行为:

  1. 来自 Vue 的更新
  2. 来自外部行为的更新

来自 Vue 的更新

Vue 会异步的将未生效的 DOM 批量更新,避免因数据反复变化而导致不必要的渲染。

你可以阅读Vue 文档了解更多关于异步指更新的信息。

在实践中,这意味着变更一个响应式 property 之后,为了断言这个变化,你的测试需要等待 Vue 完成更新。其中一种办法是使用 await Vue.nextTick(),一个更简单且清晰的方式则是 await 那个你变更状态的方法,例如 trigger

// 在测试框架中,编写一个测试用例
it('button click should increment the count text', async () => {
  expect(wrapper.text()).toContain('0')
  const button = wrapper.find('button')
  await button.trigger('click')
  expect(wrapper.text()).toContain('1')
})

和等待上述触发等价:

it('button click should increment the count text', async () => {
  expect(wrapper.text()).toContain('0')
  const button = wrapper.find('button')
  button.trigger('click')
  await Vue.nextTick()
  expect(wrapper.text()).toContain('1')
})

可以被 await 的方法有:

来自外部行为的更新

在 Vue 之外最常见的一种异步行为就是在 Vuex 中进行 API 调用。以下示例将展示如何测试在 Vuex 中进行 API 调用的方法。本示例使用 Jest 运行测试并模拟 HTTP 库axios。可以在这里找到有关 Jest Mock 的更多信息。

axios mock 的实现如下所示:

export default {
  get: () => Promise.resolve({ data: 'value' })
}

当按钮被点击时,组件将会产生一个 API 调用,并且将响应的返回内容赋值给 value

<template>
  <button @click="fetchResults">{{ value }}</button>
</template>

<script>
  import axios from 'axios'

  export default {
    data() {
      return {
        value: null
      }
    },

    methods: {
      async fetchResults() {
        const response = await axios.get('mock/service')
        this.value = response.data
      }
    }
  }
</script>

可以这样编写测试:

import { shallowMount } from '@vue/test-utils'
import Foo from './Foo'
jest.mock('axios', () => ({
  get: Promise.resolve('value')
}))

it('fetches async when a button is clicked', () => {
  const wrapper = shallowMount(Foo)
  wrapper.find('button').trigger('click')
  expect(wrapper.text()).toBe('value')
})

上面的代码代码会执行失败,这是因为我们在 fetchResults 方法执行完毕前就对结果进行断言。绝大多数单元测试框架都会提供一个回调来通知你测试将在何时完成。Jest 和 Mocha 都使用done 这个方法。我们可以将 done$nextTicksetTimeout 结合使用,以确保在进行断言前已经处理完所有的 Promise 回调。

it('fetches async when a button is clicked', done => {
  const wrapper = shallowMount(Foo)
  wrapper.find('button').trigger('click')
  wrapper.vm.$nextTick(() => {
    expect(wrapper.text()).toBe('value')
    done()
  })
})

setTimeout 也可以使测试通过的原因是,Promise 回调的微任务队列会排在 setTimeout 回调的微任务队列之前。这意味着当 setTimeout 回调执行时,微任务队列上的所有 Promise 回调已经被执行过了。另一方面,$nextTick 也存在调度微任务的情况,但是由于微任务队列是先进先出的,因此也保证了在进行断言时已经处理完所有的 Promise 回调。请参阅此处了解更多详细说明。

另外一个使用 async 方法的解决方案是使用类似 flush-promises 的包。flush-promises 会刷新所有处于 pending 状态或 resolved 状态的 Promise。你可以用 await 语句来等待 flushPromises 刷新 Promise 的状态,这样可以提升你代码的可读性。

修改以后的测试代码:

import { shallowMount } from '@vue/test-utils'
import flushPromises from 'flush-promises'
import Foo from './Foo'
jest.mock('axios')

it('fetches async when a button is clicked', async () => {
  const wrapper = shallowMount(Foo)
  wrapper.find('button').trigger('click')
  await flushPromises()
  expect(wrapper.text()).toBe('value')
})

相同的技术细节也可以应用在处理 Vuex 的 action 上,默认情况下,它也会返回一个 Promise。

为什么不使用 await button.trigger()

如之前所解释的,Vue 更新其组件和完成其 Promise 对象的时机不同,如 axios 解析出的那个。

一个易于遵循的规则是在诸如 triggersetProps 的变更时始终使用 await。如果你的代码依赖一些诸如 axios 的异步操作,也要为 flushPromises 加入一个 await。

配合 TypeScript 使用

我们在 GitHub 上放有一个关于这些设置的示例工程。

TypeScript 是一个流行的 JavaScript 超集,它在普通 JS 的基础上加入了类型 (type) 和类 (class)。Vue Test Utils 在发行包中包含了类型信息,因此它可以很好的和 TypeScript 配合使用。

在这份指南中,我们会介绍如何基于 Vue CLI 的 TypeScript 设置,使用 Jest 和 Vue Test Utils 为一个 TypeScript 工程建立测试。

加入 TypeScript

首先,你需要创建一个工程。如果你还没有安装 Vue CLI 的话,请先全局安装:

$ npm install -g @vue/cli

然后通过下列命令创建一个工程:

$ vue create hello-world

在 CLI 提示符中,选择 Manually select features (手动选择特性),并选中 TypeScript 回车。这将会创建一个配置好 TypeScript 的工程。

注意

如果你想要了解更多用 TypeScript 设置 Vue 的细节,请移步 TypeScript Vue starter guide

下一步就是把 Jest 添加到工程中。

设置 Jest

Jest 是一个由 Facebook 研发的测试运行器,它致力于提供一个低能耗的测试解决方案。你可以在其官方文档中了解更多 Jest 的情况。

安装 Jest 和 Vue Test Utils:

$ npm install --save-dev jest @vue/test-utils

接下来在 package.json 里定义一个 test:unit 脚本。

// package.json
{
  // ..
  "scripts": {
    // ..
    "test:unit": "jest"
  }
  // ..
}

在 Jest 中执行单文件组件

为了讲解 Jest 如何处理 *.vue 文件,我们需要安装并配置 vue-jest 预处理器:

npm install --save-dev vue-jest

然后在 package.json 里创建一个 jest 块:

{
  // ...
  "jest": {
    "moduleFileExtensions": [
      "js",
      "ts",
      "json",
      // 告诉 Jest 处理 `*.vue` 文件
      "vue"
    ],
    "transform": {
      // 用 `vue-jest` 处理 `*.vue` 文件
      ".*\\.(vue)$": "vue-jest"
    },
    "testURL": "http://localhost/"
  }
}

为 Jest 配置 TypeScript

为了在测试中使用 TypeScript 文件,我们需要在 Jest 中设置编译 TypeScript。为此我们需要安装 ts-jest

$ npm install --save-dev ts-jest

接下来,我们需要在 package.json 中的 jest.transform 中加入一个入口告诉 Jest 使用 ts-jest 处理 TypeScript 测试文件:

{
  // ...
  "jest": {
    // ...
    "transform": {
      // ...
      // 用 `ts-jest` 处理 `*.ts` 文件
      "^.+\\.tsx?$": "ts-jest"
    }
    // ...
  }
}

放置测试文件

默认情况下,Jest 将会在整个工程里递归地找到所有的 .spec.js.test.js 扩展名文件。

我们需要改变 package.json 文件里的 testRegex 配置项以运行 .ts 扩展名的测试文件。

package.json 中添加以下 jest 字段:

{
  // ...
  "jest": {
    // ...
    "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$"
  }
}

Jest 推荐我们在被测试的代码旁边创建一个 __tests__ 目录,但你完全可以根据自己的喜好组织你的测试文件。只是要注意 Jest 会在进行截图测试的时候在测试文件旁边创建一个 __snapshots__ 目录。

撰写一个单元测试

现在我们已经把工程设置好了,是时候撰写一个单元测试了。

创建一个 src/components/__tests__/HelloWorld.spec.ts 文件,并加入如下代码:

// src/components/__tests__/HelloWorld.spec.ts
import { shallowMount } from '@vue/test-utils'
import HelloWorld from '../HelloWorld.vue'

describe('HelloWorld.vue', () => {
  test('renders props.msg when passed', () => {
    const msg = 'new message'
    const wrapper = shallowMount(HelloWorld, {
      propsData: { msg }
    })
    expect(wrapper.text()).toMatch(msg)
  })
})

这就是我们让 TypeScript 和 Vue Test Utils 一起工作所需要的全部工作!

相关资料

配合 Vue Router 使用

在测试中安装 Vue Router

在测试中,你应该杜绝在基本的 Vue 构造函数中安装 Vue Router。安装 Vue Router 之后 Vue 的原型上会增加 $route$router 这两个只读属性。

为了避免这样的事情发生,我们创建了一个 localVue 并对其安装 Vue Router。

import { shallowMount, createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router'

const localVue = createLocalVue()
localVue.use(VueRouter)
const router = new VueRouter()

shallowMount(Component, {
  localVue,
  router
})

**注意:**在一个 localVue 上安装 Vue Router 时也会将 $route$router 作为两个只读属性添加给该 localVue。这意味着如果你使用安装了 Vue Router 的 localVue,则不能在挂载一个组件时使用 mocks 选项来覆写 $route$router

当你安装 Vue Router 的时候,router-linkrouter-view 组件就被注册了。这意味着我们无需再导入可以在应用的任意地方使用它们。

当我们运行测试的时候,需要令 Vue Router 相关组件在我们挂载的组件中可用。有以下两种做法:

使用存根

import { shallowMount } from '@vue/test-utils'

shallowMount(Component, {
  stubs: ['router-link', 'router-view']
})

为 localVue 安装 Vue Router

import { shallowMount, createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router'

const localVue = createLocalVue()
localVue.use(VueRouter)

shallowMount(Component, {
  localVue
})

伪造 $route$router

有的时候你想要测试一个组件在配合 $route$router 对象的参数时的行为。这时候你可以传递自定义假数据给 Vue 实例。

import { shallowMount } from '@vue/test-utils'

const $route = {
  path: '/some/path'
}

const wrapper = shallowMount(Component, {
  mocks: {
    $route
  }
})

wrapper.vm.$route.path // /some/path

常识

安装 Vue Router 会在 Vue 的原型上添加 $route$router 只读属性。

这意味着在未来的任何测试中,伪造 $route$router 都会失效。

要想回避这个问题,就不要在运行测试的时候全局安装 Vue Router,而用上述的 localVue 用法。

配合 Vuex 使用

在本教程中,我们将会看到如何用 Vue Test Utils 测试组件中的 Vuex,以及如何测试一个 Vuex store。

在组件中测试 Vuex

伪造 Action

我们来看一些代码。

这是我们想要测试的组件。它会调用 Vuex action。

<template>
  <div class="text-align-center">
    <input type="text" @input="actionInputIfTrue" />
    <button @click="actionClick()">Click</button>
  </div>
</template>

<script>
  import { mapActions } from 'vuex'

  export default {
    methods: {
      ...mapActions(['actionClick']),
      actionInputIfTrue: function actionInputIfTrue(event) {
        const inputValue = event.target.value
        if (inputValue === 'input') {
          this.$store.dispatch('actionInput', { inputValue })
        }
      }
    }
  }
</script>

站在测试的角度,我们不关心这个 action 做了什么或者这个 store 是什么样子的。我们只需要知道这些 action 将会在适当的时机触发,以及它们触发时的预期值。

为了完成这个测试,我们需要在浅渲染组件时给 Vue 传递一个伪造的 store。

我们可以把 store 传递给一个 localVue,而不是传递给基础的 Vue 构造函数。localVue 是一个独立作用域的 Vue 构造函数,我们可以对其进行改动而不会影响到全局的 Vue 构造函数。

我们来看看它的样子:

import { shallowMount, createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'
import Actions from '../../../src/components/Actions'

const localVue = createLocalVue()

localVue.use(Vuex)

describe('Actions.vue', () => {
  let actions
  let store

  beforeEach(() => {
    actions = {
      actionClick: jest.fn(),
      actionInput: jest.fn()
    }
    store = new Vuex.Store({
      state: {},
      actions
    })
  })

  it('dispatches "actionInput" when input event value is "input"', () => {
    const wrapper = shallowMount(Actions, { store, localVue })
    const input = wrapper.find('input')
    input.element.value = 'input'
    input.trigger('input')
    expect(actions.actionInput).toHaveBeenCalled()
  })

  it('does not dispatch "actionInput" when event value is not "input"', () => {
    const wrapper = shallowMount(Actions, { store, localVue })
    const input = wrapper.find('input')
    input.element.value = 'not input'
    input.trigger('input')
    expect(actions.actionInput).not.toHaveBeenCalled()
  })

  it('calls store action "actionClick" when button is clicked', () => {
    const wrapper = shallowMount(Actions, { store, localVue })
    wrapper.find('button').trigger('click')
    expect(actions.actionClick).toHaveBeenCalled()
  })
})

这里发生了什么?首先我们用 localVue.use 方法告诉 Vue 使用 Vuex。这只是 Vue.use 的一个包裹器。

然后我们用 new Vuex.Store 伪造了一个 store 并填入假数据。我们只把它传递给 action,因为我们只关心这个。

该 action 是 Jest 伪造函数。这些伪造函数让我们去断言该 action 是否被调用。

然后我们可以在我们的测试中断言 action 存根是否如预期般被调用。

现在我们定义 store 的方式在你看来可能有点特别。

我们使用 beforeEach 来确认我们在每项测试之前已经拥有一个干净的 store。beforeEach 是一个 mocha 的钩子,会在每项测试之前被调用。我们在测试中会重新为 store 的变量赋值。如果我们没有这样做,伪造函数就需要被自动重置。它还需要我们改变测试中的 state,而不会影响后面的其它测试。

该测试中最重要的注意事项是:我们创建了一个伪造的 Vuex store 并将其传递给 Vue Test Utils

好的,现在我们可以伪造 action 了,我们再来看看伪造 getter。

伪造 Getter

<template>
  <div>
    <p v-if="inputValue">{{inputValue}}</p>
    <p v-if="clicks">{{clicks}}</p>
  </div>
</template>

<script>
  import { mapGetters } from 'vuex'

  export default {
    computed: mapGetters(['clicks', 'inputValue'])
  }
</script>

这是一个非常简单的组件。它根据 getter clicksinputValue 渲染结果。还是那句话,我们并不关注这些 getter 返回什么——只关注它们被正确的渲染。

让我们看看这个测试:

import { shallowMount, createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'
import Getters from '../../../src/components/Getters'

const localVue = createLocalVue()

localVue.use(Vuex)

describe('Getters.vue', () => {
  let getters
  let store

  beforeEach(() => {
    getters = {
      clicks: () => 2,
      inputValue: () => 'input'
    }

    store = new Vuex.Store({
      getters
    })
  })

  it('Renders "store.getters.inputValue" in first p tag', () => {
    const wrapper = shallowMount(Getters, { store, localVue })
    const p = wrapper.find('p')
    expect(p.text()).toBe(getters.inputValue())
  })

  it('Renders "store.getters.clicks" in second p tag', () => {
    const wrapper = shallowMount(Getters, { store, localVue })
    const p = wrapper.findAll('p').at(1)
    expect(p.text()).toBe(getters.clicks().toString())
  })
})

这个测试和我们的 action 测试很相似。我们在每个测试运行之前创建了一个伪造的 store,在我们调用 shallowMount 的时候将其以一个选项传递进去,并断言我们伪造的 getter 的返回值被渲染。

这非常好,但是如果我们想要检查我们的 getter 是否返回了正确的 state 的部分该怎么办呢?

伪造 Module

Module 对于将我们的 store 分隔成多个可管理的块来说非常有用。它们也暴露 getter。我们可以在测试中使用它们。

看看这个组件:

<template>
  <div>
    <button @click="moduleActionClick()">Click</button>
    <p>{{moduleClicks}}</p>
  </div>
</template>

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

  export default {
    methods: {
      ...mapActions(['moduleActionClick'])
    },

    computed: mapGetters(['moduleClicks'])
  }
</script>

简单的包含一个 action 和一个 getter 的组件。

其测试:

import { shallowMount, createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'
import MyComponent from '../../../src/components/MyComponent'
import myModule from '../../../src/store/myModule'

const localVue = createLocalVue()

localVue.use(Vuex)

describe('MyComponent.vue', () => {
  let actions
  let state
  let store

  beforeEach(() => {
    state = {
      clicks: 2
    }

    actions = {
      moduleActionClick: jest.fn()
    }

    store = new Vuex.Store({
      modules: {
        myModule: {
          state,
          actions,
          getters: myModule.getters
        }
      }
    })
  })

  it('calls store action "moduleActionClick" when button is clicked', () => {
    const wrapper = shallowMount(MyComponent, { store, localVue })
    const button = wrapper.find('button')
    button.trigger('click')
    expect(actions.moduleActionClick).toHaveBeenCalled()
  })

  it('renders "state.clicks" in first p tag', () => {
    const wrapper = shallowMount(MyComponent, { store, localVue })
    const p = wrapper.find('p')
    expect(p.text()).toBe(state.clicks.toString())
  })
})

测试一个 Vuex Store

这里有两个测试 Vuex store 的方式。第一个方式是分别单元化测试 getter、mutation 和 action。第二个方式是创建一个 store 并针对其进行测试。我们接下来看看这两种方式如何。

为了弄清楚如果测试一个 Vuex store,我们会创建一个简单的计数器 store。该 store 会有一个 increment mutation 和一个 evenOrOdd getter。

// mutations.js
export default {
  increment(state) {
    state.count++
  }
}
// getters.js
export default {
  evenOrOdd: state => (state.count % 2 === 0 ? 'even' : 'odd')
}

分别测试 getter、mutation 和 action

Getter、mutation 和 action 全部是 JavaScript 函数,所以我们可以不通过 Vue Test Utils 和 Vuex 测试它们。

分别测试 getter、mutation 和 action 的好处是你的单元测试是非常详细的。当它们失败时,你完全知道你代码的问题是什么。当然另外一方面你需要伪造诸如 commitdispatch 的 Vuex 函数。这会导致在一些情况下你伪造错了东西,导致单元测试通过,生产环境的代码却失败了。

我们会创建两个测试文件:mutations.spec.jsgetters.spec.js

首先,我们测试名为 increment 的 mutation:

// mutations.spec.js

import mutations from './mutations'

test('"increment" increments "state.count" by 1', () => {
  const state = {
    count: 0
  }
  mutations.increment(state)
  expect(state.count).toBe(1)
})

现在让我们测试 evenOrOdd getter。我们可以通过创建一个伪造的 state 来测试它,带上 state 调用这个 getter 并检查它是否返回正确的结果。

// getters.spec.js

import getters from './getters'

test('"evenOrOdd" returns even if "state.count" is even', () => {
  const state = {
    count: 2
  }
  expect(getters.evenOrOdd(state)).toBe('even')
})

test('"evenOrOdd" returns odd if "state.count" is odd', () => {
  const state = {
    count: 1
  }
  expect(getters.evenOrOdd(state)).toBe('odd')
})

测试一个运行中的 store

另一个测试 Vuex store 的方式就是使用 store 配置创建一个运行中的 store。

这样做的好处是我们不需要伪造任何 Vuex 函数。

另一方面当一个测试失败时,排查问题的难度会增加。

我们来写一个测试吧。当我们创建一个 store 时,我们会使用 localVue 来避免污染 Vue 的基础构造函数。该测试会使用 store-config.js 导出的配置创建一个 store:

// store-config.js

import mutations from './mutations'
import getters from './getters'

export default {
  state: {
    count: 0
  },
  mutations,
  getters
}
// store-config.spec.js

import { createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'
import storeConfig from './store-config'
import { cloneDeep } from 'lodash'

test('increments "count" value when "increment" is committed', () => {
  const localVue = createLocalVue()
  localVue.use(Vuex)
  const store = new Vuex.Store(cloneDeep(storeConfig))
  expect(store.state.count).toBe(0)
  store.commit('increment')
  expect(store.state.count).toBe(1)
})

test('updates "evenOrOdd" getter when "increment" is committed', () => {
  const localVue = createLocalVue()
  localVue.use(Vuex)
  const store = new Vuex.Store(cloneDeep(storeConfig))
  expect(store.getters.evenOrOdd).toBe('even')
  store.commit('increment')
  expect(store.getters.evenOrOdd).toBe('odd')
})

注意我们在创建一个 store 之前使用了 cloneDeep 来克隆 store 配置。这是因为 Vuex 会改变用来创建 store 的选项对象。为了确保我们能为每一个测试都提供一个干净的 store,我们需要克隆 storeConfig 对象。

然而,cloneDeep 不足以“deep”到克隆 store 的模块。如果你的 storeConfig 包含模块,你需要向 new Vue.Store() 传入一个对象,例如:

import myModule from './myModule'
// ...
const store = new Vuex.Store({ modules: { myModule: cloneDeep(myModule) } })

相关资料