阅读 2175

前端抢饭碗系列之Vue项目中如何做单元测试

  关于单元测试,最常见的问题应该就是“前端单元测试有必要吗?”,通过这篇文章,你将会了解单元测试的必要性,以及在Vue项目中如何能够全面可靠的测试我们写的组件。

本文首发于公众号【前端壹读】,更多精彩内容敬请关注公众号最新消息。

单元测试的必要性

  一般在我们的印象里,单元测试都是测试工程师的工作,前端负责代码就行了;百度搜索Vue单元测试,联想词出来的都是“单元测试有必要吗?” “单元测试是做什么的?”虽然我们平时项目中一般都会有测试工程师来对我们的页面进行测试“兜底”,但是根据我的观察,一般测试工程师并不会覆盖所有的业务逻辑,而且有一些深层次的代码逻辑测试工程师在不了解代码的情况下也根本无法进行触发。因此在这种情况下,我们并不能够完全的依赖测试工程师对我们项目测试,前端项目的单元测试就显得非常的有必要。

  而且单元测试也能够帮助我们节省很大一部分自我测试的成本,假如我们有一个订单展示的组件,根据订单状态的不同以及其他的一些业务逻辑来进行对应文案的展示;我们想在页面上查看文案展示是否正确,这时就需要繁琐的填写下单信息后才能查看;如果第二天又又加入了一些新的逻辑判断(你前一天下的单早就过期啦),这时你有三个选择,第一种选择就是再次繁琐地填写订单并支付完(又给老板提供资金支持了),第二种选择就是死皮赖脸的求着后端同事给你更改订单状态(后端同事给你一个白眼自己体会),第三种选择就是代理接口或者使用mock数据(你需要编译整个项目运行进行测试)。

  这时,单元测试就提供了第四种成本更低的测试方式,写一个测试用例,来对我们的组件进行测试,判断文案是否按照我们预想的方式进行展示;这种方式既不需要依赖后端的协助,也不需要对项目进行任何改动,可谓是省时又省力。

测试框架和断言库

  说到单元测试,我们首先来介绍一下流行的测试框架,主要是mocha和jest。先简单介绍下mocha,翻译成中文就是摩卡(人家是一种咖啡!不是抹茶啊),名字的由来估猜是因为开发人员喜欢喝摩卡咖啡,就像Java名字也是从咖啡由来一样,mocha的logo也是一杯摩卡咖啡:

mocha logo

  和jest相比,两者主要的不同就是jest内置了集成度比较高的断言库expect.js,而mocha需要搭配额外的断言库,一般会选择比较流行的chai作为断言库,这里一直提到断言库,那么什么是断言库呢?我们首先来看下mocha是怎么来测试代码的,首先我们写了一个addNum函数,但是不确定是否返回我们想要的结果,因此需要对这个函数进行测试:

//src/index.js
function addNum(a, b) {
  return a + b;
}
module.exports = addNum;
复制代码

  然后就可以写我们的测试文件了,所有的测试文件都放在test目录下,一般会将测试文件和所要测试的源码文件同名,方便进行对应,运行mocha时会自动对test目录下所有js文件进行测试:

//test/index.test.js
var addNum = require("../src/index");
describe("测试addNum函数", () => {
  it("两数相加结果为两个数字的和", () => {
    if (addNum(1, 2) !== 3) {
      throw new Error("两数相加结果不为两个数字的和");
    }
  });
});
复制代码

  上面这段代码就是测试脚本的语法,一个测试脚本会包括一个或多个describe块,每个describe又包括一个或多个it块;这里describe称为测试套件(test suite),表示一组相关的测试,它包含了两个参数,第一个参数是这个测试套件的名称,第二个参数是实际执行的函数。

  而it称为测试用例,表示一个单独的测试,是测试的最小单位,它也包含两个参数,第一个参数是测试用例的名称,第二个参数是实际执行的函数。

  it块中就是我们需要测试的代码,如果运行结果不是我们所预期的就抛出异常;上面的测试用例写好后,我们就可以运行测试了,

运行mocha

  运行结果通过了,是我们想要的结果,说明我们的函数是正确的;但是每次都通过抛出异常来判断,多少有点繁琐了,断言库就出现了;断言的目的就是将测试代码运行后和我们的预期做比较,如果和预期一致,就表明代码没有问题;如果和预期不一致,就是代码有问题了;每一个测试用例最后都会有一个断言进行判断,如果没有断言,测试就没有意义了。

  上面也说了mocha一般搭配chai断言库,而chai有好几种断言风格,比较常见的有should和expect两种风格,我们分别看下这两种断言:

var chai = require("chai"),
  expect = chai.expect,
  should = chai.should();

describe("测试addNum函数", () => {
  it("1+2", () => {
    addNum(1, 2).should.equal(3);
  });
  it("2+3", () => {
    expect(addNum(2, 3)).to.be.equal(5);
  });
});
复制代码

  这里should是后置的,在断言变量之后,而expect是前置的,作为断言的开始,两种风格纯粹看个人喜好;我们发现这里expect是从chai中获取的一个函数,而should则是直接调用,这是因为should实际上是给所有的对象都扩充了一个 getter 属性should,因此我们才能够在变量上使用.should方式来进行断言。

  和chai的多种断言风格不同,jest内置了断言库expect,它的语法又有些不同:

describe("测试addNum函数", () => {
  it("1+2", () => {
    expect(addNum(1, 2)).toBe(3);
  });
  it("2+3", () => {
    expect(addNum(2, 3)).toBe(5);
  });
});
复制代码

  jest中的expect直接通过toBe的语法,在形式上相较于mocha更为简洁;这两个框架在使用上极其相似,比如在异步代码上都支持done回调和async/await关键字,在断言语法和其他用法有些差别;两者也有相同的钩子机制,连名字都相同beforeEach和afterEach;在vue cli脚手架创建项目时,也可以在两个框架中进行选择其一,我们这里主要以jest进行测试。

Jest

  Jest是Facebook出品的一个测试框架,相较于其他测试框架,最大的特点就是内置了常用的测试工具,比如自带断言、测试覆盖率工具,实现了开箱即用,这也和它官方的slogan相符。

jest logo

Jest 是一个令人愉快的 JavaScript 测试框架,专注于简洁明快

  Jest几乎是零配置的,它会自动识别一些常用的测试文件,比如*.spec.js*.test.js后缀的测试脚本,所有的测试脚本都放在tests__tests__目录下;我们可以在全局安装jest或者局部安装,然后在packages.json中指定测试脚本:

{
  "scripts": {
    "test": "jest"
  }
}
复制代码

  当我们运行npm run test时会自动运行测试目录下所有测试文件,完成测试;我们在jest官网可能还会看到通过test函数写的测试用例:

test("1+2", () => {
  expect(addNum(1, 2)).toBe(3);
});
复制代码

  和it函数相同,test函数也代表一个测试用例,mocha只支持it,而jest支持ittest,这里为了和jest官网保持统一,下面代码统一使用test函数。

匹配器

  我们经常需要对测试代码返回的值进行匹配测试,上面代码中的toBe是最简单的一个匹配器,用来测试两个数值是否相同。

test("test tobe", () => {
  expect(2 + 2).toBe(4);
  expect(true).toBe(true);
  const val = "team";
  expect(val).toBe("team");
  expect(undefined).toBe(undefined);
  expect(null).toBe(null);
});
复制代码

  toBe函数内部使用了Object.is来进行精确匹配,它的特性类似于===;对于普通类型的数值可以进行比较,但是对于对象数组等复杂类型,就需要用到toEqual来比较了:

    test("expect a object", () => {
    var obj = {
        a: "1",
    };
    obj.b = "2";
    expect(obj).toEqual({ a: "1", b: "2" });
});

test("expect array", () => {
    var list = [];
    list.push(1);
    list.push(2);
    expect(list).toEqual([1, 2]);
});
复制代码

  我们有时候还需要对undefined、null等类型或者对条件语句中的表达式的真假进行精确匹配,Jest也有五个函数帮助我们:

  • toBeNull:只匹配null
  • toBeUndefined:只匹配undefined
  • toBeDefined:与toBeUndefined相反,等价于.not.toBeUndefined
  • toBeTruthy:匹配任何 if 语句为真
  • toBeFalsy:匹配任何 if 语句为假
test("null", () => {
    const n = null;
    expect(n).toBeNull();
    expect(n).not.toBeUndefined();
    expect(n).toBeDefined();
    expect(n).not.toBeTruthy();
    expect(n).toBeFalsy();
});
test("0", () => {
    const z = 0;
    expect(z).not.toBeNull();
    expect(z).not.toBeUndefined();
    expect(z).toBeDefined();
    expect(z).not.toBeTruthy();
    expect(z).toBeFalsy();
});
test("undefined", () => {
    const a = undefined;
    expect(a).not.toBeNull();
    expect(a).toBeUndefined();
    expect(a).not.toBeDefined();
    expect(a).not.toBeTruthy();
    expect(a).toBeFalsy();
});
复制代码

  toBeTruthy和toBeFalsy用来判断在if语句中的表达式是否成立,等价于`if(n)if(!n)``的判断。

  对于数值类型的数据,我们有时候也可以通过大于或小于来进行判断:

test("number", () => {
    const val = 2 + 2;
    // 大于
    expect(val).toBeGreaterThan(3);
    // 大于等于
    expect(val).toBeGreaterThanOrEqual(3.5);
    // 小于
    expect(val).toBeLessThan(5);
    // 小于等于
    expect(val).toBeLessThanOrEqual(4.5);
    // 完全判断
    expect(val).toBe(4);
    expect(val).toEqual(4);
});
复制代码

  浮点类型的数据虽然我们也可以用toBe和toEqual来进行比较,但是如果遇到有些特殊的浮点数据计算,比如0.1+0.2就会出现问题,我们可以通过toBeCloseTo来判断:

test("float", () => {
    // expect(0.1 + 0.2).toBe(0.3); 报错
    expect(0.1 + 0.2).toBeCloseTo(0.3);
});
复制代码

  对于数组、set或者字符串等可迭代类型的数据,可以通过toContain来判断内部是否有某一项:

test("expect iterable", () => {
    const shoppingList = [
      "diapers",
      "kleenex",
      "trash bags",
      "paper towels",
      "milk",
    ];
    expect(shoppingList).toContain("milk");
    expect(new Set(shoppingList)).toContain("diapers");
    expect("abcdef").toContain("cde");
});
复制代码

异步代码

  我们项目中经常也会涉及到异步代码,比如setTimeout、接口请求等都会涉及到异步,那么这些异步代码怎么来进行测试呢?假设我们有一个异步获取数据的函数fetchData

export function fetchData(cb) {
  setTimeout(() => {
    cb("res data");
  }, 2000);
}
复制代码

  在2秒后通过回调函数返回了一个字符串,我们可以在测试用例的函数中使用一个done的参数,Jest会等done回调后再完成测试:

test("callback", (done) => {
  function cb(data) {
    try {
      expect(data).toBe("res data");
      done();
    } catch (error) {
      done();
    }
  }
  fetchData(cb);
});
复制代码

  我们将一个回调函数传入fetchData,在回调函数中对返回的数据进行断言,在断言结束后需要调用done;如果最后没有调用done,那么Jest不知道什么时候结束,就会报错;在我们日常代码中,都会通过promise来获取数据,将我们的fetchData进行一下改写:

export function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("promise data");
    }, 2000);
  });
}
复制代码

  Jest支持在测试用例中直接返回一个promise,我们可以在then中进行断言:

test("promise callback", () => {
  return fetchData().then((res) => {
    expect(res).toBe("promise data");
  });
});
复制代码

  除了直接将fetchData返回,我们也可以在断言中使用.resolves/.rejects 匹配符,Jest也会等待promise结束:

test("promise callback", () => {
  return expect(fetchData()).resolves.toBe("promise data");
});
复制代码

  除此之外,Jest还支持async/await,不过我们需要在test的匿名函数加上async修饰符表示:

test("async/await callback", async () => {
  const data = await fetchData();
  expect(data).toBe("promise data");
});
复制代码

全局挂载与卸载

  全局挂载和卸载有点类似Vue-Router的全局守卫,在每个导航触发前和触发后做一些操作;在Jest中也有,比如我们需要在每个测试用例前初始化一些数据,或者在每个测试用例之后清除数据,就可以使用beforeEachafterEach

let cityList = []
beforeEach(() => {
  initializeCityDatabase();
});

afterEach(() => {
  clearCityDatabase();
});

test("city data has suzhou", () =>  {
  expect(cityList).toContain("suzhou")
})

test("city data has shanghai", () =>  {
  expect(cityList).toContain("suzhou")
})
复制代码

  这样,每个测试用例进行测试前都会调用init,每次结束后都会调用clear;我们有可能会在某些test中更改cityList的数据,但是在beforeEach进行初始化的操作后,每个测试用例获取的cityList数据就保证都是相同的;和上面一节异步代码一样,在beforeEachafterEach我们也可以使用异步代码来进行初始化:

let cityList = []
beforeEach(() => {
  return initializeCityDatabase().then((res)=>{
    cityList = res.data
  });
});
//或者使用async/await
beforeEach(async () => {
  cityList = await initializeCityDatabase();
});
复制代码

  和beforeEachafterEach相对应的就是beforeAllafterAll,区别就是beforeAllafterAll只会执行一次;beforeEachafterEach默认会应用到每个test,但是我们可能希望只针对某些test,我们可以通过describe将这些test放到一起,这样就只应用到describe块中的test:

beforeEach(() => {
  // 应用到所有的test
});
describe("put test together", () => {
  beforeEach(() => {
    // 只应用当前describe块中的test
  });
  test("test1", ()=> {})
  test("test2", ()=> {})
});
复制代码

模拟函数

  在项目中,一个模块的函数内常常会去调用另外一个模块的函数。在单元测试中,我们可能并不需要关心内部调用的函数的执行过程和结果,只想知道被调用模块的函数是否被正确调用,甚至会指定该函数的返回值,因此模拟函数十分有必要。

  如果我们正在测试一个函数forEach,它的参数包括了一个回调函数,作用在数组上的每个元素:

export function forEach(items, callback) {
  for (let index = 0; index < items.length; index++) {
    callback(items[index]);
  }
}
复制代码

  为了测试这个forEach,我们需要构建一个模拟函数,来检查模拟函数是否按照预期被调用了:

test("mock callback", () => {
  const mockCallback = jest.fn((x) => 42 + x);
  forEach([0, 1, 2], mockCallback);
  expect(mockCallback.mock.calls.length).toBe(3);
  expect(mockCallback.mock.calls[0][0]).toBe(0);
  expect(mockCallback.mock.calls[1][0]).toBe(1);
  expect(mockCallback.mock.calls[2][0]).toBe(1);
  expect(mockCallback.mock.results[0].value).toBe(42);
});
复制代码

  我们发现在mockCallback有一个特殊的.mock属性,它保存了模拟函数被调用的信息;我们打印出来看下:

mock属性

  它有四个属性:

  • calls:调用参数
  • instances:this指向
  • invocationCallOrder:函数调用顺序
  • results:调用结果

  在上面属性中有一个instances属性,表示了函数的this指向,我们还可以通过bind函数来更改我们模拟函数的this:

test("mock callback", () => {
    const mockCallback = jest.fn((x) => 42 + x);
    const obj = { a: 1 };
    const bindMockCallback = mockCallback.bind(obj);
    forEach([0, 1, 2], bindMockCallback);
    expect(mockCallback.mock.instances[0]).toEqual(obj);
    expect(mockCallback.mock.instances[1]).toEqual(obj);
    expect(mockCallback.mock.instances[2]).toEqual(obj);
});
复制代码

  通过bind更改函数的this之后,我们可以用instances来进行检测;模拟函数可以在运行时将返回值进行注入:

const myMock = jest.fn();
// undefined
console.log(myMock());

myMock
    .mockReturnValueOnce(10)
    .mockReturnValueOnce("x")
    .mockReturnValue(true);

//10 x true true
console.log(myMock(), myMock(), myMock(), myMock());

myMock.mockReturnValueOnce(null);

// null true true
console.log(myMock(), myMock(), myMock());
复制代码

  我们第一次执行myMock,由于没有注入任何返回值,然后通过mockReturnValueOncemockReturnValue进行返回值注入,Once只会注入一次;模拟函数在连续性函数传递返回值时使用注入非常的有用:

const filterFn = jest.fn();
filterFn.mockReturnValueOnce(true).mockReturnValueOnce(false);
const result = [2, 3].filter((num) => filterFn(num));
expect(result).toEqual([2]);
复制代码

  我们还可以对模拟函数的调用情况进行断言:

const mockFunc = jest.fn();

// 断言函数还没有被调用
expect(mockFunc).not.toHaveBeenCalled();
mockFunc(1, 2);
mockFunc(2, 3);
// 断言函数至少调用一次
expect(mockFunc).toHaveBeenCalled();
// 断言函数调用参数
expect(mockFunc).toHaveBeenCalledWith(1, 2);
expect(mockFunc).toHaveBeenCalledWith(2, 3);
// 断言函数最后一次的调用参数
expect(mockFunc).toHaveBeenLastCalledWith(2, 3);
复制代码

  除了能对函数进行模拟,Jest还支持拦截axios返回数据,假如我们有一个获取用户的接口:

// /src/api/users
const axios = require("axios");

function fetchUserData() {
  return axios
    .get("/user.json")
    .then((resp) => resp.data);
}

module.exports = {
  fetchUserData,
};
复制代码

  现在我们想要测试fetchUserData函数获取数据但是并不实际请求接口,我们可以使用jest.mock来模拟axios模块:

const users = require("../api/users");
const axios = require("axios");
jest.mock("axios");

test("should fetch users", () => {
  const userData = {
    name: "aaa",
    age: 10,
  };
  const resp = { data: userData };

  axios.get.mockResolvedValue(resp);

  return users.fetchUserData().then((res) => {
    expect(res).toEqual(userData);
  });
});
复制代码

  一旦我们对模块进行了模拟,我们可以用get函数提供一个mockResolvedValue方法,以返回我们需要测试的数据;通过模拟后,实际上axios并没有去真正发送请求去获取/user.json的数据。

Vue Test Utils

  Vue Test Utils是Vue.js官方的单元测试实用工具库,能够对我们编写的Vue组件进行测试。

挂载组件

  在Vue中我们通过import引入组件,然后在components进行注册后就能使用;在单元测试中,我们使用mount来进行挂载组件;假如我们写了一个计数器组件counter.js,用来展示count,并且有一个按钮操作count:

<!-- Counter.vue -->
<template>
  <div class="counter">
    <span class="count">{{ count }}</span>
    <button id="add" @click="add"></button>
  </div>
</template>
<script>
export default {
  data() {
    return {
      count: 0,
    };
  },
  methods: {
    add() {
      this.count++;
    },
  },
};
</script>
复制代码

  组件进行挂载后得到一个wrapper(包裹器),wrapper会暴露很多封装、遍历和查询其内部的Vue组件实例的便捷的方法。

import { mount } from "@vue/test-utils";
import Counter from "@/components/Counter";
const wrapper = mount(Counter);
const vm = wrapper.vm;
复制代码

  我们可以通过wrapper.vm来访问组件的Vue实例,进而获取实例上的methods和data等;通过wrapper,我们可以对组件的渲染情况做断言:

// test/unit/counter.spec.js
describe("Counter", () => {
  const wrapper = mount(Counter);
  test("counter class", () => {
    expect(wrapper.classes()).toContain("counter");
    expect(wrapper.classes("counter")).toBe(true);
  });
  test("counter has span", () => {
    expect(wrapper.html()).toContain("<span class="count">0</span>");
  });
  test("counter has btn", () => {
    expect(wrapper.find("button#add").exists()).toBe(true);
    expect(wrapper.find("button#add").exists()).not.toBe(false);
  });
});
复制代码

  上面几个函数我们根据名字也能猜出它们的作用:

  • classes:获取wrapper的class,并返回一个数组
  • html:获取组件渲染html结构字符串
  • find:返回匹配子元素的wrapper
  • exists:断言wrapper是否存在

  find返回的是查找的第一个DOM节点,但有些情况我们希望能操作一组DOM,我们可以用findAll函数:

const wrapper = mount(Counter);
// 返回一组wrapper
const divList = wrapper.findAll('div');
divList.length
// 找到第一个div,返回它的wrapper
const firstDiv = divList.at(0);
复制代码

  有些组件需要通过外部传入的props、插槽slots、provide/inject等其他的插件或者属性,我们在mount挂载时可以传入一个对象,设置这些额外属性:

const wrapper = mount(Component, {
  // 向组件传入data,合并到现有的data中
  data() {
    return {
      foo: "bar"
    }
  },
  // 设置组件的props
  propsData: {
    msg: "hello"
  },
  // vue本地拷贝
  localVue,
  // 伪造全局对象
  mocks: {
    $route
  },
  // 插槽
  // 键名就是相应的 slot 名
  // 键值可以是一个组件、一个组件数组、一个字符串模板或文本。
  slots: {
    default: SlotComponent,
    foo: "<div />",
    bar: "<my-component />",
    baz: ""
  },
  // 用来注册自定义组件
  stubs: {
    "my-component": MyComponent,
    "el-button": true,
  },
  // 设置组件实例的$attrs 对象。
  attrs: {},
  // 设置组件实例的$listeners对象。
  listeners: {
    click: jest.fn()
  },
  // 为组件传递用于注入的属性
  provide: {
    foo() {
      return "fooValue"
    }
  }
})
复制代码

  stubs主要用来处理在全局注册的自定义组件,比如我们常用的组件库Element等,直接使用el-buttonel-input组件,或者vue-router注册在全局的router-view组件等;当我们在单元测试中引入时就会提示我们对应的组件找不到,这时我们就可以通过这个stubs来避免报错。

  我们在对某个组件进行单元测试时,希望只针对单一组件进行测试,避免子组件带来的副作用;比如我们在父组件ParentComponent中判断是否有某个div时,恰好子组件ChildComponent也渲染了该div,那么就会对我们的测试带来一定的干扰;我们可以使用shallowMount挂载函数,相遇比mount,shallowMount不会渲染子组件:

import { shallowMount } from '@vue/test-utils'
const wrapper = shallowMount(Component)
复制代码

  这样就保证了我们需要测试的组件在渲染时不会渲染其子组件,避免子组件的干扰。

操作组件

  我们经常需要对子组件中的元素或者子组件的数据进行一些操作和修改,比如页面的点击、修改data数据,进行操作后再来断言数据是否正确;我们以一个简单的Form组件为例:

<template>
  <div class="form">
    <div class="title">{{ title }}</div>
    <div>
      <span>请填写姓名:</span>
      <input type="text" id="name-input" v-model="name" />
      <div class="name">{{ name }}</div>
    </div>
    <div>
      <span>请选择性别:</span>
      <input type="radio" name="sex" v-model="sex" value="f" id="" />
      <input type="radio" name="sex" v-model="sex" value="m" id="" />
    </div>
    <div>
      <span>请选择爱好:</span>
      footbal
      <input
        type="checkbox"
        name="hobby"
        v-model="hobby"
        value="footbal"
      />
      basketball
      <input
        type="checkbox"
        name="hobby"
        v-model="hobby"
        value="basketball"
      />
      ski
      <input type="checkbox" name="hobby" v-model="hobby" value="ski" />
    </div>
    <div>
      <input
        :class="submit ? 'submit' : ''"
        type="submit"
        value="提交"
        @click="clickSubmit"
      />
    </div>
  </div>
</template>
<script>
export default {
  name: "Form",
  props: {
    title: {
      type: String,
      default: "表单名称",
    },
  },
  data() {
    return {
      name: "",
      sex: "f",
      hobby: [],
      submit: false,
    };
  },
  methods: {
    clickSubmit() {
      this.submit = !this.submit;
    },
  },
};
</script>
复制代码

  我们可以向Form表单组件传入一个title,作为表单的名称,其内部也有input、radio和checkbox等一系列元素,我们就来看下怎么对这些元素进行修改;首先我们来修改props的值,在组件初始化的时候我们传入了propsData,在后续的代码中我们可以通过setProps对props值进行修改:

const wrapper = mount(Form, {
  propsData: {
    title: "form title",
  },
});
const vm = wrapper.vm;
test("change prop", () => {
  expect(wrapper.find(".title").text()).toBe("form title");
  wrapper.setProps({
    title: "new form title",
  });
  // 报错了
  expect(wrapper.find(".title").text()).toBe("new form title");
});
复制代码

  我们满怀期待进行测试,但是发现最后一条断言报错了;这是因为Vue异步更新数据,我们改变prop和data后,获取dom发现数据并不会立即更新;在页面上我们一般都会通过$nextTick进行解决,在单元测试时,我们也可以使用nextTick配合获取DOM:

test("change prop1", async () => {
  expect(wrapper.find(".title").text()).toBe("new form title");
  wrapper.setProps({
    title: "new form title1",
  });
  await Vue.nextTick();
  // 或者使用vm的nextTick
  // await wrapper.vm.nextTick();
  expect(wrapper.find(".title").text()).toBe("new form title1");
});

test("change prop2", (done) => {
  expect(wrapper.find(".title").text()).toBe("new form title1");
  wrapper.setProps({
    title: "new form title2",
  });
  Vue.nextTick(() => {
    expect(wrapper.find(".title").text()).toBe("new form title2");
    done();
  });
});
复制代码

  和Jest中测试异步代码一样,我们也可以使用done回调或者async/await来进行异步测试;除了设置props,setData可以用来改变wrapper中的data:

test("test set data", async () => {
  wrapper.setData({
    name: "new name",
  });
  expect(vm.name).toBe("new name");
  await Vue.nextTick();
  expect(wrapper.find(".name").text()).toBe("new name");
});
复制代码

  对于input、textarea或者select这种输入性的组件元素,我们有两种方式来改变他们的值:

test("test input set value", async () => {
  const input = wrapper.find("#name-input");
  await input.setValue("change input by setValue");
  expect(vm.name).toBe("change input by setValue");
  expect(input.element.value).toBe("change input by setValue");
});
// 等价于
test("test input trigger", () => {
  const input = wrapper.find("#name-input");
  input.element.value = "change input by trigger";
  // 通过input.element.value改变值后必须触发trigger才能真正修改
  input.trigger("input");
  expect(vm.name).toBe("change input by trigger");
});
复制代码

  可以看出,通过input.element.value或者setValue的两种方式改变值后,由于v-model绑定关系,因此vm中的data数据也进行了改变;我们还可以通过input.element.value来获取input元素的值。

  对于radio、checkbox选择性的组件元素,我们可以通过setChecked(Boolean)函数来触发值的更改,更改同时也会更新元素上v-model绑定的值:

test("test radio", () => {
  expect(vm.sex).toBe("f");
  const radioList = wrapper.findAll('input[name="sex"]');
  radioList.at(1).setChecked();
  expect(vm.sex).toBe("m");
});
test("test checkbox", () => {
  expect(vm.hobby).toEqual([]);
  const checkboxList = wrapper.findAll('input[name="hobby"]');
  checkboxList.at(0).setChecked();
  expect(vm.hobby).toEqual(["footbal"]);
  checkboxList.at(1).setChecked();
  expect(vm.hobby).toEqual(["footbal", "basketball"]);
  checkboxList.at(0).setChecked(false);
  expect(vm.hobby).toEqual(["basketball"]);
});
复制代码

  对于按钮等元素,我们希望在上面触发点击操作,可以使用trigger进行触发:

test("test click", async () => {
  const submitBtn = wrapper.find('input[type="submit"]');
  await submitBtn.trigger("click");
  expect(vm.submit).toBe(true);
  await submitBtn.trigger("click");
  expect(vm.submit).toBe(false);
});
复制代码

自定义事件

  对于一些组件,可能会通过$emit触发一些返回数据,比如我们改写上面Form表单中的submit按钮,点击后返回一些数据:

{
  methods: {
    clickSubmit() {
      this.$emit("foo", "foo1", "foo2");
      this.$emit("bar", "bar1");
    },
  },
}
复制代码

  除了触发组件中元素的点击事件进行$emi,我们还可以通过wrapper.vm触发,因为vm本身相当于组件的this

wrapper.vm.$emit("foo", "foo3");
复制代码

  最后,所有$emit触发返回的数据都存储在wrapper.emitted(),它返回了一个对象;结构如下:

{
    foo: [ [ 'foo1', 'foo2' ], [ 'foo3' ] ],
    bar: [ [ 'bar1' ] ]
}
复制代码

  emitted()返回对象中的属性是一个数组,数组的length代表了这个方法被触发了多少次;我们可以对对象上的属性进行断言,来判断组件的emit是否被触发:

test("test emit", async () => {
  // 组件元素触发emit
  await wrapper.find('input[type="submit"]').trigger("click");
  wrapper.vm.$emit("foo", "foo3");
  await vm.$nextTick();
  // foo被触发过
  expect(wrapper.emitted().foo).toBeTruthy();
  // foo触发过两次
  expect(wrapper.emitted().foo.length).toBe(2);
  // 断言foo第一次触发的数据
  expect(wrapper.emitted().foo[0]).toEqual(["foo1", "foo2"]);
  // baz没有触发
  expect(wrapper.emitted().baz).toBeFalsy();
});
复制代码

  我们也可以把emitted()函数进行改写,并不是一次性获取整个emitted对象

expect(wrapper.emitted('foo')).toBeTruthy();
expect(wrapper.emitted('foo').length).toBe(2);
复制代码

  有一些组件触发emit事件可能是由其子组件触发的,我们可以通过子组件的vm进行emit:

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

describe('ParentComponent', () => {
  it("emit", () => {
    const wrapper = mount(ParentComponent)
    wrapper.find(ChildComponent).vm.$emit('custom')
  })
})
复制代码

配合Vue-Router

  在有些组件中,我们有可能会用到Vue-Router的相关组件或者Api方法,比如我们有一个Header组件:

<template>
  <div>
    <div @click="jump">{{ $route.params.id }}</div>
    <router-link :to="{ path: '/detail' }"></router-link>
    <router-view></router-view>
  </div>
</template>
<script>
export default {
  data() {
    return {};
  },
  mounted() {},
  methods: {
    jump() {
      this.$router.push({
        path: "/list",
      });
    },
  },
};
</script>
复制代码

  直接在测试脚本中引入会报错,提示找不到router-linkrouter-view两个组件和$route属性;这里不推荐使用Vue.use(VueRouter),因为会污染全局的Vue;我们有两种方法解决,第一种使用createLocalVue 创建一个Vue的类,我们可以在这个类中进行添加组件、混入和安装插件而不会污染全局的Vue类:

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

// 一个Vue类
const localVue = createLocalVue()
localVue.use(VueRouter)
// 路由数组
const routes = []
const router = new VueRouter({
  routes
})

shallowMount(Header, {
  localVue,
  router
})
复制代码

  我们来看下这里做了哪些操作,通过createLocalVue创建了一个localVue,相当于import Vue;然后localVue.use告诉Vue来使用VueRouter,和Vue.use有着相同的作用;最后实例化创建router对象传入shallowMount进行挂载。

  第二种方式是注入伪造数据,这里主要用的就是mocksstubsmocks用来伪造routeroute和router等全局对象,是一种将属性添加到Vue.prototype上的方式;而stubs用来覆写全局或局部注册的组件:

import { mount } from "@vue/test-utils";
import Header from "@/components/Header";

describe("header", () => {
  const $route = {
    path: "/home",
    params: {
      id: "111",
    },
  };
  const $router = {
    push: jest.fn(),
  };
  const wrapper = mount(Header, {
    stubs: ["router-view", "router-link"],
    mocks: {
      $route,
      $router,
    },
  });
  const vm = wrapper.vm;
  test("render home div", () => {
    expect(wrapper.find("div").text()).toBe("111");
  });
});
复制代码

  相比于第一种方式,第二种方式可操作性更强,可以直接伪造$route路由的数据;一般第一种方式不会单独使用,经常会搭配第二种伪造数据的方式。

配合Vuex

  我们通常会在组件中会用到vuex,我们可以通过伪造store数据来模拟测试,假如我们有一个的count组件,它的数据存放在vuex中:

<template>
  <div>
    <div class="number">{{ number }}</div>
    <div class="add" @click="clickAdd">add</div>
    <div class="sub" @click="clickSub">sub</div>
  </div>
</template>
<script>
import { mapState, mapGetters } from "vuex";
export default {
  name: "Count",
  computed: {
    ...mapState({
      number: (state) => state.number,
    }),
  },
  methods: {
    clickAdd() {
      this.$store.commit("ADD_COUNT");
    },
    clickSub() {
      this.$store.commit("SUB_COUNT");
    },
  },
};
</script>
复制代码

  在vuex中我们通过mutations对number进行修改:

export default new Vuex.Store({
  state: {
    number: 0,
  },
  mutations: {
    ADD_COUNT(state) {
      state.number = state.number + 1;
    },
    SUB_COUNT(state) {
      state.number = state.number - 1;
    },
  }
});
复制代码

  那我们现在如何来伪造store数据呢?这里和Vue-Router的原理是一样的,通过createLocalVue创建一个隔离的Vue类:

import { mount, createLocalVue } from "@vue/test-utils";
import Count from "@/components/Count";
import Vuex from "vuex";

const localVue = createLocalVue();
localVue.use(Vuex);

describe("count", () => {
  const state = {
    number: 0,
  };
  const mutations = {
    ADD_COUNT: jest.fn(),
    SUB_COUNT: jest.fn(),
  };
  const store = new Vuex.Store({
    state,
    mutations
  });
  test("render", async () => {
    const wrapper = mount(Count, {
      store,
      localVue,
    });
    expect(wrapper.find(".number").text()).toBe("0");
    wrapper.find(".add").trigger("click");
    expect(mutations.ADD_COUNT).toHaveBeenCalled();
    expect(mutations.SUB_COUNT).not.toHaveBeenCalled();
  });
});
复制代码

  我们看一下这里做了什么操作,前面和VueRouter一样创建一个隔离类localVue;然后通过new Vuex.Store创建了一个store并填入假数据state和mutations;这里我们并不关心mutations中函数做了哪些操作,我们只要知道元素点击触发了哪个mutations函数,通过伪造的函数我们去断言mutations是否被调用。

  另一种测试store数据的方式是创建一个运行中的store,不再通过页面触发Vuex中的函数,这样的好处就是不需要伪造Vuex函数;假设我们有一个store/list.js

export default {
  state: {
    list: [],
  },
  getters: {
    joinList: (state) => {
      return state.list.join(",");
    },
  },
  mutations: {
    PUSH(state, payload) {
      state.list.push(payload);
    },
  },
};
复制代码
import { createLocalVue } from "@vue/test-utils";
import Vuex from "vuex";
import { cloneDeep } from "lodash";
import listStore from "@/store/list";

describe("list", () => {
  test("expect list", () => {
    const localVue = createLocalVue();
    localVue.use(Vuex);
    const store = new Vuex.Store(cloneDeep(listStore));
    expect(store.state.list).toEqual([]);
    store.commit("PUSH", "1");
    expect(store.state.list).toEqual(["1"]);
  });
  test("list getter", () => {
    const localVue = createLocalVue();
    localVue.use(Vuex);
    const store = new Vuex.Store(cloneDeep(listStore));

    expect(store.getters.joinList).toBe("");
    store.commit("PUSH", "1");
    store.commit("PUSH", "3");
    expect(store.getters.joinList).toBe("1,3");
  });
});
复制代码

  我们直接创建了一个store,通过store来进行commit和getters的操作。

总结

  前端框架迭代不断,但是前端单元测试确显有人关注;一个健壮的前端项目应该有单元测试的模块,保证了我们的项目代码质量和功能的稳定;但是也并不是所有的项目都需要有单元测试的,毕竟编写测试用例也需要成本;因此如果你的项目符合下面的几个条件,就可以考虑引入单元测试:

  • 长期稳定的项目迭代,需要保证代码的可维护性和功能稳定;
  • 页面功能相对来说说比较复杂,逻辑较多;
  • 对于一些复用性很高的组件,可以考虑单元测试;

  

更多前端资料请关注公众号【前端壹读】

如果觉得写得还不错,请关注我的掘金主页。更多文章请访问谢小飞的博客

文章分类
前端
文章标签