likes
comments
collection
share

关于 Vue 组件的单元测试

作者站长头像
站长
· 阅读数 16

什么是单元测试

在计算机编程中,单元测试(Unit Testing)又称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。单元测试通常由软件开发人员编写,用于确保他们所写的代码符合软件需求和遵循开发目标。它的实施方式可以是非常手动的(透过纸笔),或者是做成构建自动化的一部分。单元测试的目标是隔离程序部件并证明这些单个部件是正确的。一个单元测试提供了代码片断需要满足的严密的书面规约。

单元测试的好处

  • 提供描述组件行为的文档
  • 节省手动测试的时间
  • 减少研发新特性时产生的 bug
  • 改进设计
  • 促进重构

单元测试能提高程序的可靠性,让开发者在发布时更有底气,让使用者更有安全感。单元测试并不能完全代替功能测试,因为程序本身设计的逻辑错误或者其它的一些环境因素所造成的影响,单元测试可能无能为力。单元测试只能保证你想让程序输出一只猪,最后不会整出一头驴。

要测试什么

业务代码或业务组件是比较难以实施单元测试的,一方面它们比较多变、另一方面很多团队很少有精力维护这部分单元测试。所以通常只要求对一些基础/底层的组件、框架或者服务进行测试, 视情况考虑是否要测试业务代码。对于 UI 组件来说,不推荐一味追求行级覆盖率,因为它会导致我们过分关注组件的内部实现细节,从而导致琐碎的测试。取而代之的是,推荐把测试撰写为断言你的组件的公共接口,并在一个黑盒内部处理它。一个简单的测试用例将会断言一些输入 (用户的交互或 prop 的改变) 提供给某组件之后是否导致预期结果 (渲染结果或触发自定义事件)。单元测试的侧重点应该在测试功能型组件、vue插件、二次封装的库。

框架选择

名称描述Github地址个人理解
jest开发人员就绪:全面的JavaScript测试解决方案。对于大多数JavaScript项目而言,都是开箱即用的。即时反馈:快速的交互式监视模式仅运行与已更改文件相关的测试文件。快照测试:捕获大型对象的快照,以简化测试并分析其随时间的变化。github.com/facebook/je…facebook的测试框架,旨在在大多数JavaScript项目中开箱即用,无需配置。
mocha用于node.js和浏览器的简单,灵活,有趣的javascript测试框架github.com/mochajs/moc…强大的测试框架,中文名叫抹茶,常见的describe,beforeEach就来自这里
karma一个简单的工具,使您可以在多个实际的浏览器中执行JavaScript代码。Karma的主要目的是使您的测试驱动开发变得容易,快速和有趣。github.com/karma-runne…不是测试框架,也不是断言库,可以做到抹平浏览器障碍式的生成测试结果
chaiChai是用于节点和浏览器的BDD / TDD断言库,可以与任何javascript测试框架完美地配对。Chai是一个断言库,类似于Node的内置库assert。通过提供许多可以针对代码运行的断言,它使测试变得更加容易。github.com/chaijs/chaiBDD/TDD断言库,assert,expect,should比较有趣
sinonJavaScript的独立测试间谍,存根和模拟。适用于任何单元测试框架。github.com/sinonjs/sin…js mock测试框架,everything is fake,spy比较有趣
vue-test-utilsVue Test Utils是Vue.js官方的单元测试实用工具库。github.com/vuejs/vue-t…专门为测试单文件组件而开发,学会使用vue-test-utils,将会在对vue的理解上更上一层楼

经过各方面对比,选择使用 Jest 框架。

它具有以下好处:

  • 一站式的解决方案

在使用 Jest 之前,需要一个测试框架(mocha),需要一个测试运行器(karma),需要一个断言库(chai),需要一个用来做 spies/stubs/mocks 的工具(sinon 以及 sinon-chai 插件),一个用于测试的浏览器环境(可以是 Chrome 浏览器,也可以用 PhantomJS)。而使用 Jest 后,只要安装它,全都搞定了。

  • 全面的官方文档,易于学习和使用

Jest 的官方文档很完善,对着文档很快就能上手。而在之前,需要学习好几个插件的用法,至少得知道 mocha 用处和原理吧 ,得学会 karma 的配置和命令,chai 的各种断言方法……,经常得周旋于不同的文档站之间,其实是件很烦也很低效的事。

  • 配置简单方便
  • 更直观明确的测试信息提示
  • 方便的命令行工具

全局安装 Jest 后,可以在命令行执行单元测试,配合各种命令参数,可以方便地实现执行单个测试、监视文件变化并自动执行等功能。特别是对于监视文件变化并执行,它提供多种模式,可以只执行修改过的测试。Jest 甚至提供了 jest-codemods 这一工具,用来将使用其它包的测试迁移为使用 Jest。

Jest 也有不足之处:

  • jsdom 的一些局限性

因为 Jest 是基于 jsdom 的,jsdom 毕竟不是真实的浏览器环境,它在测试过程中其实并不真正的“渲染”组件。这会导致一些问题,例如,如果组件代码中有一些根据实际渲染后的属性值进行计算(比如元素的 clientWidth)就可能出问题,因为 jsdom 中这些参数通常默认是 0。所以有些情况下,测试中可能要施以一些骚操作,比如自行 mock(实例上就是伪造,但合理地伪造)一些中间值,来满足测试用例。如果你的项目中这样的情况很多,还是建议使用 karma + mocha + chrome 这一组合。

  • 2.周边相关的包可能还不完善

例如 vue-jest,目前的版本并不能完全实现 vue-loader 的功能。比如,使用 sass,postcss 之类的功能,它会抛出警告信息。代码中直接 import 实际的 css 文件,则有可能报错,这时则需要使用 mock 来模拟 css 文件。这些问题,在使用 karma-mocha Chrome 的时候是没有的,因为测试运行于真实的浏览器环境中。

一些概念

断言

断言是单元测试框架中核心的部分,断言失败会导致测试不通过,或报告错误信息。没有断言的单元测试就是在耍流氓。所谓"断言",就是判断源码的实际执行结果与预期结果是否一致,如果不一致就抛出一个错误。所有的测试用例(it 块/test 块)都应该含有一句或多句的断言。它是编写测试用例的关键。同等性断言:

expect(sth).toEqual(value)
expect(sth).not.toEqual(value)

比较性断言:

expect(sth).toBeGreaterThan(number)
expect(sth).toBeLessThanOrEqual(number)

类型性断言:

expect(sth).toBeInstanceOf(Class)

条件性测试:

expect(sth).toBeTruthy()
expect(sth).toBeFalsy()
expect(sth).toBeDefined()

常用断言语句:

toBe()----测试具体的值
toEqual()----测试对象类型的值
toBeCalled()----测试函数被调用
toHaveBeenCalledTimes()----测试函数被调用的次数
toHaveBeenCalledWith()----测试函数被调用时的参数
toBeNull()----结果是null
toBeUndefined()----结果是undefined
toBeDefined()----结果是defined
toBeTruthy()----结果是true
toBeFalsy()----结果是false
toContain()----数组匹配,检查是否包含
toMatch()----匹配字符型规则,支持正则
toBeCloseTo()----浮点数
toThrow()----支持字符串,浮点数,变量
toMatchSnapshot()----jest特有的快照测试
.not.+matcher,eg. .not.toBe()----前面加上.not就是否定形式,

测试用例 test case

为某个特殊目标而编制的一组测试输入、执行条件以及预期结果,以便测试某个程序路径或核实是否满足某个特定需求。表示一个单独的测试,是测试的最小单位。它也是一个函数,第一个参数是测试用例的名称,第二个参数是一个实际执行的函数。形式如下:

test('should ...', function() {
  ...
  expect(sth).toEqual(sth);
});

测试套件 test suite

describe** **是"测试套件"(test suite),表示一组相关的测试。它是一个函数,第一个参数是测试套件的名称("加法函数的测试"),第二个参数是一个实际执行的函数。

describe('test ...', function() {
  test('should ...', function() { ... });
  test('should ...', function() { ... });
  ...
});

mock

mock 一般指在测试过程中,对于某些不容易构造或者不容易获取的对象,用一个虚拟的对象来创建以便测试的测试方法广义的讲,spystub 等,以及一些对模块的模拟,对 ajax 返回值的模拟、对 timer 的模拟,都叫做 mocksinon是一个 once 函数。sinon.spy()是指 根据已有函数 生成一个间谍函数,它会记录下函数调用的参数,返回值,this 的值,以及抛出的异常。sinon.stub() 可以说是 spy() 的加强版,他还能额外操作函数的行为。

mountshallowMount 的区别是什么?

shallowMount 仅仅挂载当前组件实例;而 mount 挂载当前组件实例以外,还会挂载子组件。

运行在哪个文件夹下?

默认为__test__文件夹下,文件名以.spec.js结尾即可,这个可以根据自己的需要在 jest.config.js 中设置。

能否生成测试报告?

可以通过在jest.config.js中设置输出测试报告,默认是在控制台打印测试用例是否执行成功。jest.config.js中关于测试报告的设置参考如下,可以根据自己的需要设置。

collectCoverage:是否进行测试覆盖率收集。

coverageDirectory:测试报告存放位置,默认在根目录下创建一个coverage目录存放测试报告,此目录下会生成一个index.html文件,可以通过访问此HTML文件在浏览器中清楚的查看测试报告。

collectCoverageFrom:测试哪些文件和不测试哪些文件,可以根据需要进行设置。

TDD 与 BDD

现在流行的单元测试风格主要有 TDD(测试驱动开发)和 BDD(行为驱动开发)两种。比较明显的区别是一个是先写测试用例再写代码,一个是先写代码再写测试用例。当然在实际开发中没必要片面的追求是符合 TDD 还是 BDD,不能让测试风格留意表面,更不能为了强行符合某一种测试风格影响实际开发进展。

TDD

TDD 的开发流程一般是先编写测试用例,再编写代码。比如写一个 Todolist,我们一开始先想好它的功能和注意点,每一个功能都具备对应的测试用例,编写测试用例,没写代码之前肯定是通不过的;然后写完开始编写代码,使测试用例通过测试,再继续重复步骤,完成开发。这样写出来的代码质量和维护性也会更好。所以 TDD 一般就是作为单元测试,即是单一组件功能上测试得较为完善,但几个组件加起来测试这种却不一定能测试完善。单元测试应用比较多的是函数库,可对每个函数进行单独细致测试。单元测试比较独立,但过于独立也会隐藏一些潜在问题无法测到,这个时候就需要集成测试辅助了。

BDD

BDD 的开发流程一般是先编写代码,再写测试用例。这个就比较符合我们以往的开发模式,不过 BDD 更关注的是整体的行为是否符合预期,一般结合集成测试,也就是说整体好多个组件整体业务测试这种。集成测试重点关心的是结果,而不是代码,更适合业务开发。

如何测试

安装 jest

通过脚手架 vue-cli 来新建项目的时候,如果选择了 Unit Testing 单元测试且选择的是 Jest 作为测试运行器,那么在项目创建好后,就会自动配置好单元测试需要的环境,直接能用 Vue-Test-Utils 和 Jest 的 API 来写测试用例了。但是新建项目之初没有选择单元测试功能,需要后面去添加的话,有以下两种方案:**方案一:**直接在项目中添加一个 unit-jest插件,会自动将需要的依赖安装配置好,但可控性不高。

vue add unit-jest

**方案二:**这种配置会麻烦一点,但可控制性更高,自己可以一步步的控制自己想要的,具体操作步骤如下:

  • 安装依赖
//1. 安装  Jest 和 Vue Test Utils
npm install --save-dev jest  @vue/test-utils


//2.安装 babel-jest 、 vue-jest 和 7.0.0-bridge.0 版本的 babel-core
npm install --save-dev babel-jest vue-jest babel-core@7.0.0-bridge.0

//3.安装 jest-serializer-vue
npm install --save-dev jest-serializer-vue
  • 配置 jest.config.js 文件
module.exports = {

  preset: '@vue/cli-plugin-unit-jest',

  // 告诉jest在编辑的过程中可以忽略哪些文件,默认为node_modules下的所有文件
  testPathIgnorePatterns: ['/node_modules/'],

  //匹配测试用例的文件,即告诉jest去哪里找我们编写的测试文件
  testMatch: ['**/tests/unit/**/*.spec.[jt]s?(x)', '**/src/**/*.spec.[jt]s?(x)', '**/__tests__/*.[jt]s?(x)'],

  // 处理别名,同webpack中的alias
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1'
  },

  // 在执行测试用例之前需要先执行的文件
  //setupFiles: ['<rootDir>/tests/unit/lib/register-context.js'],

  // 不进行匹配的目录 ,转换前与所有源文件路径匹配的regexp模式字符串数组。如果文件路径与任何模式匹配,则不会进行转换。
  transformIgnorePatterns: ['/node_modules/(?!element-ui)'],

  //告诉 Jest 需要匹配的文件后缀
  moduleFileExtensions: ['js', 'vue'],

  //transform 匹配到 .vue 文件的时候用 vue-jest 处理, 匹配到 .js 文件的时候用 babel-jest 处理
  transform: {
    '^.+\\.vue$': '<rootDir>/node_modules/vue-jest',
    '^.+\\.js$': '<rootDir>/node_modules/babel-jest'
  },

  //Vue组件进行Jest快照序列化的工具配置
  snapshotSerializers: ['jest-serializer-vue']
}

书写测试用例

根据上一步中jest.config.js中的关于testMatch的配置来匹配测试用例文件,即在组件的根目录下创建一个__tests__(注意是两个下划线,即上一步中设置的路径)目录,然后在此目录下创建一个index.spec.js。一个简单的测试用例(参数传递、mock 数据、插槽)

import { shallowMount, mount } from '@vue/test-utils'
import Cpn from '../'
import sinon from 'sinon'

const clickHandler = sinon.stub()

const mockRoute = {
  $route: {
    meta: {
      title: '路由标题'
    }
  },
  $router: {
    go: clickHandler
  }
}

const data = {
  title: '页面标题',
  description: '这是关于页面功能/操作的文字描述部分。'
}

describe('测试状态栏', () => {

  test('整体功能', () => {
    const wrapper = shallowMount(Cpn, {
      propsData: data,
      mocks: mockRoute
    })

    expect(wrapper.html()).toMatchSnapshot()
  })

  test('整体功能 + 插槽slot[right]bar', () => {
    const wrapper = shallowMount(Cpn, {
      //组件需要传递的参数
      propsData: data,
      //mock数据
      mocks: mockRoute,
      //具名插槽
      slots: {
        right: '<div>右侧slot插槽</div>'
      }
      //默认插槽
      // slots: {
      //  default: 'content'
      //}

    })

    // 右侧slot是否插入对应元素
    expect(wrapper.find('div.right').exists()).toBe(true)
    expect(wrapper.html()).toMatchSnapshot()
  })
})

测试elementUI组件库中分页组件中点击事件(上一页/下一页)是否调用正确

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

const ShallowMount = (value = {}) => {
  return shallowMount(Cpn, {
    propsData: {
      ...value
    }
  })
}

const props = {
  currentPage: 5,
  pageSize: 8,
  total: 500,
  pageSizes: [10, 50, 100],
  layout: 'total, sizes, prev, pager, next'
}

describe('分页', () => {
  const wrapper = ShallowMount({
    ...props
  })
  test('检查点击上一页/下一页按钮点击响应', async () => {
    wrapper.find('.btn-prev').trigger('click')
    await wrapper.vm.$nextTick()

    expect(wrapper.find('.number+.active').text()).toBe(String(props.currentPage - 1))

    wrapper.find('.btn-next').trigger('click')
    await wrapper.vm.$nextTick()

    expect(wrapper.find('.number+.active').text()).toBe(String(props.currentPage - 1 + 1))
  })
})

执行测试用例

  • package.json文件的scripts标签中添加如下命令
"scripts": {
  "test:unit": "vue-cli-service test:unit"
}
  • 在控制台中输入如下命令执行测试用例
npm run test:unit

测试结果中,成功的用例会用绿色表示,而失败的部分会显示为红色,若出现如下图所示(没有出现 failed),则表示执行测试用例成功。生成快照后会在index.spec.js的同级目录下自动创建一个新目录__snapshots__,在此目录下存放保存快照所生成的 HTML 代码。关于 Vue 组件的单元测试若测试用例执行失败,即执行上述命令后出现 failed,则根据报错信息查看具体问题,在单元测试中,如果没有通过测试,要么是因为测试的对象有问题,或者是测试代码有问题。关于 Vue 组件的单元测试VsCode编辑器中,可以通过安装Jest插件,它可以在我们不运行 npm run test:unit命令的时候就提示测试用例是否通过。关于 Vue 组件的单元测试

需要注意的点

  • 测试应尽早介入。
  • 测试是不可能穷尽的。
  • 测试不是为了保证程序没有错误,而是为了发现程序中存在的错误。
  • 测试应该贯穿于整个生命周期。
  • 尽量保持单元测试的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间尽可能不互相调用,也不能依赖执行的先后次序。
  • 单元测试是可以重复执行的,不能受到外界环境的影响。
  • 对于不可测的代码建议做必要的重构,使代码变得可测,避免为了达到测试要求而书写不规范测试代码。
  • 不能片面的追求单元测试的覆盖率,不能为了追求单元测试的覆盖率导致单元测试过于复杂或花费的时间精力过度。

最后

单元测试是为了提高代码质量,但并不是说代码要迁就与单元测试,这样就本末倒置了。