小程序最佳实践之『单测』篇 👮

654 阅读8分钟

前言:接下来会开启一个小程序最佳实践的知识小册,从小程序作为切入点,但绝不限于此。将涵盖几大篇章『命名篇』、『单测篇』、『代码风格篇』、『注释篇』、『安全生产篇』、『重构篇』、『代码设计篇』、『性能篇』、『国际化篇』和『Why 小程序开发最佳实践』。每一篇都会包含正面和反面示例以及详细的解释。

文中示例都是在 CR 过程中发现的一些典型的问题。希望大家能通过该知识小册提前规避此类问题,让我们的 CR 关注点更多聚焦在业务逻辑而非此类琐碎或基本理念的缺失上而最终形成高质量的 CR。

单测篇 👮

本篇仅介绍 unittest,不包含:integration test、doc test、fuzz test、proptest、benchmark 等。

单测的重要性

你的代码质量如何度量?

你是如何保证代码质量?

你敢随时重构代码吗?

你是如何确保重构的代码依然保持正确性?

你是否有足够信心在没有测试的情况下随时发布你的代码?

如果对这些问题没有答案,或者没有 100% 的信心,那你需要给你的代码做单元测试。

—— 来自产品工程师的修炼之路

前端要不要做单测

前端要不要做单测:单测是为了解放你手动回归的成本,要不要关键在平衡,你手动测试和自动测试需要的时间成本和维护精力的平衡,如果你觉得手动测试也很方便那也没必要写单测。工具为了解决问题而非加重负担。

—— 来自天猪在某次直播的回答

单测减少的是保证质量情况下,同一段代码的反复手动测试时间 + 非相关的模块潜在的回归测试时间。增加的是一次性的编写成本 + 后续维护成本。需要评估具体项目对质量的容忍程度,手动测试的耗时,以及需求变化导致用例不稳定带来的维护成本, 找到其中的平衡点和覆盖面。

—— 来自天猪在某次全员月报的回复

前端什么时候写单测

首先库或 utils 一定要写(当然首推团队或社区的这些都是有充分单测过的),其次针对核心的稳定的逻辑要写单测以及覆盖率,一方面能保障人工测试对边界 case 的疏漏,其次如果是老项目写单测还能帮助阅读代码甚至发现老代码的逻辑漏洞……

单测自我体会

  1. 单测是让线上问题左移最简单有效的手段,其次能够培养开发者良好的防御意识,提高系统在极端情况的稳定性,尤其是在不断迭代中能确保不对老逻辑造成『意料之外的破坏』。
  2. 单测是让他人了解自己代码最快速的途径之一。
  3. 单测是代码的照妖镜,通常单测很难跑起来的往往暗示代码设计不合理,耦合过多,单测可以帮助自己优化代码,迫使开发者写出无副作用、满足 SRP 原则的『好函数』等好处。
  4. 单测可让开发者提前考虑边界情况,提升代码的鲁棒性(Robustness),同时让自己拥有重构的信心。
  5. 单元测试旨在针对程序最小单位类、方法开展测试,考虑规模代价平方定律,即定位并修复一个BUG所需的代价正比于目标代码规模的平方,充分的单测能更早发现代码BUG,降低问题的修复成本,提升研发流程效率,是整个研发活动质量保障中重要的一环。
  6. 为什么说没有单测的代码是负债?答:因为不敢重构,不敢维护,无法揣测原先的逻辑,尤其边界逻辑。

单测案例

单测是了解代码最快速的途径之一

单测是了解代码最快速的途径之一

日常 CR 发现有个 utils 没有单测。

Reviewer 评论:这么复杂写个单测吧。

/**
 * 接口返回数据转换
 */
export const changeResponseData = (data: Array<Record<string, any>>) => {
  // 放时间的数组
  const mouthArray = [];
  let _index = 0;
  // 处理好的list
  const detailList = [];
  forEach(data, (_item) => {
    const item = { ..._item };
    if (!includes(mouthArray, _item.month)) {
      mouthArray.push(_item.month);
      detailList[_index] = [];
      detailList[_index].push(item);
      _index++;
    } else {
      detailList[indexOf(mouthArray, _item.month)].push(item);
    }
  });
  return detailList;
};

单测写完如下

describe('changeResponseData', () => {
  it('data为空数组时', () => {
    const data = [];
    const actual = changeResponseData(data);
    const expected = [];
    expect(actual).toEqual(expected);
  });
  
  it('正确的data数据', () => {
    const data = [
      { month: '11', xxx: 'xxx' },
      { month: '11', xxx: 'xxx' },
      { month: '10', xxx: 'xxx' },
    ];
    const actual = changeResponseData(data);
    const expected = [
      [
        { month: '11', xxx: 'xxx' },
        { month: '11', xxx: 'xxx' },
      ],
      [{ month: '10', xxx: 'xxx' }],
    ];
    expect(actual).toEqual(expected);
  });
});

Reviewer:哦,原来就是 groupBy 呀!

重构成

import values from 'lodash-es/values';
import groupBy from 'lodash-es/groupBy';

/**
 * 接口返回数据转换-通过月份对列表中数据进行分组
 */
export const groupByMonth = (data: Array<{month: string; [key: string]: any}>) => {
  return values(groupBy(data, 'month'));
};

收益:

  • 代码逻辑更简单可读性更强:从16 行变成 1 行;
  • 少了中间变量更不容易出错,可维护性更强了;
  • 函数名称从模糊不清的 changeResponseData 可以变成 groupByMonth,自描述更强了,同时防止了 SRP 的可能性,最后函数描述变得更清晰了。

单测原则

单测 AIR原则

说明:单元测试在线上运行时,感觉像空气(AIR)一样感觉不到,但在测试质量的保障上,却是非常关键的。好的单元测试宏观上来说,具有自动化、独立性、可重复执行的特点。

集团的开发规约中描述了好的单测的特征, 被称为 AIR 原则

  • A:Automatic(自动化):单元测试应该是全自动执行的,并且非交互式的。
  • I:Independent(独立性):为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序。
  • R:Repeatable(可重复):单元测试通常会被放到持续集成中,每次有代码 check in 时单元测试都会被执行。如果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。

单测 FIRST原则

  • Fast
  • Isolated
  • Repeatable
  • Salf Verifying
  • Timely

image.png

单测编写 BCDE 原则

【推荐】编写单元测试代码遵守BCDE原则,以保证被测试模块的交付质量。

  • B:Border,边界值测试,包括循环边界、特殊取值、特殊时间点、数据顺序等。
  • C:Correct,正确的输入,并得到预期的结果。
  • D:Design,与设计文档相结合,来编写单元测试。
  • E:Error,强制错误信息输入(如:非法数据、异常流程、非业务允许输入等),并得到预期的结果。

更多阅读

书写规范

【建议】复杂单测应符合以下更具可读性的编码模式:

describe('testFunc', () => {
  it('should do sth when some conditions met', () => {
    const input = 'abc';
    const actual = testFunc(input);
    const expected = 'xyz';

    expect(actual).toEqual(expected);
  });
})
Good
describe('splitSubtitle', () => {
  it('正确分隔含金额的副标题', () => {
    const input = '奖励3000元还款额度';
    const actual = splitSubtitle(input);
    const expected = ['奖励', '3000','元还款额度'];

    expect(actual).toEqual(expected);
  });
});
Bad

当入参较长则不具备可读性

it('normal src parameter restful url', () => {
  expect(foo('https://mdn.example.com/antmedia/afts/img/A*aafffffffffffff/600w_600h')).toBe('https://mdn.example.com/antmedia/afts/img/A*aafffffffffffff/600w_600h/432w_288h');
});
Good
it('should add zoom into path for restful url', () => {
  const input = 'https://mdn.example.com/antmedia/afts/img/A*aafffffffffffff/600w_600h';
  const actual = foo(input);
  const expected = 'https://mdn.example.com/antmedia/afts/img/A*aafffffffffffff/600w_600h/432w_288h';

  expect(actual === expected);
});

覆盖率

分为 branches statements line function 下一篇文章在展开细讲。

覆盖率报告

覆盖率是衡量代码质量最直观的一个指标,通过配置覆盖率阈值让线上运行的代码能始终恒稳。一般我们会要求新增的 util 覆盖率达到 99% 以上,增加配置文件,当阈值低于 99%,则测试不通过,以此限制代码质量在历经修改后不会恶化。

jest

有两种方式,通过命令行或配置

// @filename package.json

{
  "scripts": {
    "test": "jest --coverage" // or --collectCoverage
  }
}

或通过配置可以写到 package.json 或 jest.config.js

// @filename package.json

{
  "jest": {
    "collectCoverage": true,
  },
}

💡 Tips:为了保障下次 util 修改不会导致覆盖率下降,增加阈值配置,当低于 100%,单测将失败,保证代码质量不劣化。

// @filename package.json

{
    "jest": {
    "collectCoverage": true,
+   "coverageThreshold": {
+     "global": {
+       "branches": 100,
+       "functions": 100,
+       "lines": 100,
+       "statements": 100
+     }
+   }
  }
}

分支覆盖率

刚开始写单测的开发者,易犯的错误是,洋洋洒洒列举一大堆 case,但是对分支覆盖率没有任何增益。

如何有效提高覆盖率?我们必须先了解 branches statements line function 以及知道如何阅读覆盖率报告。先挖一个坑,等待下一篇文章《覆盖率》。