测试组件方法

本文共--字 阅读约--分钟 | 浏览: -- Last Updated: 2022-06-06

测试自包含的方法并不复杂,但是现实世界的方法通常具有依赖项,而测试有依赖的的方法,会引入一个更复杂的环境。

依赖是指在被测代码单元控制之外的任何代码。依赖有多种形式,浏览器方法、被导入模块和被注入的 Vue 实例属性,都是常见依赖。

本章节中的规范:

  • ProgressBar 进度条组件有这些方法:start、 finish、fail

    • start 被调用时,ProgressBar 应显示进度条。
    • finish 被调用时, ProgressBar 的宽度值应为 100%。
    • finish 被调用时, ProgressBar 应隐藏进度条。
    • start 被调用时,ProgressBar 应重置宽度值。
    • fail 方法被调用时,进度条将置于一个 error 状态。
  • 调用 start 方法后,进度条的宽度是随时间增加, 每100ms 增加 1% 的宽度显示加载,组件将使用定时器功能。

测试公共组件和私有组件方法

通常,在组件内部使用的方法,被视为私有方法,它们一般不在组件外部被调用,私有方法是实现细节的,因此不用直接为它们编写测试。

在 Vue 中,公共方法不是一种常见的模式,但它们可以很强大,你应该习惯为公共方法编写测试,因为公共方法是组件契约的一部分,比如在应用中经常用到的 “埋点”、比如在本章中 ProgressBar 的 start、 finish、fail 都将被设计为公共方法,便于在应用的各个场景都可以控制进度条的展示。

测试公共组件方法

test('ProgressBar methods', () => {
  const wrapper = shallowMount(ProgressBar);
  expect(wrapper.classes()).toContain('hidden');
  wrapper.vm.start();
  expect(wrapper.classes()).not.toContain('hidden');
  // not 修饰符对一个断言进行否定

  wrapper.vm.finish();
  expect(wrapper.element.style.width)toBe('100%');
  expect(wrapper.classes()).toContain('hidden');

  wrapper.vm.start();
  expect(wrapper.element.style.width)toBe('0%');
})

测试定时器函数

定时器函数是实时运行的,这对于速度敏感的单元测试来说不是好消息,一个单元测试的执行时间每多占用一秒就会使整个测试条件变得更糟,因此测试带有定时器功能的代码容易出现问题,比如之前提到的,测试代码已经跑完了,但是定时器的回调根本没有机会执行。而且使用类似 await 等待也并不划算,如果要使用 setTimeout 测试组件在 500ms 后执行某些操作,则需要在测试中等待 500ms,这种延迟会影响测试套件的性能,通常测试套件是在几秒钟内完成数百次测试的。

在不减慢测试速度的情况下测试定时器函数的唯一方法是将定时器函数替换为同步运行的自定义函数。

使用 Jest 中的假定时器,当你调用 jest.useFakeTimers 方法时,Jest 假定时器会替换全局定时器函数来工作,定时器被替换后,你可以使用 runTimersToTime 推进假时间。

如果在一个测试套件中,你需要使用定时器函数的代码编写测试,最好在测试文件中添加 beforeEach 钩子函数以启用假定时器。

describe('Test Timer',() => {
  beforeEach(() => {
    jest.useFakeTimers();
  })

  test('increases width by 1% every 100ms after start call',() => {
    const wrapper = shallowMount(ProgressBar);
    wrapper.vm.start();
    jest.runTimersToTime(100);
    expect(wrapper.element.style.width)toBe('1%');
    jest.runTimersToTime(900);
    expect(wrapper.element.style.width)toBe('10%');
    jest.runTimersToTime(4000); // 时间是一直累加的
    expect(wrapper.element.style.width)toBe('50%');
  })
})

使用假定时器测试解决测试快慢的问题之后,剩下的问题就是,在进度条停止运行时定时器会被删除。

在调用 setInterval 启动定时器后,会保留其引用,这样就可以通过 clearInterval 来停止定时器的运行,要测试 clearInterval 是否被调用,你需要学习如何使用 spy(间谍)。

通常,当你测试的代码使用了你不能控制的 API 时,你需要检查 API 中的函数是否已经被调用。

你可以使用 Jest 的 jest.spyOn 函数创建一个 spy,spyOn 函数让你可以使用toHaveBeenCalled 匹配器检查函数是否被调用,toHaveBeenCalledWith匹配器测试 spy 是否带指定参数被调用。

在 ProgressBar 的 start 方法被调用时就会启动一个定时器来控制 width,所以当 finish 方法被调用时,你应该使用从原始 setInterval 调用返回的定时器ID 调用 clearInterval,这样就可以清除定时器并阻止潜在的内存泄漏。

test('clears timer when finish is called', () => {
  jest.spyOn(window, 'clearInterval');
  setInterval.mockReturnValue(123); // 配置函数的返回值
  const wrapper = shallowMount(ProgressBar);
  wrapper.vm.start();
  wrapper.vm.finish();
  expect(window.clearInterval).toHaveBeenCalledWith(123);
  // 这里就不需要多余去测试 toHaveBeenCalled 了
})

向 Vue 实例添加属性

import Vue from 'vue';
import App from './App';
import ProgressBar from './components/ProgressBar';

// 创建一个挂载的 ProgressBar 实例,这样不同场景使用的 进度条 其实都是同一个了 
// 类似插件 子组件就可以通过实例对其访问控制展示了
const bar = new Vue(ProgressBar).$mount();
Vue.prototype.$bar = bar;
document.body.appendChild(bar.$el); // 将组件元素添加进 Document

new Vue({
  el: '#app',
  render: h => h(App)
})

插件的写法

import Vue from 'vue';
import ProgressBar from './components/ProgressBar';

const ProgressBarConstructor = Vue.extend(ProgressBar);

function showProgressBar(vue) {
  const progressBarDom = new ProgressBarConstructor().$mount();

  document.body.appendChild(progressBarDom.$el);
  vue.prototype.$bar = progressBarDom;
}

export default showProgressBar;

// 在main.js
import Vue from 'vue';
import ProgressBarPlugin from './components/ProgressBarPlugin';

Vue.use(ProgressBarPlugin);

用于安装 Vue.js 插件,如果插件是一个对象,必须提供 install 方法,如果插件是一个函数,它会被作为 install 方法,install 方法被调用的时,会将 Vue 作为参数传入,当 install 方法被同一个插件多次调用,插件将只会被安装一次。

模拟代码

生产环境可能很乱,它可以进行 HTTP 调用,打开数据库连接,并制作复杂的依赖树,在单元测试中,你可以通过模拟代码忽略所有这些问题。

简单来说,模拟代码是用 你可控制的代码 替换 你不可控制的代码,如下是模拟代码的三个好处:

  • 你可以在测试中停止类似 HTTP 调用这样的副作用问题。
  • 你可以控制函数的行为和返回值。
  • 你可以测试函数是否被调用。

模拟组件中的 Vue 实例属性

在 Vue 中为 Vue 原型添加属性是一种常见模式,如上面提到的 ProgressBar,但是在测试中 main.js 入口文件不会被执行,因此 $bar 永远不会作为实例属性被添加。

因此,如果你在测试中挂载组件,你需要为其添加所需属性,否则你将收到错误:不存在$bar属性。

可以使用 Vue Test Utils mock 选项来实现:

shallowMount(ItemList, {
  mocks: {
    $bar: {
      start: () => {}
    }
  }
})

了解 Jest mock 函数

有时你需要在测试中检查函数是否被调用,那么你可以使用可记录自身信息的模拟函数替换该函数。

// mock 函数的简单实现
const mock = function(...args) {
  mock.calls.push(args);
}
mock.calls = [];
mock(1);
mock(2, 3);
mock.calls.length // 2
mock.calls // [[1], [2, 3]] 

使用 jest.fn() 创建 Jest mock 函数。

test('calls $bar start on load', () => {
  const $bar ={
    start: jest.fn(),
    finish: () => {}
  }

  shallowMount(ItemList, { mock: { $bar }});
  expect($bar.start).toHaveBeenCalledTimes(1);
  // toHaveBeenCalledTimes 匹配器断言 start 是否被调用1次
})

模拟模块依赖

试图将一个单元隔离开进行测试可能就像从地面一株植物一样,将植物拉出来后,你看到的只是植物的根系缠绕在其他植物周围,也就是说,在你发现之前,你已经拉动了半个花园。

同样的,在导入模块的时候,被导入的模块将成为一个模块依赖,然后可能就存在一个依赖一个的模块,因此,需要使用模拟模块依赖是用来消除副作用,模拟模块依赖就是将导入的模块替换为另一个对象的过程。

比如,你的组件在 created 的时候会调用外部文件暴露的 fetchData 使用 HTTP 去拉取数据,而 HTTP 请求不在单元测试范围,它们会降低单元测试的速度,并且妨碍单元测试的可靠性。所以你需要在你的单元测试中模拟这个外部文件,从而让 fetchData 永远不会发送一个 HTTP 请求。

使用 Jest mock 模拟模块依赖

Jest 提供了一个 API,用于选择当一个模块导入另一个模块时返回哪些文件或函数,要使用此功能,你需要创建一个 Jest 应该解析的 mock 文件,而不是请求文件。 mock 文件将包含你希望测试使用的函数,而不是真正的文件函数。

这里相当于是 手动mock,如果你要引入的模块是src/api/fetchData.js,那么你需要建立一个 src/api/__mocks__/fetchData.js__mocks__ 注意名称必须是这个,否则无法被Jest所识别到。规则是你要引入文件所在的目录下创建一个__mocks__文件夹

// src/api/__mocks__/fetchData.js
// 使用 jest.fn 创建的 Jest mock函数应该是一个无操作函数
// 通信请求一般是一个异步函数 所以返回一个 Promise
export const fetchData = jest.fn(() => Promise.resolve([]));

// 使用的时候
// ItemList.spec.js
jest.mock('src/api/fetchData');

import { fetchData } from 'src/api/fetchData';
fetchData.mockResolvedValue({data: []});

第二种方式,不用手动创建 mock 文件:

//  ItemList.spec.js
// 一定要是第一行
jest.mock('src/api/fetchData.js', () => ({
  fetchData: jest.fn(() => Promise.resolve([]))
}));

// 举例2: mock axios
import axios from 'axios'

jest.mock('axios', () => ({
  get: jest.fn(() => Promise.resolve({ data: 3 }))
}))

// 或者这么使用
// mock 之后变成 jest mock 对象之后就可以在调用相关 api 了
import axios from 'axios'

jest.mock('axios');
axios.get.mockResolvedValue({data: 3});

测试异步代码

test('fetches data', async () => {
  expect.assertions(1);
  const data = await fetchData();
  expect(data).toBe('some data');
})

在异步测试中使用 assertions 设置断言数量的原因是为了确保在测试结束之前执行完所有断言

如果你测试的函数使用回调,则需要使用 done 函数

// 将 done 作为参数传入
// 因为不调用 done,fetchData(callback) 调用完之后整个测试用例就执行完了
// 根本等不到 callback 被执行,同时断言也不会被执行
test('test done', (done) => {
  function callback(data) {
    expect(data).toBe('some data');
    done();
  }
  fetchData(callback);
});

会有另一种场景,你并不总是可以访问需要等待的异步函数,这意味着你不能再测试中使用 await 来等待异步函数结束,这是一个问题,因为即使函数返回一个已解析的 promise,then 回调也不会同步运行,而是会在下一个事件循环被执行,此时可能测试代码已经跑完了,如下例:

// 安装 npm i -D flush-promises
// 作用就是会等待所有 promise 回调运行
import flushPromises from 'flush-promises';

test('await promises', async () => {
  expect.assertions(1);
  let res = false;
  Promise.resolve().then(() => {
    res = true;
  })

  await flushPromises(); // 删除此行将运行失败,与之前提到原因一样,then 回调还没有执行,测试函数就跑完了
  // 或者直接修改上面的 Promise.resolve()... 为 await Promise.resolve()...

  expect(res).toBe(true)
})

mock 依赖 & mock 拒绝函数

jest.mock('../../api/api');
import { shallowMount } from '@vue/test-utils';
import ItemList from '../ItemList.vue';
import Item from '../Item.vue';
import flushPromises from 'flush-promises';
import { fetchData } from '../../api/api';

describe('test summary', () => {
  // 此时 fetchData 就是一个 jest mock 对象了
  test('renders an Item with data for each item', async () => {
    expect.assertions(4);
    const $bar = {
      start: () => {},
      finish: () => {}
    }

    const items = [{ id: 1 }, { id: 2 }, { id: 3 }];
    fetchData.mockResolvedValueOnce(items);
    const wrapper = shallMount(ItemList, { mocks: { $bar }})
    await flushPromises();
    const Items = wrapper.findAllComponents(Item);
    expect(Items).toHaveLength(items.length);
    Items.wrappers.forEach((warpper, i) => {
      expect(wrapper.props().item).toBe(items[i])
    })
  })

  // 模拟拒绝函数
  test('calls $bars.fail when fetch unsuccessful', async () => {
    expect.assertions(1);
    const $bar = {
      start: () => {}
      fail: jest.fn(),
    }
    fetchData.mockRejectedValueOnce();
    const wrapper = shallMount(ItemList, { mocks: { $bar }})
    await flushPromises();
    expect($bar.fail).toHaveBeenCalled();

    // 这里需要注意的是 你断言会有 1个断言 会被执行
    // 但是 当 fetchData 返回一个 promise reject 时而未被捕获时
    // 导致 测试代码会抛出一个错误 并且断言从未被调用
    // 这就是为什么异步测试中你应该始终定义期望的断言数量
    // 解决方法 给 fetchData 增加一个 catch
  })
})

适度使用 mock

你已经了解如何通过不同的方式使用 mock: 控制一个函数的返回值,检查函数是否被调用,以及组织 HTTP 请求这样的副作用,这些都是模拟很好的例子,因为在没有模拟的情况下很难对它们进行测试,但是,模拟应该始终是最后的选择。

模拟增加了一个测试和生产代码之间的耦合,同时也增加了测试所做的假设,在一个测试中,每多一个 mock 就会给测试代码和生产代码的不同步创造新的机会。

你应该只模拟副作用是减慢测试速度的文件,常见的减慢测试速度的副作用如下:

  • HTTP 调用
  • 连接数据库
  • 使用文件系统

不是告诉你不要使用 mock,而是提醒你使用 mock 的时候,它们潜在的危险。