Vue 测试速成班

5,119 阅读9分钟
  • 原文地址:dev.to/blacksonic/…
  • 原文作者:Gábor Soós
  • 译者:马雪琴
  • 声明:本翻译仅做学习交流使用,转载请注明来源

在你快要完成一个项目时,突然工程里的很多地方都出现了 bug,你修完一个又冒出新的一个,就像在玩打地鼠游戏一样……几轮下来,你会感到一团糟。此时有一个可以让你的项目再次发光的解救方案,那就是为将要开发的和已经存在的特性编写测试。编写测试可以保证功能特性没有 bug。

在本教程中,我将向你展示如何为 Vue 应用程序编写单元、集成和端到端测试。

有关更多测试示例,可以查看我的 Vue TodoApp 实现

1. 类型

我们可以编写三种类型的测试:单元测试、集成测试和端到端测试。下面这个金字塔可以帮助我们理解这些测试类型。

在金字塔下端的测试写起来更容易,运行起来更快,也更容易维护。但是,为什么我们不能只写单元测试呢?因为金字塔上端的测试可以帮助我们检查系统里的各个组件之间是否能很好地协同工作,使我们对系统更有把握。

单元测试只能被单独使用在单个代码单元(类、函数)里;集成测试可以检查多个单元是否能按预期协同工作(组件层次结构、组件 + 存储);端到端测试则是从外部世界观察应用程序:浏览器及其交互。

2. 测试运行器

对于新的 Vue 项目,添加测试的最简单方法是使用 Vue CLI。在生成项目(执行 vue create myapp)时,你必须手动选择单元测试和 E2E 测试。

安装完成后,package.json 中将出现下面几个附加依赖项:

  • @vue/cli-plugin-unit-mocha: 使用 Mocha 进行单元/集成测试的插件
  • @vue/test-utils: 单元/集成测试的工具库
  • chai: 断言库 Chai

从现在开始,单元/集成测试文件可以使用 *.spec.js 后缀写在 tests/unit 目录中。测试的目录不是硬连线的,你可以用下面的命令行参数来修改它:

vue-cli-service test:unit --recursive 'src/**/*.spec.js'

recursive 参数告诉测试运行器依据后面的通配符模式来搜索测试文件。

3. 单元测试

到目前为止,一切顺利,但是我们还没有编写任何测试。接下来我们将编写第一个单元测试!

describe('toUpperCase', () => {
  it('should convert string to upper case', () => {
    // 准备
    const toUpperCase = info => info.toUpperCase();

    // 操作
    const result = toUpperCase('Click to modify');

    // 断言
    expect(result).to.eql('CLICK TO MODIFY');
  });
});

上面的例子验证了 toUpperCase 函数是否将传入的字符串转换为了大写字母。

首先是准备工作,导入函数、实例化对象并设置其参数,让目标对象(这里是一个函数)进入一个可测试的状态。然后操作该功能/方法。最后我们对函数返回的结果进行断言。

Mocha 提供了 describeit 两个方法。describe 函数表示围绕测试单元组织测试用例:测试单元可以是类、函数、组件等。Mocha 没有内置的断言库,所以我们必须使用 Chai :它可以设置对结果的期望。Chai 有许多不同的内置断言,但没有涵盖所有用例,缺失的断言可以通过 Chai 的插件系统导入。

大多数时候,你还将为组件层次结构之外的业务逻辑编写单元测试,例如,状态管理或后端 API 处理。

4. 组件展示

下一步是为组件编写集成测试。集成测试不只是测试 Javascript 代码,还会测试 DOM 和相应组件逻辑之间的交互。

// src/components/Footer.vue
<template>
  <p class="info">{{ info }}</p>
  <button @click="modify">Modify</button>
</template>
<script>
  export default {
    data: () => ({ info: 'Click to modify' }),
    methods: {
      modify() {
        this.info = 'Modified by click';
      }
    }
  };
</script>

我们测试的第一个组件是一个渲染其状态并在单击按钮时修改状态的组件。

// test/unit/components/Footer.spec.js
import { expect } from 'chai';
import { shallowMount } from '@vue/test-utils';
import Footer from '@/components/Footer.vue';

describe('Footer', () => {
  it('should render component', () => {
    const wrapper = shallowMount(Footer);

    const text = wrapper.find('.info').text();
    const html = wrapper.find('.info').html();
    const classes = wrapper.find('.info').classes();
    const element = wrapper.find('.info').element;

    expect(text).to.eql('Click to modify');
    expect(html).to.eql('<p class="info">Click to modify</p>');
    expect(classes).to.eql(['info']);
    expect(element).to.be.an.instanceOf(HTMLParagraphElement);
  });
});

要在测试中渲染组件,我们必须使用 Vue 测试工具库中的 shallowMountmount。这两个方法都会渲染组件,但是 shallowMount 不会渲染子组件(子元素将是空元素)。当需要引入某个组件进行测试时,我们可以以相对路径引用 ../../../src/components/Footer.vue 或使用别名 @,路径开头的 @ 符号表示对源文件夹 src 的引用。

我们可以使用 find 选择器在渲染的 DOM 中搜索并获取它的 HTML、文本、类名或原生 DOM 元素。如果搜索的是一个可能不存在的片段,我们可以使用 exists 方法判断它是否存在。上述各种断言只是为了示意各种情况,实际在测试用例中写其中一个断言就够了。

5. 组件交互

我们已经测试了 DOM 的渲染,但还没有与组件进行任何交互。我们可以通过 DOM 或组件实例与组件交互:

it('should modify the text after calling modify', () => {
  const wrapper = shallowMount(Footer);

  wrapper.vm.modify();

  expect(wrapper.vm.info).to.eql('Modified by click');
});

上面的例子展示了如何使用组件实例来实现交互。我们可以使用 vm 属性访问组件实例,还可以通过组件实例访问到组件 method 中的方法和 data 对象(状态)里的属性。

另一种方法是通过 DOM 与组件交互,我们可以触发按钮上的单击事件并观察是否显示文本:

it('should modify the text after clicking the button', () => {
  const wrapper = shallowMount(Footer);

  wrapper.find('button').trigger('click');
  const text = wrapper.find('.info').text();

  expect(text).to.eql('Modified by click');
});

触发 buttonclick 事件等同于在组件实例上调用 modify 方法。

6. 父子组件交互

上面我们单独测试了组件,但实际应用程序由多个部分组成。父组件通过 props 与子组件通信,子组件通过触发事件与父组件通信。

我们可以通过修改传入组件的 props 来更新组件的展示文案,并通过事件将改动通知给父组件。

export default {
  props: ['info'],
  methods: {
    modify() {
      this.$emit('modify', 'Modified by click');
    }
  }
};

在接下来的测试中,我们需要把 props 作为输入,并监听触发的事件。

it('should handle interactions', () => {
  const wrapper = shallowMount(Footer, {
    propsData: { info: 'Click to modify' }
  });

  wrapper.vm.modify();

  expect(wrapper.vm.info).to.eql('Click to modify');
  expect(wrapper.emitted().modify).to.eql([
    ['Modified by click']
  ]);
});

shallowMountmount 方法的第二个参数是一个可选参数,我们可以使用 propsData 设置输入的 props。触发的事件可以通过调用 emitted 方法获得,得到的结果是一个对象,key 是事件的名称,value 是事件参数数组。

6. store 集成

在前面的例子中,状态都在组件内部。而在复杂的应用程序中,我们需要在不同的位置访问和改变相同的状态。Vuex 是 Vue 的状态管理库,它可以帮助你在一个地方组织状态管理,并确保其可预测地发生变化。

const store = {
  state: {
    info: 'Click to modify'
  },
  actions: {
    onModify: ({ commit }, info) => commit('modify', { info })
  },
  mutations: {
    modify: (state, { info }) => state.info = info
  }
};
const vuexStore = new Vuex.Store(store);

上面的 store 有一个单一的状态属性,它与我们在上面的组件中设置的一样。我们可以使用 onModify 操作修改状态,该操作将输入参数传递给名为 modify 的 mutation 来改变状态。

首先,我们可以给 store 里的每个方法单独编写单元测试:

it('should modify state', () => {
  const state = {};

  store.mutations.modify(state, { info: 'Modified by click' });

  expect(state.info).to.eql('Modified by click');
});

我们也可以构建 store 来编写集成测试,从而检查整体是否能不抛出错误,正常运行:

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

it('should modify state', () => {
  const localVue = createLocalVue();
  localVue.use(Vuex);
  const vuexStore = new Vuex.Store(store);

  vuexStore.dispatch('onModify', 'Modified by click');

  expect(vuexStore.state.info).to.eql('Modified by click');
});

首先,我们必须创建一个 Vue 的局部实例,然后使用 use 语句。如果我们不调用 use 方法,将会抛出一个错误。通过创建 Vue 的局部副本,我们还可以避免污染全局对象。

我们可以通过 dispatch 方法改变 store。第一个参数表示调用哪个 action;第二个参数作为参数传递给 action。我们可以随时通过 state 属性检查当前状态。

当使用组件的 store 时,我们必须将局部 Vue 实例和 store 实例传递给 mount 函数。

const wrapper = shallowMount(Footer, { localVue, store: vuexStore });

8. 路由

测试路由的设置与测试 store 有点类似,必须创建 Vue 实例的局部副本和路由实例,使用路由实例作为插件,然后创建组件。

<div class="route">{{ $router.path }}</div>

上面这行组件模板将渲染当前路由路径。在测试中,我们可以断言这个元素的内容。

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

it('should display route', () => {
  const localVue = createLocalVue();
  localVue.use(VueRouter);
  const router = new VueRouter({
    routes: [
      { path: '*', component: Footer }
    ]
  });

  const wrapper = shallowMount(Footer, { localVue, router });
  router.push('/modify');
  const text = wrapper.find('.route').text();

  expect(text).to.eql('/modify');
});

我们用 * 路径为组件添加了一个全匹配路由。有了 router 实例后,我们还需要使用路由器的 push 方法为应用程序设置导航。

创建所有路由可能会是一项耗时的任务,我们可以实现一个伪路由器,将其作为一个 mock 数据传递:

it('should display route', () => {
  const wrapper = shallowMount(Footer, {
    mocks: {
      $router: {
        path: '/modify'
      }
    }
  });
  const text = wrapper.find('.route').text();

  expect(text).to.eql('/modify');
});

我们也可以在 mocks 中定义一个 $store 属性来 mock store。

9. HTTP 请求

初始状态通常是通过 HTTP 请求得到的。我们很容易在测试中完成真实的请求,但这会使得测试变得脆弱,并且对外部形成依赖。为了避免这种情况,我们可以在运行时更改请求的实现。在运行时更改实现称为 mocking,我们将使用 Sinon 这一 mocking 框架来实现。

const store = {
  actions: {
    async onModify({ commit }, info) {
      const response = await axios.post('https://example.com/api', { info });
      commit('modify', { info: response.body });
    }
  }
};

我们在上面这段代码中修改了 store 的实现:首先输入参数通过 POST 请求被发送,然后将该请求得到的结果传递给 mutation。代码变成了异步,并有了一个外部依赖项,外部依赖项将是我们在运行测试之前必须更改(mock)的项。

import chai from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
chai.use(sinonChai);

it('should set info coming from endpoint', async () => {
  const commit = sinon.stub();
  sinon.stub(axios, 'post').resolves({
    body: 'Modified by post'
  });

  await store.actions.onModify({ commit }, 'Modified by click');

  expect(commit).to.have.been.calledWith('modify', { info: 'Modified by post' });
});

我们为 commit 方法创建了一个伪实现,并更改了 axios.post 的原始实现。这些伪实现可以捕获传递给它们的参数,并用我们要求它们返回的内容进行响应。我们没有为 commit 方法指定返回值,所以它将返回一个空值。axios.post 将返回一个 promise,该 promise 被解析为带有 body 属性的对象。

我们必须将 sinonChai 作为一个插件添加到 Chai 中,以便能够对调用签名进行断言。这个插件扩展了 Chaito.have.been 属性和 to.have.been.calledWith 方法。

如果我们返回一个 Promise,测试函数将变成异步的。Mocha 可以检测并等待异步函数完成。在函数内部,我们等待 onModify 方法完成,然后断言伪 commit 方法是否被调用并传入了 post 调用返回的参数。

10. 浏览器

从代码的角度来看,我们已经测试到了应用程序的各个方面。但有一个问题我们仍然不能回答:应用程序可以在浏览器中运行吗?使用 Cypress 编写的端到端测试可以告诉我们答案。

Vue CLI 提供如下功能:启动应用程序并在浏览器中运行 Cypress 测试,然后关闭应用程序。如果你想在 headless 模式下运行 Cypress 测试,你必须将 headless 标记添加到命令中。

describe('New todo', () => {
  it('it should change info', () => {
    cy.visit('/');

    cy.contains('.info', 'Click to modify');

    cy.get('button').click();

    cy.contains('.info', 'Modified by click');
  });
});

上述测试代码的组织结构与单元测试相同:describe 代表测试分组,it 代表测试运行。全局变量 cy 表示 Cypress 运行器。我们可以同步地命令运行程序在浏览器中执行什么操作。

在访问了主页(visit)之后,我们可以通过 CSS 选择器访问页面中的 HTML。我们可以使用 contains 来断言元素的内容。页面交互也是相同的方式:首先,选择元素(get),然后进行交互(click)。在测试的最后,我们检查内容是否更改。

总结

我们已经介绍完了所有的测试用例,从一个函数的基本单元测试到在实际浏览器中运行的端到端测试。在本文中,我们为 Vue 应用程序的构建块(组件、存储、路由)创建了集成测试,并介绍了 mocking 实现的一些基础。你可以在现有的或未来的项目中使用这些技术来避免程序上的 bug。希望本文能降低大家为 Vue 应用程序编写测试的门槛。

本文中的示例阐明了测试相关的许多事情,希望你们喜欢!


如果你觉得这篇内容对你有价值,请点赞,并关注我们的官网和我们的微信公众号(WecTeam),每周都有优质文章推送:

WecTeam