Vue 测试速成课程

2025-05-28

Vue 测试速成课程

项目即将完工,只剩下一个功能。你实现了最后一个功能,但系统的不同部分出现了 bug。你修复了这些 bug,但又出现了另一个。你开始玩打地鼠游戏,玩了好几轮之后,感觉自己已经搞砸了。不过,有一个解决方案,一个能让项目重现辉煌的救星:为未来功能和现有功能编写测试。这可以保证正常运行的功能不会出现 bug。

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

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

1.类型

测试有三种类型:单元测试、集成测试和端到端测试。这些测试类型通常被可视化为金字塔。

测试金字塔

金字塔表明,较低层的测试编写成本更低、运行速度更快、维护也更轻松。那么,为什么我们不只编写单元测试呢?因为较高层的测试能让我们更有信心地评估系统,并检查各个组件是否能够良好地协同工作。

总结一下测试类型之间的区别:单元测试仅单独处理单个代码单元(类、函数),集成测试检查多个单元是否按预期协同工作(组件层次结构、组件+存储),而端到端测试从外部世界(浏览器)观察应用程序。

2. 测试运行器

对于新项目,向项目添加测试的最简单方法是通过Vue CLI。在生成项目 ( vue create myapp) 时,您必须手动选择单元测试和端到端测试。

Vue CLI

安装完成后,您的package.json文件中将出现多个附加依赖项:

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

从现在开始,单元/集成测试可以在tests/unit带有*.spec.js后缀的目录中编写。测试目录不是固定的;您可以使用命令行参数进行修改:

vue-cli-service test:unit --recursive 'src/**/*.spec.js'
Enter fullscreen mode Exit fullscreen mode

recursive参数告诉测试运行器根据以下 glob 模式搜索测试文件。

3. 单体

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

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

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

    // Assert
    expect(result).to.eql('CLICK TO MODIFY');
  });
});
Enter fullscreen mode Exit fullscreen mode

这将验证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>
Enter fullscreen mode Exit fullscreen mode

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

// 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);
  });
});
Enter fullscreen mode Exit fullscreen mode

要在测试中渲染组件,我们必须使用Vue Test Utils 中的shallowMountmount。这两种方法都会渲染组件,但shallowMount不会渲染其子组件(子元素将为空元素)。在包含被测组件时,我们可以相对引用它../../../src/components/Footer.vue,也可以使用提供的 别名@@路径开头的符号 引用源文件夹src

我们可以使用选择器在渲染的 DOM 中搜索find,并检索其 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');
});
Enter fullscreen mode Exit fullscreen mode

上面的示例展示了如何使用组件实例来实现这一点。我们可以通过vm属性访问组件实例。实例上可以使用对象(状态)下的函数methods和属性data。在这种情况下,我们无需触及 DOM。

另一种方式是通过 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');
});
Enter fullscreen mode Exit fullscreen mode

我们click在 上触发事件,其结果与我们在实例上button调用方法的结果相同。modify

6. 亲子互动

我们已经单独研究了一个组件,但实际的应用程序由多个部分组成。父组件通过 与其子组件通信props,子组件也通过发出事件与其父组件通信。

让我们修改它接收显示文本的组件,props并通过发出的事件通知父组件有关修改的信息。

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

在测试中,我们必须提供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']
  ]);
});
Enter fullscreen mode Exit fullscreen mode

方法shallowMountand有第二个可选参数,我们可以用 来mount设置输入。发出的事件可从方法 result 中获取。事件的名称将作为对象键,每个事件将是数组中的一个条目。propspropsDataemitted

7.商店整合

在前面的示例中,状态始终位于组件内部。在复杂的应用程序中,我们需要在不同位置访问和修改相同的状态。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);
Enter fullscreen mode Exit fullscreen mode

Store 只有一个 state 属性,与我们在组件上看到的一样。我们可以通过onModify将输入参数传递给modifyMutation 来修改状态。

我们可以首先为商店中的每个功能分别编写单元测试。

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

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

  expect(state.info).to.eql('Modified by click');
});
Enter fullscreen mode Exit fullscreen mode

或者我们可以构建存储并编写集成测试。这样,我们可以检查方法是否能够协同工作,而不是抛出错误。

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');
});
Enter fullscreen mode Exit fullscreen mode

首先,我们必须创建一个 Vue 的本地实例。为什么需要它?因为use在 Vue 实例上创建 store 时,必须使用这个语句。如果我们不调用该use方法,就会抛出错误。通过创建 Vue 的本地副本,我们还可以避免污染全局对象。

我们可以通过该方法修改存储dispatch。第一个参数指定要调用哪个操作;第二个参数作为参数传递给操作。我们始终可以通过该state属性检查当前状态。

当将 store 与组件一起使用时,我们必须将本地 Vue 实例和 store 实例传递给 mount 函数。

const wrapper = shallowMount(Footer, { localVue, store: vuexStore });
Enter fullscreen mode Exit fullscreen mode

8. 路由

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

<div class="route">{{ $router.path }}</div>
Enter fullscreen mode Exit fullscreen mode

组件模板中的上述代码将显示当前路由。在测试中,我们可以断言此元素的内容。

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');
});
Enter fullscreen mode Exit fullscreen mode

我们已经将组件添加为一个包含所有路径的路由*。实例化后router,我们需要使用路由器的push方法以编程方式导航应用程序。

创建所有路由可能是一项耗时的任务。我们可以使用一个伪造的路由器实现来加快编排速度,并将其作为模拟传递。

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

  expect(text).to.eql('/modify');
});
Enter fullscreen mode Exit fullscreen mode

$store我们也可以通过在 上声明属性来将这种模拟技术用于商店mocks

it('should display route', () => {
  const wrapper = shallowMount(Footer, {
    mocks: {
      $router: {
        path: '/modify'
      },
      $store: {
        dispatch: sinon.stub(),
        commit: sinon.stub(),
        state: {}
      }
    }
  });
  const text = wrapper.find('.route').text();
  expect(text).to.eql('/modify');
});
Enter fullscreen mode Exit fullscreen mode

9. HTTP 请求

初始状态的突变通常发生在 HTTP 请求之后。虽然在测试中让该请求到达目的地很诱人,但这也会使测试变得脆弱,并且依赖于外部世界。为了避免这种情况,我们可以在运行时更改请求的实现,这称为模拟。我们将使用Sinon模拟框架来实现这一点。

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

我们修改了 store 的实现:输入参数首先通过 POST 请求发送,然后将结果传递给突变。代码变为异步的,并获得了一个外部依赖项。在运行测试之前,我们需要更改(模拟)这个外部依赖项。

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' });
});
Enter fullscreen mode Exit fullscreen mode

我们正在为该commit方法创建一个伪实现,并更改 的原始实现axios.post。这些伪实现会捕获传递给它们的参数,并响应我们指定的返回值。commit由于我们没有指定值,该方法将返回一个空值。axios.post它将返回一个Promise解析为具有 属性的对象body

我们必须将 Sinon 作为插件添加到 Chai 中,才能对调用签名进行断言。该插件扩展了 Chai 的to.have.been.called属性和to.have.been.calledWith方法。

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

10.浏览器

从代码角度来看,我们已经触及了应用程序的各个方面。但还有一个问题我们无法回答:该应用程序能在浏览器中运行吗?用Cypress编写的端到端测试可以解答这个问题。

Vue CLI 负责编排:启动应用程序并在浏览器中运行 Cypress 测试,然后关闭应用程序。如果要在无头模式下运行 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');
  });
});
Enter fullscreen mode Exit fullscreen mode

测试的组织方式与单元测试相同:describe代表分组,it代表运行测试。我们有一个全局变量,cy代表 Cypress 运行器。我们可以同步命令运行器在浏览器中执行操作。

访问主页 ( visit) 后,我们可以通过 CSS 选择器访问显示的 HTML。我们可以使用 断言元素的内容contains。交互的工作方式相同:首先,选择元素 ( get),然后进行交互 ( click)。在测试结束时,我们检查内容是否已更改。

概括

我们已经完成了测试用例的讲解。希望您喜欢这些示例,它们阐明了许多与测试相关的知识。我希望降低开始编写 Vue 应用程序测试的门槛。我们已经从针对函数的基本单元测试过渡到在真实浏览器中运行的端到端测试。

在我们的过程中,我们为 Vue 应用程序的构建块(组件、store、路由器)创建了集成测试,并初步掌握了实现模拟。借助这些技术,您现有和未来的项目可以保持无错误。

标题图片由 Louis Reed 在 Unsplash 上提供

文章来源:https://dev.to/emarsys/vue-testing-crash-course-59kl
PREV
未来编程会是什么样子?多核挑战:数据流编程,Nevalang 登场
NEXT
改进 PostgreSQL 查询 解释查询计划 优化规则 感兴趣的设置 索引调整技巧