1

前端单元测试

简介前端单元测试是指对前端代码中的最小可测试单元进行测试的过程。这些最小单元可以是函数、组件或模块等。通过编写针对这些单元的测试用例,我们可以验证它们在各种情况下的行为是否符合预期。

单元测试从概念上可以分为:

  • TDD (Test-Driven Development):测试驱动开发
  • BDD (Behavior-Driven Development):行为驱动开发

TDD

TDD 强调在编写实际代码之前先编写测试用例。TDD的核心理念是通过编写测试来指导代码的开发,以确保代码的正确性和可靠性。TDD的工作流程通常包括以下步骤:

  • 编写测试用例:首先,开发者根据需求或功能规范编写一个测试用例。这个测试用例描述了期望的行为和结果。初始时,由于尚未编写任何实际代码,所以测试用例会失败。
  • 运行测试用例:接下来,开发者运行测试用例,确认它们失败。这是因为尚未编写与测试用例相匹配的实际代码。
  • 编写实际代码:在测试用例失败的基础上,开发者开始编写实际的代码,以满足测试用例中所描述的需求。目标是使得测试用例能够通过并成功执行。
  • 运行测试用例并重构:一旦实际代码编写完成,开发者再次运行测试用例。如果测试用例通过,说明代码已经正确实现了需求。此时,开发者可以进行代码重构,改进代码的结构、可读性和性能,同时确保测试用例仍然通过。

编写单元测试的意义

  • 提高代码质量:通过测试用例覆盖率和错误检测,我们可以及早发现并修复潜在的问题,从而提高代码的质量。
  • 简化调试过程:当出现问题时,单元测试可以帮助我们快速定位错误所在,并且在修复后能够确保不会再次出现同样的问题。
  • 支持重构和维护:在进行代码重构或修改时,单元测试可以帮助我们确保改动不会破坏原有功能。
  • 促进团队协作:通过编写单元测试,团队成员可以更好地理解彼此的代码,并共享对代码行为的理解,从而促进团队协作。

如何进行单元测试

简单尝试

第一步,用你最喜欢的包管理工具安装测试框架Jest。
npm install --save-dev jest

我们现在可以写一个简单的demo来试试水,找个空地创建名为sum.js的文件。

function sum(a, b) {
  return a + b;
}
module.export = sum;

显然这是个提供计算两数之和(或拼接字符串)能力的模块。如果不使用单元测试框架,我们会通过把代码跑起来打日志这种朴素的方法去验证它的正确性,现在我们可以创建sum.test.js文件来对其进行验证。

const sum = require('./sum');

describe('sum.js', () => {
  test('两数之和', () => {
    expect(sum(1, 2)).toBe(3);
  });
  it('字符串拼接', () => {
    expect(sum('a', 'b')).toBe('ab');
  });
});

在package.json中添加

{
  "scripts": {
    "test": "jest"
  }
}

运行
npm run test

如果一切正常,Jest将输出如下信息:

图片

测试文件由许多测试用例组成,Jest通过test方法(或与其等价的it方法)执行测试代码,在上述demo中,我们编写了两个测试用例,验证了sum方法的正确性。

支持ES6语法

Jest是执行在Node环境的,Node.js采用CommonJS模块化规范,通过require引入模块,如果要使用import这类ES6模块化规范的语法,必须借助babel来转义支持。

安装babel相关依赖

npm install --save-dev @babel/core @babel/preset-env

在根目录下配置babel,创建.babelrc

{
  "presets": [
    "@babel/preset-env"
  ]
}

现在我们可以自由使用ES6的模块化语法了。

function sum(a, b) {
  return a + b;
}
export sum;
import { sum } from './sum';

describe('sum.js', () => {
  test('两数之和', () => {
    expect(sum(1, 2)).toBe(3);
  });
  test('字符串拼接', () => {
    expect(sum('a', 'b')).toBe('ab');
  });
});

原因是Jest运行时内部会先执行jest-babel检测是否安装babel-core,然后获取.babelrc中的配置,在运行测试文件前用babel将其转换为支持的语法再执行测试。

使用TypeScript

如果要对TypeScript文件进行单元测试,需要额外安装依赖,借助babel去解析TypeScript文件再进行测试。

npm install --save-dev @babel/preset-typescript

在.babelrc中添加对应配置

{
  "presets": [
      "@babel/preset-env",
    "@babel/preset-typescript"
  ]
}

如果使用如VSCode之类的编辑器,为了解决其对Jest断言方法类型的报错,还需额外安装@types中的第三方类型库。
npm install --save-dev @types/jest

目录结构

测试文件一般以.test.js/ts为尾缀,通常放在根目录下的test文件夹内。

图片

不过现在更推荐放在被测试的源代码旁边,这样做有很多好处,其一是方便查找,其二是方便管理,当废弃某个组件或模块是,与其有关的所有垃圾文件(包括测试代码)方便一并废弃。

图片

简单配置

尽管Jest做到了开箱即用,但有时我们仍想自定义某些配置,此时可以通过执行。

## 全局安装时
jest --init
## 局部安装时
npx jest --init

生成基础配置。每个字段的具体含义,可以参阅官方文档。

测试覆盖率

单元测试的覆盖率是指所有功能代码中完成单元测试的代码所占的比例,可以反映测试用例的全面性与被测试代码的可靠性。测试覆盖率由语句覆盖率、函数覆盖率、分支覆盖率和行覆盖率所量化。

  • Statements(语句覆盖率)
    是否每个语句都执行了
  • Branches(分支覆盖率)
    是否每个判断都执行了
  • Functions(函数覆盖率)
    是否每个函数都执行了
  • Lines(行覆盖率)
    是否每行都执行了

我们可以通过如下配置来查看每个测试用例的测试覆盖率以及自定义覆盖率阈值。

module.exports = {
  // 是否显示测试覆盖率报告
  collectCoverage: true,
  // 哪些文件需要被执行单元测试
  collectCoverageFrom: [
    'src/utils/**/*'
  ],
  extensionsToTreatAsEsm: ['.tsx', '.jsx', '.ts'],
  // 测试覆盖阈值
  coverageThreshold: {
    global: {
      statements: 90, // 保证90%的语句都执行了
      functions: 90, // 保证90%的函数都调用了
      branches: 60, // 保证60%的 if 等分支代码都执行了
    },
  },
}

常见测试场景

匹配

toBe与toEqual

两者都是用来验证相等的断言,toBe常用来比较值是否相等,toEqual常用来比较引用类型是否等价,按照官方解释,toEqual会递归对比对象实例的所有属性,因此也被称作深度相等。

test('相等', () => {
  const foo = { bar: 1 };
  expect(foo.bar).toBe(1); // 通过
  expect(foo).toBe({ bar: 1 }); // 不通过
  expect(foo).toEqual({ bar: 1 }); // 通过
});

not

not修饰符用来表示取反,用在其他断言之前,比如

test('not', () => {
  const foo = 1;
  expect(foo).not.toBe(2); // 通过
});

toMatch

toMatch允许我们传入一个正则表达式,用来精确匹配字符串。

test('match', () => {
  const foo = 'hello jest';
  expect(foo).toMatch(/hello/i); // 通过
});

toContain

toContain用来检测对象中是否包含某个值。

test('contain', () => {
  const data = ['foo', 'bar'];
  expect(data).toContain('fo'); // 不通过
});

其他匹配器

  • toBeNull:是否 null
  • toBeUndefined:是否 undefined
  • toBeDefined:是否定义
  • toBeTruthy:是否为真
  • toBeFalsy:是否为假
  • toBeGreaterThan 大于
  • toBeGreaterThanOrEqual 大于等于
  • toBeLessThan 小于
  • toBeLessThanOrEqual 小于等于
  • toBeCloseTo 匹配浮点数

函数

测试异常

可以使用toThrow来测试函数执行过程中是否抛出错误,需要注意的是,在Jest中我们必须对被测试函数再做一层包装才有效。

function onlyNumber(param) {
  if (typeof param !== 'number') {
    throw Error('Only Number!');
  }
  return param;
}

test('throw', () => {
  expect(onlyNumber('1')).toThrow('Only Number!'); // 通过
});

测试回调

关键是需要手动调用done()。

function getName(callback) {
  new Promise((resolve) => {
    setTimeout(() => {
      resolve('bar');
    }, 1000);  
  }).then((res) => {
    callback(res);
  });
}

describe('test callback', () => {
  // 用例1, 错误用法
  test('fault', () => {
    getName((res) => {
      expect(res).toBe('foo'); // 通过
    });
  });
  // 用例2, 正确用法
  test('right', (done) => {
    getName((res) => {
      expect(res).toBe('foo'); // 不通过
      done();
    });
  });
});

在错误的用例1中,无论断言是toBe什么,只要被测试函数本身不报错,测试用例都会通过,因为回调函数本身并未执行。

图片

需要在回调函数中手动调用done(),表示该回调函数执行以后,用例才算通过。

图片

测试异步

这块测试我们可以通过使用 async 和 await 关键字来做到和写法和同步代码基本一致(推荐),也可以使用jest官方提供的resolves和rejects修饰符,当然,使用.then之类的链式写法也可以。后两种方法记得return。

// 模拟一个异步操作
function getName() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('bar');
    }, 1000);
  });
}

describe('test promise', () => {
  // 写法1
  test('style1', async () => {
    const res = await getName();
    expect(res).toBe('bar');
  });
  // 写法2
  test('style2', () => {
    return expect(getName()).resolves.toBe('bar');
  });
  // 写法3
  test('style3', () => {
    return getName().then((res) => {
      expect(res).toBe('bar');
    })
  });
});

如果在测试时发现jest不认识async、await等关键字,那就需要加强下babel,首先安装 @babel/plugin-transform-runtime。

npm install --save-dev @babel/plugin-transform-runtime

配置babel

{
  "plugins": ["@babel/plugin-transform-runtime"]
}

Mock

mock函数

mock函数常用于被测试内部结构复杂的函数的运行是否符合预期,mock函数就是jest提供的一个被监控的函数,函数的参数、返回值、函数体、被调用次数等一切属性都可以自定义或观察。

// 创建 mock 函数
const func = jest.fn();
// mock 函数返回值, 不 mock 默认 undefined
func.mockReturnValue(1);
// mock 函数返回值, 只生效一次,需要多次使用,方便 mock 不同情况下的返回值
func.mockReturnValueOnce(1);
// mock 函数实现
func.mockImplementation((params) => {
  console.log('func be called with', params);
});
// mock 函数实现,只生效一次
func.mockImplementationOnce(() => {});
// mock 函数被调用次数
console.log(func.mock.calls.length);
// mock 函数第 i + 1 次被调用时所接受的实参
let i = 2;
console.log(func.mock.calls[i]);
// mock 函数第 i + 1 次被调用时内部的 this 指向
console.log(func.mock.instances[i]);

具体的例子在mock定时器一节中。

mock定时器

在实际的被测试代码中,可能会遇到代码中存在计时器的延时操作。假如这个时间是30s,那么我们在对这块代码做测试时一定不想等待30s。因此,jest提供了一些与计时器有关的API来帮助我们跳过或加速时间。

// 被测试代码
function handleData(callback) {
  setTimeout(() => {
    callback('bar');
    seTimeout(() => {
      callback('foo');
    }, 1000);
  }, 30 * 1000);
}

// 测试代码
// 每个测试用例都 mock 一个定时器, 避免串扰
beforeEach(() => { // beforeEach是jest提供的生命周期钩子, 每个测试用例执行前触发
  jest.useFakeTimers(); // 一定要在 mock 计时器前进行声明
});

describe('test mock timer', () => {
  test('runAllTimers', () => {
    // mock 一个 callback 来用,
    // 因为在本次测试中我们只关心计时器, 具体的内部操作我们不关心,
    // 因此很适合用mock函数, 这样做的好处是假如内部是个耗时操作, 我们可以直接规避
    const fn = jest.fn();
    // 执行被测试代码
    handleData(fn);
    // 一次性执行完所有计时器
    jest.runAllTimers();
    // 回调执行了2次
    expect(fn.mock.calls.length).toBe(2); // 通过
    // 2次实参分别是'bar'和'foo'
    expect(fn.mock.calls).toEqual(['bar', 'foo']); // 通过
  });
  test('runOnlyPendingTimers', () => {
    const fn = jest.fn();
    handleData(fn);
    // 仅执行处于消息队列中的计时器
    jest.runOnlyPendingTimers();
    // 显然回调只会执行外层的一次
    expect(fn.mock.calls.length).toBe(1); // 通过
  });
  test('advanceTimersByTime', () => {
    const fn = jest.fn();
    handleData(fn);
    // 快进30s, 执行完外层计时器
    jest.advanceTimersByTime(30 * 1000);
    expect(fn.mock.calls.length).toBe(1); // 通过
    // 快进1s, 执行完内层计时器
    jest.advanceTimersByTime(1000);
    expect(fn.mock.calls.length).toBe(2); // 通过
  });
});

mock模块

我们在代码中总是会引用到第三方模块,如果我们要测试的某一段代码依赖某个第三方模块,我们此时就可以mock它。最常见的例子就是网络请求。要mock一个模块,我们可以在被测模块的同级目录下创建一个__mocks__文件夹,并且在该文件下创建同名的mock模块;也可以不用这么做,直接在测试文件中导入原有模块,用jest.mock来mock想mock的模块。

// 被mock模块
import axios from 'axios';

function fetchData1() {
  return axios.get('https://xxx.xxx.xxx/xxx', res => res.data);
}

function fetchData2() {
  return axios.get('https://xxx.xxx.xxx/xxx', res => res.data);
  // 预计返回
  // { type: '5G' }
}

export { fechData1, fetchData2 };
// mock 指定路径下的模块
jest.mock('./service', () => {
  // 导入真实模块内容
  const actualModules = jest.requireActual('./service');
  // 混入 mock 内容
  return {
    ...actualModules,
    fetchData1: jest.fn(() => {
      return new Promise((resolve, reject) => {
        resolve({
          foo: 'bar',
        });
      });
    }),
  };
});

// 此时导入的 fetchData1 是我们 mock 的, fetchData2 是原有的
import { fetchData1, fetchData2 } from './service';

describe('test mock modules', () => {
  test('fetchData1', async () => {
    const res = await fetchData1();
    expect(res).toEqual({foo: 'bar'}); // 通过
  });
  test('fetchData2', async () => {
    const res = await fechData2();
    expect(res).toEqual({type: '5G'}); // 预期通过
  });
});

(本文作者:黄成翰)

图片


哈啰技术
89 声望51 粉丝

哈啰官方技术号,不定期分享哈啰的相关技术产出。