# 自动化测试

作者: 王振州 时间: 2020-12-24

目前开发大型应用,测试是一个非常重要的环节,但是大多数前端开发者对测试相关的知识是比较缺乏的,因为可能项目开发周期短根本没有机会写。所以你没有办法体会到前端自动化测试的重要性!

DEMO 地址

# 1 为什么要引入自动化测试?

我们来看看我们在写代码的时候经常遇到的问题:

  • 修改某个模块功能的时候,其他模块也受到了影响,很难快速定位/发现 bug
  • 很多人开发代码越来越难以维护
  • 代码质量差,考虑不周全,健壮性差

增加自动化测试后:

  • 为核心功能编写测试后可以保障项目的稳定和可靠性
  • 强迫开发者,编写更容易被测试的代码,提高代码质量
  • 编写的测试可以起到文档的作用,方便维护,统一存放测试代码,开发时可以直接看测试即可,保证代码尽量简洁

# 2 测试分类

# 2.1 单元测试(Unit Testing)

单元测试是指对程序中最小可测试单元进行的测试,例如测试一个函数一个模块一个组件...

# 2.2 集成测试(Integration Testing)

将已测试过的单元测试函数进行组合集成暴露出的高层函数或类的封装,对这些函数或类进行的测试

# 2.3 端到端测试(E2E Testing)

打开应用程序模拟输入,检查功能以及界面是否正确

# 3 测试框架

  • Karma Karma为前端自动化测试提供了跨浏览器测试的能力,可以在浏览器中执行测试用例
  • Mocha 前端自动化测试框架,需要配合其他库一起使用,像 chai、sinon...
  • Jest Jest 是Facebook推出的一款测试框架,集成了 Mocha,chai,jsdom,sinon等功能。

ReactVue 默认使用的是jest, jest也有一些缺陷就是不能像karam这样直接跑早浏览器上,它采用的是jsdom,优势是简单、0 配置! 后续我们通过 jest 来聊前端自动化测试

# 4 Jest核心应用

Jest 官网

# 4.1 安装 jest

yarn init -y # 初始化package.json
yarn add jest
1
2

通过配置package.json中的scripts 来执行命令

"scripts": {
    "test": "jest"
}
1
2
3

但是一般我们的项目是使用ES6语法来进行开发的,而node默认是不支持的,所以需要 babel 进行转义

# core是babel的核心包 preset-env将es6转化成es5
yarn add @babel/core @babel/preset-env
1
2

配置.babelrc文件

{
  "presets": [
    [
      // https://www.babeljs.cn/docs/babel-preset-env
      "@babel/preset-env",
      // 预设options
      {
        // 描述项目支持的环境/目标。
        "targets": {
          // 如果希望根据当前node版本进行编译,可以指定“node”:true或“node”:“current”,这将与“node”:process.versions.node相同。
          "node": "current"
        }
      }
    ]
  ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

默认jest中集成了babel-jest,运行时默认会调用.babelrc进行转义,可以直接将ES6转成ES5语法, 这样我们就可以执行yarn jest

# 4.2 测试文件

Jest默认会调用执行以.test.js和.spec.js结尾的文件

我们先写一个 qs.js 的文件包含两个待测试的函数

// qs.js
// 可以转换字符串变成对象   a=1&b=2 ==>> { "a": 1, "b": 2 }
export const parser = (str) => {
  const obj = {};
  // /([^&=?]+)=([^&=?]+)/g   全局匹配  中间是 =  左、右侧不是 = & ?
  str.replace(/([^&=?]+)=([^&=?]+)/g, function () {
    const [, key, value] = arguments;
    // 看是否可以转换成
    const valueN = +value;
    // 赋值
    obj[key] = isNaN(valueN) ? value : valueN;
  });
  return obj;
};
// 对象反转字符串 { "a": 1, "b": 2} ==>> a=1&b=2
export const stringify = (obj) => {
  const arr = [];
  for (const key in obj) {
    arr.push(`${key}=${obj[key]}`);
  }
  return arr.join("&");
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

测试用例

// qs.test.js
import { parser, stringify } from "./qs";

describe("qs.spec", () => {
  it("测试 parser 是否能正常解析结果", () => {
    // expect 断言,判断解析出来的结果是否和 {name:'xw'}相等
    expect(parser(`name=xw`)).toEqual({
      name: "xw",
    });
  });
  test("测试 stringify 是否能正常解析结果", () => {
    expect(stringify({ name: "xw" })).toEqual(`name=xw`);
  });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 4.3 jest 术语

describe("qs.spec", () => {
  it("测试 parser 是否能正常解析结果", () => {
    // expect 断言,判断解析出来的结果是否和 {name:'xw'}相等
    expect(parser(`name=xw`)).toEqual({
      name: "xw",
    });
  });
  test("测试 stringify 是否能正常解析结果", () => {
    expect(stringify({ name: "xw" })).toEqual(`name=xw`);
  });
});
1
2
3
4
5
6
7
8
9
10
11
  • describe 描述, decribe会形成一个分组或作用域
  • it 断言,断定这些是不是我所期望发生的
  • expect 期望
  • test 测试,类似it

# 4.3 matchers 匹配器

在写第一个测试用例时,我们一直在使用toEqual其实这就是一个匹配器,那我们来看看jest中常用的匹配器有哪些?因为匹配器太多了,所以我就说一些常用的!

测试的方法可以有 N 多中写法都可以达到效果,所以为了方便理解,可以把匹配器分为三类:判断相等、不等、是否包含

it("判断是否相等", () => {
  expect(1 + 1).toBe(2); // 相等于 js中的===
  expect({ name: "xw" }).toEqual({ name: "xw" }); // 比较内容是否相等
  expect(true).toBeTruthy(); // 是否为 true / false 也可以用toBe(true)
  expect(false).toBeFalsy();
  expect(null).toBeNull(); // 是否为 null
  expect(undefined).toBeUndefined(); // 是否为 undefined
});

it("判断不相等关系", () => {
  expect(1 + 1).not.toBe(3); // not取反
  expect(1 + 1).toBeLessThan(5); // js中的小于
  expect(1 + 1).toBeGreaterThan(1); // js中的大于
});

it("判断是否包含", () => {
  expect("hello world").toContain("hello"); // 是否包含
  expect("hello world").toMatch(/hello/); // 正则
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 4.4 测试操作节点方法

我们来写一个删除节点的方法,基于 jsdom,在 node 中模拟操作 dom 元素

// dom.js
export const removeNode = (node) => {
  node.parentNode.removeChild(node);
};
1
2
3
4

核心就是测试传入一个节点,这个节点是否能从DOM中删除

// dom.spec.js
import { removeNode } from "../dom";

describe("dom 测试", () => {
  it("测试删除节点", () => {
    // jest js-dom 可以在node环境下模拟一套dom结构
    // 先创建一个节点 扔到页面中,调用删除方法 再去看一下这个节点是否消失了
    document.body.innerHTML = `<div><button data-btn="btn"></button</div>`;
    let btn = document.querySelector('[data-btn="btn"]');
    expect(btn).not.toBeNull(); // 说明扔到页面中了
    removeNode(btn); // 删除逻辑
    btn = document.querySelector('[data-btn="btn"]'); // 这里没有dom映射
    expect(btn).toBeNull();
  });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 4.5 watchAll

当我们希望每次更改测试后,自动重新执行测试,修改执行命令:(但是要配合git使用)

"scripts": {
    "test": "jest --watchAll"
  // "test": "jest --watch" // 检测变化的文件
}
1
2
3
4

重新执行 yarn test,

› Press f to run only failed tests. // 运行只运行失败用例
 › Press o to only run tests related to changed files. // 只运行有更改的文件  需要配合git  因为git知道哪些文件变化了
 › Press p to filter by a filename regex pattern. // 匹配某个文件取运行
 › Press t to filter by a test name regex pattern. // 用测试的名字去过滤
 › Press q to quit watch mode. // 退出
 › Press Enter to trigger a test run. // 触发运行
1
2
3
4
5
6

这时就会监控文件的修改重新执行

# 5 Jest 进阶使用

# 5.1 异步函数测试

说到异步函数无非就两种情况,一种是回调函数的方式,一种就是目前比较流行的 promise 的方式

我们先写两个异步函数

// async.js
export const getDataThroughCallback = (fn) => {
  setTimeout(() => {
    fn({ name: "xw" });
  }, 1000);
};
export const getDataThroughPromise = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ name: "xw" });
    }, 1000);
  });
};
1
2
3
4
5
6
7
8
9
10
11
12
13

编写async.spec.js

import { getDataThroughCallback, getDataThroughPromise } from "../async.js";

// 1. 默认测试用例不会等待测试完成,所以增加done参数,当完成时调用done函数
it("测试传入回调函数 获取异步返回结果", (done) => {
  // 异步测试方法可以通过done
  getDataThroughCallback((data) => {
    expect(data).toEqual({ name: "xw" });
    done();
  });
});
// 2. 返回一个promise 会等待这个promise执行完成
it("测试promise 返回结果 1", () => {
  return getDataThroughPromise().then((data) => {
    expect(data).toEqual({ name: "xw" });
  });
});
// 3. 直接使用async + await语法
it("测试promise 返回结果 2", async () => {
  let data = await getDataThroughPromise();
  expect(data).toEqual({ name: "xw" });
});
// 4. 使用自带匹配器
it("测试promise 返回结果 3", async () => {
  expect(getDataThroughPromise()).resolves.toMatchObject({ name: "xw" });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

# 5.2 mock

# 5.2.1 模拟函数 jest.fn

通过 jest.fn 声明的函数可以被追溯, 可以记录查看具体的执行过程!

// map.js
export const myMap = (arr, fn) => {
  return arr.map(fn);
};
1
2
3
4
import { myMap } from "../map";

// 通过jest.fn声明的函数可以被追溯

describe("jest.fn", () => {
  it("测试 map 方法", () => {
    let fn = jest.fn((item) => (item *= 2));

    expect(myMap([1, 2, 3], fn)).toEqual([2, 4, 6]);

    // 调用3次
    expect(fn.mock.calls.length).toBe(3);

    // 第一次调用函数时的第一个参数是 1
    expect(fn.mock.calls[0][0]).toBe(1);

    // 第二次调用函数时的第一个参数是 1
    expect(fn.mock.calls[1][0]).toBe(2);

    // 第一次函数调用的返回值是 2
    expect(fn.mock.results[0].value).toBe(2);

    // 每次函数返回的值是 2,4,6
    expect(fn.mock.results.map((item) => item.value)).toEqual([2, 4, 6]);
  });
  it("测试 fn 是否执行 方法", () => {
    let fn = jest.fn((item) => (item *= 2));
    myMap([1], fn);
    expect(fn).toHaveBeenCalled();
  });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

看下上面的 fn.mock 都有什么

{
  "calls": [
    [
      // 函数参数
      1,
      0,
      [1, 2, 3]
    ],
    [2, 1, [1, 2, 3]],
    [3, 2, [1, 2, 3]]
  ],
  // ...
  // 返回值
  "results": [
    {
      "type": "return",
      "value": 2
    },
    {
      "type": "return",
      "value": 4
    },
    {
      "type": "return",
      "value": 6
    }
  ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

# 5.2.2 模拟文件 jest.mock

我们希望对接口进行 mock,可以直接在__mocks__目录下创建同名文件,将整个文件 mock 掉,例如当前文件叫api.js

import axios from "axios";

export const fetchUser = () => {
  return axios.get("/user");
};
export const fetchList = () => {
  return axios.get("/list");
};
1
2
3
4
5
6
7
8

创建__mocks__/api.js

export const fetchUser = () => {
  return new Promise((resolve, reject) => resolve({ user: "xw" }));
};
export const fetchList = () => {
  return new Promise((resolve, reject) => resolve(["香蕉", "苹果"]));
};
1
2
3
4
5
6

开始测试

jest.mock("../api.js"); // 使用__mocks__ 下的api.js
import { fetchList, fetchUser } from "../api"; // 引入mock的方法
it("fetchUser测试", async () => {
  let data = await fetchUser();
  expect(data).toEqual({ user: "xw" });
});

it("fetchList测试", async () => {
  let data = await fetchList();
  expect(data).toEqual(["香蕉", "苹果"]);
});
1
2
3
4
5
6
7
8
9
10
11

# 5.2.3 模拟 Timer

接着来看下个案例,我们期望传入一个 callback,想看下 callback 能否被调用!

export const timer = (callback) => {
  setTimeout(() => {
    callback();
  }, 2000);
};
1
2
3
4
5

因此我们很容易写出了这样的测试用例

import { timer } from "./timer";
it("callback 是否会执行", (done) => {
  let fn = jest.fn();
  timer(fn);
  setTimeout(() => {
    expect(fn).toHaveBeenCalled();
    done();
  }, 2500);
});
1
2
3
4
5
6
7
8
9

有没有觉得很愚蠢,如果时间很长呢? 很多个定时器呢?这时候我们想到了mock Timer

import { timer } from "../timer";
jest.useFakeTimers();
it("callback 是否会执行", () => {
  let fn = jest.fn();
  timer(fn);
  // 运行所有定时器,如果需要测试的代码是个秒表呢?
  // jest.runAllTimers();

  // 将时间向后移动2.5s
  // jest.advanceTimersByTime(2500);

  // 只运行当前等待定时器
  jest.runOnlyPendingTimers();
  expect(fn).toHaveBeenCalled();
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 5.3 jest 中的钩子函数

为了测试的便利,Jest 中也提供了类似于 Vue 一样的钩子函数,可以在执行测试用例前或者后来执行

class Counter {
  constructor() {
    this.count = 0;
  }
  add(count) {
    this.count += count;
  }
}
module.exports = Counter;
1
2
3
4
5
6
7
8
9

我们要测试Counter类中add方法是否符合预期,来编写测试用例

import Counter from "../hook";
it("测试  counter增加 1 功能", () => {
  let counter = new Counter(); // 每个测试用例都需要创建一个counter实例,防止相互影响
  counter.add(1);
  expect(counter.count).toBe(1);
});

it("测试  counter增加 2 功能", () => {
  let counter = new Counter();
  counter.add(2);
  expect(counter.count).toBe(2);
});
1
2
3
4
5
6
7
8
9
10
11
12

我们发现每个测试用例都需要基于一个新的counter实例来测试,防止测试用例间的相互影响,这时候我们可以把重复的逻辑放到钩子中!

钩子函数

  • beforeAll 在所有测试用例执行前执行
  • afterAll 在所有测试用例执行后
  • beforeEach 在每个用例执行前
  • afterEach 在每个用例执行后

钩子函数可以多次注册,一般我们通过 describe 来划分作用域

import Counter from "../hook";
let counter = null;
beforeAll(() => console.log("before all"));
afterAll(() => console.log("after all"));
beforeEach(() => {
  counter = new Counter();
});
describe("划分作用域", () => {
  beforeAll(() => console.log("inner before")); // 这里注册的钩子只对当前describe下的测试用例生效
  afterAll(() => console.log("inner after"));
  it("测试  counter增加 1 功能", () => {
    counter.add(1);
    expect(counter.count).toBe(1);
  });
});
it("测试  counter增加 2 功能", () => {
  counter.add(2);
  expect(counter.count).toBe(2);
});
// before all => inner before=> inner after => after all
// 执行顺序很像洋葱模型 ^-^
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 5.4 jest 中的配置文件

我们可以通过 jest 命令生成 jest 的配置文件

yarn jest --init
1

会提示我们选择配置项:

yarn jest --init
The following questions will help Jest to create a suitable configuration for your project
# 使用jsdon
✔ Choose the test environment that will be used for testing › jsdom (browser-like)
# 添加覆盖率
✔ Do you want Jest to add coverage reports? … yes
# 每次运行测试时会清除所有的mock
✔ Automatically clear mock calls and instances between every test? … yes
1
2
3
4
5
6
7
8

在当前目录下会产生一个jest.config.js的配置文件

一般项目中可能会配置下面的配置项

  • testMatch 匹配哪些文件进行测试

  • transformIgnorePatterns 不进行匹配的目录

  • moduleFileExtensions告诉Jest需要匹配的文件后缀

  • transform 匹配到 xx 文件 文件的时候用 xx 处理

  • moduleNameMapper 处理webpack的别名,比如:将@表示 /src目录

  • snapshotSerializers 将保存的快照测试结果进行序列化,使得其更美观

# 5.5 Jest 覆盖率

刚才产生的配置文件我们已经勾选需要产生覆盖率报表,所有在运行时我们可以直接增加 --coverage参数

"scripts": {
    "coverage": "jest --coverage"
}
1
2
3

可以直接执行yarn coverage,此时我们当前项目下就会产生coverage报表来查看当前项目的覆盖率

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files |     100 |      100 |     100 |     100 |
 async.js |     100 |      100 |     100 |     100 |
 dom.js   |     100 |      100 |     100 |     100 |
 hook.js  |     100 |      100 |     100 |     100 |
 map.js   |     100 |      100 |     100 |     100 |
 qs.js    |     100 |      100 |     100 |     100 |
 timer.js |     100 |      100 |     100 |     100 |
----------|---------|----------|---------|---------|-------------------

Test Suites: 8 passed, 8 total
Tests:       17 passed, 17 total
Snapshots:   0 total
Time:        8.018 s
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  • Stmts 表示语句的覆盖率
  • Branch 表示分支的覆盖率(if、else)
  • Funcs 函数的覆盖率
  • Lines 代码行数的覆盖率

到此我们的Jest常见的使用已经基本差不多了!下面我们来看下Vue相关的组件等如何测试

# 6 Vue 测试

# 6.1 集成 Jest

如果是通过 vue-cli 创建的项目可忽略该步骤,因为在创建项目的时候可能就已经集成了相关环境配置;我是使用的是一个空项目并无 Vue 开发相关环境,因此要测试的话需要配置相关环境,安装 node 包:

  • vue 安装 vue 包

  • vue-template-compiler vue 模板解析器

  • babel-core@^7.0.0-bridge.0 由于 vue-jest 使用的是 6.x 的版本,而前面使用的@babel/core 是 7.x 版本,所以要使用这个桥接包, 当然你也可以卸载@babel/core 7.x 版本,直接使用 6.x 的版本

  • @vue/test-utils Vue.js 官方的单元测试实用工具库

  • vue-jest 处理.vue文件

  • jest-transform-stub 处理图片、css 等

  • babel-jest 处理 jsx

  • jest-serializer-vue 处理 vue 组件快照

  • jest-watch-typeahead watch 提示插件

jest.config.js 配置

module.exports = {
  moduleFileExtensions: [
    // 测试的文件类型
    "js",
    "jsx",
    "json",
    "vue",
  ],
  transform: {
    // 转化方式
    "^.+\\.vue$": "vue-jest", // 如果是vue文件使用vue-jest解析
    ".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$":
      "jest-transform-stub", // 如果是图片样式则使用 jest-transform-stub
    "^.+\\.jsx?$": "babel-jest", // 如果是jsx文件使用 babel-jest
  },
  transformIgnorePatterns: [
    // 转化时忽略 node_modules
    "/node_modules/",
  ],
  moduleNameMapper: {
    // @符号 表示当前项目下的src  alias 别名
    "^@/(.*)$": "<rootDir>/src/$1",
  },
  snapshotSerializers: [
    // 快照的配置
    "jest-serializer-vue",
  ],
  testMatch: [
    // 默认测试 /test/unit中包含.spec的文件 和__tests__目录下的文件
    "**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)",
    // 匹配所有spec.js/test.js结尾的文件
    "**/*/*.(spec|test).js",
  ],
  testURL: "http://localhost/", // 测试地址
  watchPlugins: [
    // watch提示插件
    "jest-watch-typeahead/filename",
    "jest-watch-typeahead/testname",
  ],
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

# 6.2 测试组件

# 6.2.1 测试 HelloWorld 组件

在 src/components/HelloWorld 目录下创建 HelloWorld 组件

<template>
  <div class="hello">
    <h1>{{ massage }}</h1>
  </div>
</template>
<script>
export default {
  name: "HelloWorld",
  props: {
    msg: String,
  },
  data() {
    return {
      massage: this.msg,
    };
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

在 src/components/HelloWorld 目创建测试用例文件 HelloWorld.spec.js

import Vue from "vue";
import HelloWorld from "@/components/HelloWorld";
describe("测试HelloWolrd 组件", () => {
  it("extend: 传入 msg 属性看能否渲染到h1标签内", () => {
    const baseExtend = Vue.extend(HelloWorld);
    // 获取当前组件的构造函数,并且挂载此组件
    const vm = new baseExtend({
      propsData: {
        msg: "hello"
      }
    }).$mount();
    expect(vm.$el.innerHTML).toContain("hello");
  });
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14

这样一个简单的 Vue 组件就测试成功了,但是写起来感觉不简洁也不方便!所以为了更方便的测试 Vue 官方提供给我们了个测试工具Vue Test Utils

import HelloWorld from "@/components/HelloWorld";
import { shallowMount } from "@vue/test-utils";
it("传入 msg 属性看能否渲染到h1标签内", () => {
  const wrapper = shallowMount(HelloWorld, {
    propsData: {
      msg: "hello",
    },
  });
  // 生成快照
  // expect(wrapper).toMatchSnapshot();
  expect(wrapper.find("h1").text()).toContain("hello");
});
1
2
3
4
5
6
7
8
9
10
11
12

可以直接渲染组件传入属性,默认返回wrapperwrapper上提供了一系列方法,可以快速的获取 dom 元素! 其实这个测试库的核心也是在 wrapper的方法上, 更多方法请看 Vue Test Utils

这里的shallowMount被译为潜渲染,也就是说HelloWorld中引入其他组件是会被忽略掉的,当然也有深度渲染mount方法!

# 6.2.2 测试 Vue 中的异步逻辑

在测试 Vue 项目中,我们可能会在组件中发送请求,这时我们仍然需要对请求进行 mock

<template>
  <ul>
    <li v-for="(list, index) in lists" :key="index">{{ list }}</li>
  </ul>
</template>
<script>
import axios from "axios";
export default {
  data() {
    return {
      lists: [],
    };
  },
  async mounted() {
    let data = await axios.get("/list");
    this.lists = data;
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

除了上面我们提到的 mock 掉 api 的方法,我们还可以 mock 掉 axios

// src/__mocks__/axios.js
export default {
  get(url) {
    return new Promise((resolve, reject) => {
      if (url === "/list") {
        return resolve(["香蕉", "苹果"]);
      }
      resolve({});
    });
  },
};
1
2
3
4
5
6
7
8
9
10
11
import List from "@/components/List";
import { shallowMount } from "@vue/test-utils";
jest.mock("axios");
describe("测试List组件", () => {
  it("测试List组件 异步", (done) => {
    let wrapper = shallowMount(List);

    // 这里使用setTimeout的原因是我们自己mock的方法是promise,所以是微任务,
    // 我们期望微任务执行后在进行断言,所以采用setTimeout进行包裹,保证微任务已经执行完毕!
    // 如果组件中使用的不是 async、await形式,也可以使用 $nextTick,
    // (新版node中await后的代码会延迟到下一轮微任务执行)

    setTimeout(() => {
      expect(wrapper.findAll("li").length).toBe(2);
      done();
    });
  });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 6.2.3 测试 Vue 中的自定义事件

我们写了一个切换显示隐藏的组件,当子组件触发 change 事件时可以切换 p 标签的显示和隐藏效果

<template>
  <div>
    <Head @change="change"></Head>
    <p v-if="visible">这是现实的内容</p>
  </div>
</template>
<script>
// Modal.vue
import Head from "./Head";
export default {
  components: {
    Head,
  },
  data() {
    return { visible: false };
  },
  methods: {
    change() {
      this.visible = !this.visible;
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<template>
  <div><Menu></Menu>Head</div>
</template>

<script>
// Head.vue
import Menu from "./Menu.vue";
export default {
  name: "Head",
  components: {
    Menu,
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
  <div>Menu</div>
</template>
<script>
// Menu.vue
export default {
  name: "Menu",
  data() {
    return {};
  },
  methods: {},
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13

下面我们编写测试用例,可以直接通过wrapper.findComponent方法找到对应的组件来发射(emit)事件

import Modal from "@/components/Modal";
import Head from "@/components/Modal/Head";
import Menu from "@/components/Modal/Menu";
import { mount, shallowMount } from "@vue/test-utils";

describe("测试组件自定义事件", () => {
  it("测试 触发change事件后 p标签是否可以切换显示", () => {
    const wrapper = mount(Modal);
    const headWrapper = wrapper.findComponent(Head);

    // 验证 Head 组件是否存在
    expect(headWrapper.exists()).toBeTruthy();
    // 验证 p 标签是否被显示
    expect(wrapper.find("p").exists()).toBeFalsy();
    // 触发change事件
    headWrapper.vm.$emit("change");
    // console.log(wrapper.html());
    // 检验方法是否被触发
    expect(headWrapper.emitted().change).toBeTruthy();
    // 由于vue的更新是异步的所以要使用$nextTick
    wrapper.vm.$nextTick(() => {
      // 验证 p 标签是否被显示
      expect(wrapper.find("p").exists()).toBeTruthy();
    });
  });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

到这里我们对vue的组件测试已经基本搞定了,接下来我们再来看下如何对 Vue 中的vue-router、vuex进行处理

# 6.3 测试 vue-router

# 6.3.1 存根

在你的组件中引用了全局组件 router-link 或者 router-view组件时,我们使用shallowMount来渲染会提示无法找到这两个组件,我们可以使用存根 studs 的方式mock掉相关的组件

<template>
  <div>
    <h1>当前路由:{{ this.$route.path }}</h1>
    <router-link to="/">首页</router-link>
    <router-link to="/about">关于页面</router-link>

    <router-view></router-view>
  </div>
</template>
1
2
3
4
5
6
7
8
9
import Nav from "@/components/Nav";
import { shallowMount } from "@vue/test-utils";
it("测试Nav组件", () => {
  let wrapper = shallowMount(Nav, {
    // 忽略这两个组件
    stubs: ["router-link", "router-view"],
    mocks: {
      // mock一些数据传入到Nav组件中
      $route: { path: "/" },
    },
  });
  expect(wrapper.find("h1").text()).toBe("当前路由:/");
});
1
2
3
4
5
6
7
8
9
10
11
12
13

# 6.3.2 安装 vue-router

我们可以也创建一个localVue来安装 VueRouter,传入到组件中进行渲染。 安装 Vue Router 之后 Vue 的原型上会增加 $route$router 这两个只读属性。所以不要挂载到基本的 Vue 构造函数上, 同时也不能通过mocks参数重写这两个属性

// createLocalVue.spec.js
import Nav from "@/components/Nav";
import List from "@/components/List";
import { shallowMount, createLocalVue } from "@vue/test-utils";
import VueRouter from "vue-router";

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

it("安装vue-router: 测试Nav组件", async () => {
  const router = new VueRouter({
    routes: [
      { path: "/", component: List },
      { path: "/about", component: List },
    ],
  });
  const wrapper = shallowMount(Nav, {
    localVue,
    router,
  });
  router.push("/about");
  wrapper.vm.$nextTick(() => {
    expect(wrapper.find("h1").text()).toMatch(/about/);
  });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

# 6.4 测试 vuex

我们通过一个计数器的例子来掌握如何测试 vuex

<template>
  <div>
    {{ this.$store.state.number }}
    <button @click="add(3)">添加</button>
  </div>
</template>
<script>
import { mapActions } from "vuex";
export default {
  methods: {
    ...mapActions({ add: "increment" }),
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14

编写store/index.js

import Vue from "vue";
import Vuex from "vuex";
import counter from "./counter";
Vue.use(Vuex);
export default new Vuex.Store(counter);
1
2
3
4
5

编写store/mutations.js

export default {
  increment(state, count) {
    state.number += count;
  },
};
1
2
3
4
5

编写store/actions.js

export default {
  increment({ commit }, count) {
    setTimeout(() => {
      commit("increment", count);
    }, 1000);
  },
};
1
2
3
4
5
6
7

编写store/counter.js

import mutations from "./mutations";
import actions from "./actions";
export default {
  state: {
    number: 0,
  },
  mutations,
  actions,
};
1
2
3
4
5
6
7
8
9

下面我们开始测试

# 6.4.1 单元化测试 store

我们可以直接把 store 中的方法一一进行单元测试

就是一个个测试函数,但是需要 mock commitdispatch方法

import mutations from "../mutations";
import actions from "../actions";
jest.useFakeTimers();

describe("单元测试store", () => {
  it("测试mutation", () => {
    const state = { number: 0 };
    mutations.increment(state, 2);
    expect(state.number).toBe(2);
  });

  it("测试action", () => {
    const commit = jest.fn();
    actions.increment({ commit }, 2);
    jest.advanceTimersByTime(2000);
    expect(commit).toBeCalled();
    expect(commit.mock.calls[0][1]).toBe(2);
  });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 6.4.2 测试运行的 store

就是产生一个 store,进行测试 好处是不需要mock任何方法

import Vuex from "vuex";
import { createLocalVue } from "@vue/test-utils";
import counter from "../counter";
jest.useFakeTimers();
const localVue = createLocalVue();
localVue.use(Vuex);
describe("测试运行中的store", () => {
  it("测试是否可以异步增加 1", () => {
    const store = new Vuex.Store(counter); // 创建一个运行store
    expect(store.state.number).toBe(0);
    store.dispatch("increment", 2);
    expect(store.state.number).toBe(0);
    jest.advanceTimersByTime(2000); // 前进2s
    expect(store.state.number).toBe(2);
  });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 6.4.3 测试组件中的 vuex

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

const localVue = createLocalVue();
localVue.use(Vuex);
let store;
let actions;
beforeEach(() => {
  actions = {
    increment: jest.fn(),
  };
  store = new Vuex.Store({
    actions,
    state: {},
  });
});
describe("counter组件测试vuex", () => {
  it("测试组件中点击按钮 是否可以 1", () => {
    const wrapper = shallowMount(Counter, {
      localVue,
      store,
    });
    wrapper.find("button").trigger("click");
    // 测试actions中的increment 方法是否能正常调用
    expect(actions.increment).toBeCalled();
    // 调用1次
    expect(actions.increment.mock.calls.length).toBe(1);
    // 第二个参数是3
    expect(actions.increment.mock.calls[0][1]).toEqual(3);
  });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

到此关于 Vue 常见的测试就这些了,如果有遇到上面没提到的测试建议去jest 官网@vue/test-utils官网上查找下

# 7 总结

到此自动化测试相关的内容就到一段落了,我们来总结下:

1.单元测试可以保证测试覆盖率高,但是相对测试代码量大,缺点是无法保证功能正常运行

2.集成测试粒度大,普遍覆盖率低,但是可以保证测试过的功能正常运行

3.一般业务逻辑会采用 BDD 方式使用集成测试(像测试某个组件的功能是否符合预期)一般工具方法会采用 TDD 的方式使用单元测试

4.对于 UI 组件来说,我们不推荐一味追求行级覆盖率,因为它会导致我们过分关注组件的内部实现细节,从而导致琐碎的测试

{
  "name": "nature-resource-cli3",
  "version": "3.2.28",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve", // 开发环境
    "build": "vue-cli-service build", // 生产环境
    "report": "npm_config_report=true vue-cli-service build", // 依赖库分析 生产环境
    "lint": "vue-cli-service lint", //格式化代码
    "fix": "vue-cli-service lint --fix",
    "commit": "git-cz", // 提交代码
    "permission": "vue-cli-service serve --mode permission", // 开发环境 并设置mode为permission
    "test:unit": "vue-cli-service test:unit", // 单元测试
    "test:dev": "vue-cli-service test:unit --watchAll", // 开发环境 单元测试 文件发生变化自动触发
    // 自定义脚本
    "version": "node scripts/update-version.js",
    "generate-router": "node scripts/generate-router.js",
    "hjson": "node scripts/json-to-hjson"
  },
  // ...
  "lint-staged": {
    "src/**/*.{vue,js}": ["yarn lint", "git add"],
    "src/**/*.{json,md,css,scss}": ["prettier --write", "git add"]
  },
  "husky": {
    // husky 配置
    "hooks": {
      "pre-commit": "lint-staged",
      "pre-push": "yarn test:unit",
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  },
  "engines": {
    "node": ">= 10.0.0",
    "npm": ">= 5.0.0"
  },
  "config": {
    "commitizen": {
      "path": "node_modules/cz-customizable"
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42