前端工程化-前端自动化测试

文章介绍最基础的自动化测试知识。

为什么要做自动化测试

稳定性

常见的库

常见的库都包含了大量的自动化测试的代码,开源的框架和组件都需要稳定性,引入前端自动化测试为开源项目提供稳定性是再好不过的选择。

减少成本

如果没有自动化测试:在一个大项目里历史遗留的代码很多,代码质量一般,每次改动代码都可能产生意想不到的问题甚至影响主流程,于是很多人会想重构代码,花费了巨额时间成本,可能重构完并没有比原来的系统好多少。所以引入自动化测试是必要的,编写测试用例的时间和难度比重构小得多。

旧系统改造

  1. 测试用例需要覆盖主流程,每次代码改动必须要跑主流程的测试用例:
  • 如果测试用例未通过,说明本次的改动影响了主流程,需要核对是代码问题还是应该更改主流程,一般是前者。
  • 如果测试用例通过,本次改动不影响主流程,提交更加安心。

背景和原理

要开发一个工具类。
创建一个 utils.js 文件

function add (a, b) {
  return a + b;
}
function minus(a, b) {
  return a - b;
}

为了保证公共库方法的可靠性,能不能对公共库进行自动化测试呢?

可以的,创建一个 utils.test.js 文件,写一些测试代码

// utils.test.js
const result = add (1, 1);
const expected = 2;

if (result !== expected) {
  throw Error(`1 + 1 应该等于 ${expected}, 但结果却是 ${result}`);
}

const result2 = minus (1, 1);
const expected2 = 0;

if (result !== expected2) {
  throw Error(`1 - 1 应该等于 ${expected}, 但结果却是 ${result}`);
}

现在,我们可以运行 test 文件测试我们写的方法了,如果没有任何异常结果输出说明测试例子都通过了。

这有什么好处呢?
以后当你要修改 utils 文件时,可以运行 test 文件测试改动有没有影响旧方法的逻辑。充当回归测试的手段。

这是自动化测试最初的雏型,会发现写的测试例子是有套路的,先预期一个结果,再计算出真正的结果,两者做比较看是否相等,如果不相等就抛异常。

能不能把它们封装成公共函数呢?
例如:
expect(add(1, 1)).toBe(2); // 我期望 1 + 1 等于 2,不等于 2 抛异常

当然可以

function expect (result) {
  return {
    toBe: function (actual) {
      if (result !== actual) throw new Error(`预期值和实际值不相等 预期${actual} 结果却是${result}`);
    }
  }
}

当代码不通过测试时,会看到 预期值和实际值不相等 的错误提示。
问题来了,不能快速定位是哪个方法报出来的错。

我们可以改造一下 expect 调用的方法,再包一层

function test (desc, fn) {
  try {
    fn();
    console.log(`${desc} 通过测试`);
  } catch (e) {
    console.log(`${desc} 没有通过测试 ${e}`);
  }
}
test('测试加法 1 + 1', () => {
  expect(add(1, 1)).toBe(2)
})

这样就能很方便地自动化测试 utils 文件里的方法了。
实际上,上面写的 test 方法和主流的 jest mocha 底层实现原理是一样的。

自动化测试框架

测试框架

目前比较火的测试框架有:Jasmine、MOCHA、Jest
它们使用的方法、原理都差不多,学会使用一个另外的就很容易上手了。

Jest

性能好、功能丰富、非常易用。支持 Babel、TS、node 等工具使用,社区强大。
vue:vue-test-utils + jest 对组件进行自动化测试


Jest优势
  • 速度快
    对代码里的模块 A 和模块 B 分别进行自动化测试。第一次运行时,模块 A 和模块 B 的测试用例都会运行。
    假设改动了模块 A 的代码,重新运行测试用例,Jest 会只运行模块 A 的测试用例,这使得它运行的效率很高,速度很快。

  • API 简单
    api 数量很少,简单学习一下可以快速上手。

  • 易配置
    在项目中安装 Jest,通过 Jest 的配置文件简单配置均可使用。

  • 隔离性好
    可以有很多测试文件,每个测试文件被执行的时候它的环境是隔离的,互相隔离。

  • 监控模式
    23+版本里,提供监控模式,更灵活的执行测试用例。

  • IDE 整合
    在编辑器里做自动化测试将会非常简单。

  • Snapshot
    Jest 还提供了快照功能,在做一些不是很重要的测试时,希望快速编写测试用例,使用 Snapshot 很方便解决此类问题。

  • 多项目并行
    假设我们用 node 写接入层、用 react/vue 写页面,Jest 支持同时跑 node 和 react/vue 的测试用例。

  • 覆盖率
    Jest 通过命令生成 code coverage 报告。

  • Mock 丰富
    提供了 mock 方法和丰富的 mock 方式。

使用方法

  1. 在 package.json 里增加 test 命令:
"scripts": {
    "test": "jest"
}
  1. 创建 .test.js 文件,运行 npm run test 命令 jest 会自动执行它们。

尝试修改 jest 的配置

运行 npx jest --init 生成配置文件

  1. 当前环境是 node / 浏览器环境


    1
  2. 是否生成覆盖率报告


    2
  3. 在测试结束之后是否清除模拟调用


    3
  4. 自动生成 jest.config,js 配置文件


覆盖率报告

在配置文件里发现 coverageDirectory 配置:

// The directory where Jest should output its coverage files
coverageDirectory: "coverage",

创建配置文件时,我们是需要覆盖率报告的,所以它帮我们解开这个配置的注释。在运行 jest 时,不仅会在控制台输出覆盖率情况,jest 还会帮我们创建 coverage 文件夹存放覆盖率报告。

运行 npx jest --coverage,查看测试覆盖率

终端打印台
覆盖率报告

支持 ES module

值得注意的是,在 node 环境下,jest 只认识 CommonJS 的语法,不支持 ES module的语法。

需要借 Babel 工具:

  1. 安装 babel
    npm i @babel/[email protected] @babel/[email protected] -D

  2. 配置 .babelrc 文件,只需 @babel/preset-env

// .babelrc
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "node": "current"
        }
      }
    ]
  ]
}

这时就可以写 es module 的代码了,它的原理是:

  1. 当我们运行 npm run test 代码时,jest 里的 babel-jest 插件会检测是否安装了 babel-core。
  2. 如果安装了 babel-core,会取 .babelrc 文件里的配置,在运行测试之前,结合 babel 先把代码转化。
  3. 运行转化过的测试用例。

自动执行单测

--watchAll: 监听所有测试文件,当测试文件变化自动执行所有测试用例。可以提升写单侧的效率。

常用的匹配器

  • toBe
    有点像 object.is 或 === ,比较严格
test('toBe 测试', () => {
  const test = { a: 1 };
  expect(test).toBe({ a: 1 }); // 匹配不通过
})
  • toEqual
    单纯匹配内容,不会匹配引用地址
test('toEqual 测试', () => {
  const test = { a: 1 };
  expect(test).toEqual({ a: 1 }); // 匹配通过
})
  • toBeNull、toBeUndefined、toBeDefined、toBeTruthy、toBeFalsy、not
    匹配 null、undefined、被定义过的变量/属性、true / 转换成 boolean 为 true、与 toBeTruthy 相反、非语法
test('特殊匹配器测试', () => {
  expect(null).toBeNull(); // 匹配通过
  expect(undefined).toBeUndefined(); // 匹配通过
  const test = null;
  expect(test).toBeDefined(); // 匹配通过
  expect(1).toBeTruthy(); // 匹配通过
  expect(0).toBeFalsy(); // 匹配通过
  expect(1).not.toBeFalsy(); // 匹配通过
})
  • 数字
    • toBeGreaterThan: 比某个数大
    • toBeLessThan: 比某个数小
    • toBeGreaterThanOrEqual: 大于等于某个数
    • toBeLessThanOrEqual: 小于等于某个数
    • toBeCloseTo:接近于某个数,小数计算使用
test('数字相关匹配器测试', () => {
  expect(2).toBeGreaterThan(1); // 匹配通过
  expect(0.1 + 0.2).toEqual(0.3); // 匹配不通过
  expect(0.1 + 0.2).toBeCloseTo(0.3); // 匹配通过
})
  • 字符串
    • toMatch:包含(字符串 / 正则表达式)
test('toMatch 匹配器测试', () => {
  expect('string').toMatch('str'); // 匹配通过
})
  • 数组
    • toContain: 包含某个值
test('toContain 匹配器测试', () => {
  expect(['1', '2']).toContain('2'); // 匹配通过
  expect(new Set(['1', '2'])).toContain('2'); // 匹配通过
})
  • 对象
    • toMatchObject: 对象里包含某个属性值
test('toMatchObject 匹配器测试', () => {
  expect({ a: 1, b: 2 }).toMatchObject({ b: 2 }); // 匹配通过
})
  • 异常
    • toThrow: 抛出错误
test('toThrow 匹配器测试', () => {
  const throwNewError = () => throw new Error('new error');
  expect(throwNewError).toThrow(); // 匹配通过
  expect(throwNewError).toThrow('new error'); // 匹配通过
  expect(throwNewError).toThrow(/new error/); // 匹配通过
})
  • 更多匹配器:Using Matchers 、Expect-Reference-Matchers

监听模式下进入特殊的模式

当我们执行 jest --watchAll 时,终端会输出一些 tips:


合理的使用这些模式,会使测试变得灵活好用。

f 模式

键盘输入对应字母可以进入对应的模式里,正在编写测试用例时按 f 进入 f 模式,jest 只会自动执行失败的测试用例能提高写用例的效率,再按一次 f 即可退出 f 模式。

o 模式

执行 jest --watch 默认进入 o 模式,只执行有变化的文件,jest 是借助 git 工具来记录有变化的文件,变化是相对于仓库代码的改变,当没有测试文件发生变化时不会运行测试用例。
这时会多出一个 a 模式,键盘输入 a 就能切换成 --watchAll 模式

p 、t 模式

过滤模式,分别执行与键盘输入的文件名和用例名相匹配的测试用例。

异步测试

回调类型

import axios from 'axios';

const fetchData = (callback) => {
  axios.get('xxx')
    .then(res => {
      callback(res.data)
    });
}

test('异步测试, 回调类型', () => {
  fetchData(data => {
    expect(data).toEqual({
       ...
    })
  })
})

实际上,测试用例运行完,发现这种写法是测试不了的,还没进到 then 整个测试已经跑完了,只要 fetchData 能正常执行测试用例就会通过。

要显式控制测试完成的时机:

// 当运行 done 函数时,测试用例才会执行完成
test('异步测试', (done) => {
  fetchData(data => {
    expect(data).toEqual({
       ...
    })
  done()
  })
})

无回调类型

  • 把 promise 返回。
import axios from 'axios';

const fetchData = () => {
  return axios.get('xxx');
}

test('异步测试, 无回调类型', () => {
  return fetchData().then(res => {
    expect(res.data).toEqual({ ... });
  })
})
  • await
test('异步测试, 无回调类型', async () => {
  await fetchData().then(res => {
    expect(res.data).toEqual({ ... });
  })
})
test('异步测试, 无回调类型', async () => {
  const res =  await fetchData();
  expect(res.data).toEqual({ ... });
})

await 和 return Promise 效果是一样的,下面不再赘述。


  • resolves
    resolves 会拿到接口所有返回内容, 可以根据需求匹配里面的部分内容即可。
// return Promise
test('异步测试, 无回调类型', () => {
  return expect(fetchData()).resolves.toMatchObject({
    data: {
      ...
    }
  })
})

测试异步错误

如果像测试无回调类型一样测异步错误,代码如下:

import axios from 'axios';

const fetchData = (callback) => {
  return axios.get('xxx');
}

test('异步测试, 异步错误', () => {
  return fetchData().catch(e => {
    expect(e.code.toString().indexOf('4') > -1).toBe(true);
  })
})

上面的写法,如果 fetchData 能正确请求数据,不会走到 catch 的代码分支里,里面的匹配器就会不生效,测试用例会直接通过。

解决办法:

  • assertions
    告诉 jest 期望执行 expect 语句的个数。
test('异步测试, 异步错误', () => {
  // 至少要执行一个 expect 语句
  expect.assertions(1);

  return fetchData().catch(e => {
    expect(e.code.toString().indexOf('4') > -1).toBe(true);
  })
})
  • rejects
    接收失败时的 error 信息。
test('异步测试, 异步错误', () => {
  return expect(fetchData()).rejects.toThrow();
})

有人喜欢 await 、有人喜欢 return Promise,大家根据自己的代码习惯选择适合自己的测试代码类型和匹配器就好,不用纠结。

jest 中的钩子函数

jest 提供了很多钩子函数,常用的如下:

beforeAll、afterAll

  • beforeAll
    所有测试用例执行之前触发。

当我们执行测试用例之前有需求对配置、实例等属性进行初始化的时候,会在执行测试用例逻辑代码之前进行配置的初始化、创建对象实例、mock 数据等,beforeAll 钩子可以帮助测试用例在执行前完成一些准备工作。

例如,我们需要对工具类里的方法进行单测,在执行每个测试用例之前需要创建工具类的实例,可以使用 beforeAll:

beforeAll(() => {
  utils = new MyUtils();
});

test('测试 MyUtils 中的 a 方法', () => {
  let res;
  utils.a(res);
  expect(res).toBe(xxx);
});
  • afterAll
    所有测试用例执行结束后触发。

beforeEach、afterEach

  • beforeEach
    每个测试用例执行前触发。

很多时候我们希望每个测试用例的依赖独立出来,每个测试用例维护自己的依赖不相互影响,借助 beforeAll 的例子,每个测试用例创建自己的 utils 实例:

beforeEach(() => {
  utils = new MyUtils();
});
  • afterEach
    每个测试用例执行结束后触发。

分组测试

describe

如果希望测试用例的层次更加清晰,可能会分模块或相似的功能点进行分组测试,describe 提供了不创建新文件也可以使环境隔离的能力。

describe('使用 describe 隔离环境', () => {
    beforeEach(() => { ... });
    test('...', () => { ... });
});

describe('使用 describe 隔离环境', () => {
    beforeEach(() => { ... });
    test('...', () => { ... });
});

勾子的作用域

describe 是可以嵌套的,describe 里的勾子会对 describe 里包裹的所有测试用例 (包含嵌套的 describe) 生效,同种类的勾子执行之前触发的由外到内执行,执行之后触发的由内到外,原理与洋葱模型相像。

describe('分组', () => {
  beforeAll(() => {  console.log('外层 beforeAll'); });
  beforeEach(() => { console.log('外层 beforeEach'); });
  afterEach(() => { console.log('外层 afterEach'); });
  afterAll(() => { console.log('外层 afterAll'); });
  test('...', () => { ... });

  describe('分组嵌套', () => {
    beforeAll(() => {  console.log('嵌套层 beforeAll'); });
    beforeEach(() => { console.log('嵌套层 beforeEach'); });
    afterEach(() => { console.log('嵌套层 afterEach'); });
    afterAll(() => { console.log('嵌套层 afterAll'); });
    test('...', () => { ... });
  });
});
// 执行测试用例组输出:
/*
 * 外层 beforeAll
 * 外层 beforeEach
 * 外层 afterEach
 * 嵌套层 beforeAll
 * 外层 beforeEach
 * 嵌套层 beforeEach
 * 嵌套层 afterEach
 * 外层 afterEach
 * 嵌套层 afterAll
 * 外层 afterAll
*/

可能会有这样的疑惑 ️,为什么不能直接把勾子里的前置准备逻辑写到 describe 里 ,类似这样:

describe('所有测试用例', () => {
  let utils = null;

  // beforeAll(() => {
  //   utils = new MyUtils();
  // });

  utils = new MyUtils();
});

可能和我们想的不一样,直接写到 describe 的逻辑代码会比勾子优先执行,它执行的时刻不满足我们的预期,所以要把准备类型的代码写在勾子函数里执行。

only 调试单个用例

当我们的测试用例有一定的复杂程度,我们只想调试某个测试用例的时候,可以使用 only 跳过其他用例。

describe('分组嵌套', () => {
  test.only('...', () => { ... });
});

MOCK

介绍常用的 jest mock 的能力,及其核心用法

mock 捕获函数的调用和返回结果,以及 this 和调用顺序

假设我们要测一个方法的回调函数有没有执行:

const runCallback = (callback) => {
  callback();
};

如果不把 callback 的返回结果 return,断言接收不到值,没法测试。在方法里 return 吧,又要修改方法的代码,更何况有些 callback 没有返回值。可以用 jest 提供的 fn 方法,mock 函数,捕获函数的调用和返回结果,以及 this 指向和调用顺序。监控 callback 被执行:

import { runCallback } from "...";

test('测试 runCallback', () => {
  const callback = jest.fn();
  runCallback(callback);

  expect(callback).toBeCalled();
})

将上面执行过的 callback 打印出来,为:

    {
      calls: [ [] ], // 被调用时入参的情况
      instances: [ undefined ], // 每次运行 callback 时它实例的指向
      invocationCallOrder: [ 1 ], // 被调用的顺序
      results: [ { type: 'return', value: undefined } ] // value:函数执行的返回结果
    }

mock 方法返回值

当测试用例里依赖服务读数据时,往往需要 mock 掉获取数据方法的返回值。

  • mockReturnValueOnce:模拟返回结果一次
  • mockReturnValue:每次都模拟返回结果
  • mockImplementation: 除了 mock 返回结果还能在函数里做额外的事情,比 mockReturnValue 强悍。
const fetchData = async () => {
  return await http.get(...);
};
test('mock 方法返回值', () => {
  const callback = jest.fn(fetchData);
  func.mockReturnValueOnce(mockData);
  // func.mockImplementation(() => mockData);
  runCallback(callback);
  expect(func.mock.results[0].value).toEqual(fetchData);
  // expect(func.mock.calls[0]).toEqual(fetchData);
  // 如果只需调用一次 callback,断言也写成:
  // expect(func).toBeCalledWith(fetchData);
})

mock 改变函数的内部实现

jest.mock() + mockResolvedValue:

jest.mock('axios');
const getData = () => axios.get('/api').then(res =>  return res);

test.only('测试请求', async () => {
  axios.get.mockResolvedValueOnce({data: {}});
  await getData().then(data => {
    expect(data).toEqual({})
  })
})

插件选择

安装完插件后,会看到插件帮我们自动执行测试用例,不需要再命令行里开启 Jest了:


测试用例旁边有执行测试用例的状态图标以及测试用例列表,能很方便的看到执行成功/失败的测试用例,并提供详细的错误测试用例信息。

总结


文章涵盖了 JEST 文档的 Introduction 的大部分实用的内容。
没介绍的,可以自行查阅文档:
Jest Platform:设计上的思考
Jest Community:社区,有很多使用 Jest 优秀的实践
More Resources:更多资源

你可能感兴趣的:(前端工程化-前端自动化测试)