如何写好前端单元测试

lxf2023-03-16 19:22:01

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

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

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

背景

简单介绍下背景,笔者在工作中负责开发前端 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 有两种,分别是:

  • istanbuljs
  • c8

由于我们项目中使用的是 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 的解决方案就是错误的。

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