随着前端实现的业务逻辑愈发复杂,前端单元测试的重要性也水涨船高。

而好的单元测试,不仅能够保证代码质量,还能够提高开发效率。

本文就来谈谈,如何写好前端单元测试。

unit-testing

背景

简单介绍下背景,笔者在工作中负责开发前端 SDK,测试框架使用 jest

我们项目对代码测试覆盖率有一定的要求,需要满足:

{
  "branches": 80,
  "functions": 80,
  "lines": 80,
  "statements": 80
}

比如我们需要测试这个函数:

export const getConfig = (name) => {
  return {
    foo: () => {},
    name: name || "Anonymous",
  };
};

对应的测试用例一般是这样的:

describe("getConfig", () => {
  test("should return name if have the paramter", () => {
    const name = "zxf4399";
    const config = getConfig(name);

    expect(config.name).toBe(name);
  });

  test("should return default name if doesn't have the paramter", () => {
    const config = getConfig();

    expect(config.name).toBe("Anonymous");
  });
});

执行结果如下:

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files |     100 |      100 |      50 |     100 |
 index.js |     100 |      100 |      50 |     100 |
----------|---------|----------|---------|---------|-------------------
Jest: "global" coverage threshold for functions (80%) not met: 50%
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.444 s, estimated 1 s
Ran all test suites.
 ELIFECYCLE  Test failed. See above for more details.

由于 functions 的覆盖率没有达到 80%,所以测试失败了。

解决问题-Phase1

之前的解决方案:

test("should test foo function", () => {
  const config = getConfig();

  config.foo && config.foo();
});

结果:

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files |     100 |      100 |     100 |     100 |
 index.js |     100 |      100 |     100 |     100 |
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        0.448 s, estimated 1 s
Ran all test suites.

我们发现 functions 的覆盖率居然达到了 100%,但是我并没有在 should test foo function 测试用例中调用 expect 函数,这合理吗?

答案是合理的。

虽然我们没有在测试用例中调用 expect 函数,但是我们在测试用例中调用了 config.foo 函数,而 config.foo 函数是在 getConfig 函数中定义的,所以 jest 会认为我们测试了这个函数。

但实际上这个测试行为是没有任何意义的,我们只是为了达到测试覆盖率的要求而这样写。

解决问题-Phase2

解决 Phase1 问题我们需要知道 jest 的测试覆盖率是如何计算的。

jest 的 coverage provider 有两种,分别是:

由于我们项目中使用的是 coverageProvider 是 babel, 所以我们只要关注 istanbuljs 就可以了。

针对这种 noop funciton 的测试用例,我们可以使用 istanbul ignore next 来忽略这个函数的测试覆盖率。

export const getConfig = (name) => {
  return {
    foo: /* istanbul ignore next */ () => {},
    name: name || "Anonymous",
  };
};

当然更加推荐的做法是抽象出一个 noop function,然后在测试用例中调用这个函数。

/* istanbul ignore next */
export const noop = () => {};

export const getConfig = (name) => {
  return {
    foo: noop,
    name: name || "Anonymous",
  };
};

最终的结果也是令人满意的:

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files |     100 |      100 |     100 |     100 |
 index.js |     100 |      100 |     100 |     100 |
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.352 s, estimated 1 s
Ran all test suites.

本小节 Demo 传送门

如何写好单元测试

Phase1 与 Phase2 都是解决问题的方法,从技术上讲 Phase2 更合理一些(本身 noop function 就不应该是一个考核的点)但你也不能说 Phase1 的解决方案就是错误的。

所以我们需要思考如何写好单元测试 🤔

首先,我们来看个简单的例子,一个最简单的单元测试:

export const sum = (a, b) => a + b;

// unit test
import { sum } from ".";

describe("sum", () => {
  test("adds 1 + 2 to equal 3", () => {
    expect(sum(1, 2)).toBe(3);
  });
});

从这个例子中,我们可以看到,单元测试的基本结构:

  1. 准备输入数据
  2. 调用被测函数
  3. 断言输出结果

任何单元测试都可以遵循这一个框架:given-when-then

遵循这个框架是否就一定能写好单元测试呢?其实不然,我们还要知道,单元测试的特征:

  1. 失败原因明确
  2. 表现力强
  3. 执行速度快

失败原因明确

当输入不变,当且仅当业务代码功能变化,测试才会失败。

这个特征是我们重构代码的保障,当我们重构代码时,我们可以放心的修改代码,因为我们知道,只要测试通过,那么我们的代码就是正确的。

表现力极强

两方面:

  • 看到测试 case 的描述,就知道这个 case 的意图
  • 测试失败时,能够快速定位到问题

执行速度快

单元测试的执行速度是非常重要的,因为我们希望在开发过程中,能够快速的得到反馈,而不是等待很长时间。

为了达到这个目标,我们需要:

  • Mock 外部依赖,比如 WebSocket 连接,数据库连接等
  • 测试代码不包含逻辑,不然测试失败时,我们不知道是测试代码的问题还是业务代码的问题(滑稽.jpg)

一个好的单元测试

我们来看一个较好的单元测试的例子:

export class EventEmitter {
  constructor() {
    this.events = {};
  }

  on(event, listener) {
    this.events[event] = this.events[event] || [];
    this.events[event].push(listener);

    return () => this.off(event, listener);
  }

  once(event, listener) {
    const remove = this.on(event, (...args) => {
      remove();
      listener(...args);
    });
  }

  emit(event, ...args) {
    this.events[event]?.forEach((listener) => listener(...args));
  }

  off(event, listener) {
    this.events[event] = this.events[event]?.filter((l) => l !== listener);
  }
}

单元测试:

import { EventEmitter } from ".";

let eventEmitter;

beforeEach(() => {
  eventEmitter = new EventEmitter();
});

describe("EventEmitter", () => {
  describe("on", () => {
    test("should add a listener for an event", () => {
      const listener = jest.fn();

      eventEmitter.on("event", listener);
      eventEmitter.emit("event");
      expect(listener).toHaveBeenCalled();
    });

    test('should remove listener when returned "remove" function is called', () => {
      const listener = jest.fn();
      const remove = eventEmitter.on("event", listener);

      remove();
      eventEmitter.emit("event");
      expect(listener).not.toHaveBeenCalled();
    });
  });

  describe("once", () => {
    test("should add a one-time listener for an event", () => {
      const listener = jest.fn();

      eventEmitter.once("event", listener);
      eventEmitter.emit("event");
      eventEmitter.emit("event");
      expect(listener).toHaveBeenCalledTimes(1);
    });
  });

  describe("emit", () => {
    test("should call all listeners for an event", () => {
      const listener1 = jest.fn();
      const listener2 = jest.fn();

      eventEmitter.on("event", listener1);
      eventEmitter.on("event", listener2);
      eventEmitter.emit("event");
      expect(listener1).toHaveBeenCalled();
      expect(listener2).toHaveBeenCalled();
    });

    test("should pass arguments to listeners", () => {
      const listener = jest.fn();

      eventEmitter.on("event", listener);
      eventEmitter.emit("event", "arg1", "arg2");
      expect(listener).toHaveBeenCalledWith("arg1", "arg2");
    });
  });

  describe("off", () => {
    test("should remove a listener for an event", () => {
      const listener = jest.fn();

      eventEmitter.on("event", listener);
      eventEmitter.off("event", listener);
      eventEmitter.emit("event");
      expect(listener).not.toHaveBeenCalled();
    });
  });
});

这个测试代码的特点:

  • 代码简洁,没有多余的逻辑
  • 测试 case 的描述非常清晰,能够很快的知道这个 case 的意图
  • 测试 case 的执行速度非常快,因为没有外部依赖

总结

一个好的单元测试,应该符合 given-when-then 的原则,能够清晰的描述测试的意图,执行速度快,不包含逻辑。

本作品系原创,采用《署名-非商业性使用-禁止演绎 4.0 国际》许可协议