灏天阁

前端工程化-单元测试篇

· Yin灏

在计算机编程中,单元测试(英语:Unit Testing)又称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。

程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。

—-摘自维基百科

通俗的讲,在前端领域,单元测试就是 测试的单个模块,可以是一个函数,一个组件。

单元测试的优点如下:

  • 单元测试是一种验证行为
  • 单元测试是一种设计行为
  • 单元测试是一种编写文档行为
  • 单元测试是一种回归测试行为

目前来说,单元测试框架很多,本文将以 jest 为例进行探讨。

一. Jest 初体验

jest 是什么

jest 是一款优雅,简洁的 js 测试框架,他支持 babel, ts, node, react, angular, vue 等各种框架。

他具备以下优点:

  • 安全快速
  • 完整的代码覆盖,只需要一行命令
  • 轻松模拟
  • 友好的错误提示
  • 强大的生态
  • 完备的文档

环境准备

  1. 初始化一个项目:
npm init
  1. 安装 jest
yarn add jest -D

hello world

  1. 添加需要测试的文件
touch t.js

内容如下:

function sum(a, b) {
  return a + b;
}

module.exports = sum;
  1. 新建测试文件
touch t.test.js

测试脚本如下:

const sum = require("./index");

test("adds 1 + 2 to equal 3", () => {
  expect(sum(1, 2)).toBe(3);
});
  1. 配置 package.json
{
  ...,
  "scripts": {
    "test": "jest"
  }
}
  1. 执行 npm run test

image.png

二. Jest 匹配器

在上述例子中, toBe 就是一个匹配器,也是最简单的

toBe

toBe 是判断精确相等,即 x === y

toEuqal

和 toBe 类似,也是判断相等,但他判断的是对象

toBeNull

结果至匹配 null

toBeUndefined

结果只匹配 undefined

toBeDefined

与 toBeUndefined 相反

toBeTruthy

匹配任何 if 语句为真,实际上就是期望结果是 true

toBeFalsy

匹配任何 if 语句为假,实际上就是期望结果是 false

toBeGreaterThan

匹配数字时使用,期望大于,即 result > xx

toBeGreaterThanOrEqual

匹配数字时使用,期望大于等于,即 result >= xx

toBeLessThan

匹配数字时使用,期望小于,即 result < xx

toBeLessThanOrEqual

匹配数字时使用,期望小于等于,即 result <= xx

toBeCloseTo

小数点精度问题匹配,有个经典的问题: 0.1 + 0.2 !== 0.3 的,但我们期望等于,需要使用 toBeCloseTo

toMatch

匹配字符串时使用,希望字符串包含另一个字符串。

使用示例:

expect("abc").toMatch("a"); // ok

toContain

数组操作,和 indexOf 一样,数组是否包含 xx。

使用示例:

expect([1, 2, 3]).toContain(1); // ok

更多

其他可以参考官方文档说明: jestjs.io/zh-Hans/doc…

三. Jest 异步代码测试

测试代码,需要注意的是: jest 测试是同步执行的,如果直接写异步任务,会在异步之前就执行了测试期望。这个时候,我们需要手动执行 done。

1. 异步函数

const axios = require("axios");
function fetchData() {
  return axios("http://localhost:3001/");
}

module.exports = fetchData;

2. 测试异步函数

const fetchData = require("./t");

test("fetch Data", (done) => {
  fetchData().then((data) => {
    expect(data.data).toBe("ok"); // ok
    done();
  });
});

四. Jest 全局钩子

如果,我们需要在 执行测试之前 做些事件,或者执行之后,做些工作。 如果,我们需要在 每个 test case 之前或之后,做些事件

那么,我们可以使用:

beforeEach

在每个 test case 之前,执行

beforeEach(() => console.log("我是在每个test case 之前 执行"));

afterEach

在每个 test case 之后,执行

afterEach(() => console.log("我是在每个test case 之后 执行"));

beforeAll

在所有 case 之前执行, 只执行一次。

beforeAll(() => console.log("只执行一次,在所有test case之前执行"));

afterAll

在所有 case 之后执行, 只执行一次。

afterAll(() => console.log("只执行一次,在所有test case之后执行"));

五. mock 函数

当我们需要模拟 callback 函数调用时,需要使用模拟函数。

demo

需要被测试的代码:

function sum(a, b, callback) {
  return callback(a + b);
}

module.exports = sum;

测试代码:

const sum = require("./t");

test("call", () => {
  const mockCallback = jest.fn();

  sum(1, 2, mockCallback);
  sum(3, 4, mockCallback);

  expect(mockCallback.mock.calls[0][0]).toBe(3);
  expect(mockCallback.mock.calls[1][0]).toBe(7);
});

其实,mock 函数的主要使用了 jest.fn 方法,每个这样的方法,都有 mock 这个属性。

mockCallback.mock 属性,其数据结构如下:

{

  calls: [ [ 3 ], [ 7 ] ],

  instances: [ undefined, undefined ],

  invocationCallOrder: [ 1, 2 ],

  results: [

    { type: 'return', value: undefined },

    { type: 'return', value: undefined }

  ]

}

六. React + Jest

create / destroy

每次测试,都需要 render 到一个 dom 上,这就需要每次测试前,先创建一个 div。结束之后,再删除。

import { unmountComponentAtNode } from "react-dom";

let container = null;

beforeAll(() => {
  container = document.createElement("div");
  document.body.appendChild(container);
});

afterAll(() => {
  unmountComponentAtNode(container);
  container.remove();
  container = null;
});

act

它确保在进行任何断言之前,模块单元相关的所有更新都已处理并应用于 DOM

示例如下:

act(() => {
  // 渲染组件
});
// 进行断言

render 单元测试

需要被测试的组件:

import React from "react";

function Main() {
  return <div>a</div>;
}

export default Main;

单元测试代码:

import React from "react";
import { unmountComponentAtNode, render } from "react-dom";
import { act } from "react-dom/test-utils";
import Main from "./t";

let container = null;

beforeAll(() => {
  container = document.createElement("div");
  document.body.appendChild(container);
});

afterAll(() => {
  unmountComponentAtNode(container);
  container.remove();
  container = null;
});

test("test", () => {
  act(() => {
    render(<Main />, container);
  });

  expect(container.textContent).toBe("a");
});

事件

DOM 事件的测试,可以对结果做断言,使用 jest.fn 模拟函数。

react 组件代码:

import React, { useState } from "react";

function Home(props) {
  const [state, setState] = useState(true);

  const handleClick = () => {
    setState(!state);
    props.onChange(!state);
  };

  return (
    <div>
      <button id="btn" onClick={() => handleClick()}>
        {state ? "a" : "b"}
      </button>
    </div>
  );
}

export default Home;

测试代码:

import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { act } from "react-dom/test-utils";
import Home from "./index";

let container = null;
beforeEach(() => {
  container = document.createElement("div");
  document.body.appendChild(container);
});

afterEach(() => {
  unmountComponentAtNode(container);
  container.remove();
  container = null;
});

it("点击时更新值", () => {
  const onChange = jest.fn();
  act(() => {
    render(<Home onChange={onChange} />, container);
  });

  const button = document.getElementById("btn");
  expect(button.innerHTML).toBe("a");

  act(() => {
    button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  });

  expect(onChange).toHaveBeenCalledTimes(1);
  expect(button.innerHTML).toBe("b");
});

快照测试

jest 会保存上一次的快照,当我们的组件发生变化时,jest 会给出 diff 提示。

原始组件

import React from "react";

function Home(props) {
  return <div>测试</div>;
}

export default Home;

测试代码:

import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { act } from "react-dom/test-utils";
import pretty from "pretty";
import Home from "./index";

let container = null;
beforeEach(() => {
  container = document.createElement("div");
  document.body.appendChild(container);
});

afterEach(() => {
  unmountComponentAtNode(container);
  container.remove();
  container = null;
});

it("点击时更新值", () => {
  act(() => {
    render(<Home />, container);
  });

  expect(pretty(container.innerHTML)).toMatchInlineSnapshot(
    `"<div>测试</div>"`
  );
});

运行如下:

image.png

当我们改变原始组件代码时(将测试文案修改为测试 1),再次运行结果如下:

image.png

七. report

运行 jest 测试之后,项目中会出现一个 coverage 文件夹,里面有个 index.html 文件,打开之后,就能看到具体的测试报告了。 如下:

image.png

八. 总结

jest 是 react 推崇的单元测试运行器,react 框架本身也使用 jest,社区生态支持完善。

配合 react 测试库,那真的是太香了~~

- Book Lists -