如何构造一些有意义的测试

496 阅读6分钟
原文链接: www.codesky.me

好久都没更新博客了,难得有时间,还是水一篇好了……

在此之前,我从来没有聊过测试相关的话题,这是因为——平时根本没有时间去写单元测试!最多只有 lib 级别的东西不得不写测试才会有时间去写。不过这次由于业务的关系,补了一大堆单元测试,对于开发而言,整个流程其实还是挺简单的,但是正要构造起测试用例来确很麻烦,因为单元测试和开发会有一些区别——

  1. 按待测模块引入,而不是全模块载入后测试
  2. 完整的 mock 测试所需的数据,尽可能的让每个测试用例的数据独立
  3. 测试通过不等于程序完全满足需求

Node.js 用Mocha+Chai做单元测试 入门中我们曾经讲过两种测试模型,实际上,无论使用哪种测试,都没法避免「测试通过不等于程序完全满足需求」这一点,测试通过只代表在你的想象范围内,他可以符合预期的工作。

那么问题来了,如果测试覆盖率达到 100% 并且长期保持 100%,是否就代表他可以完全符合预期的工作呢?——在某些应用中是可以做到的,但是实际上为了 100% 的测试覆盖率,可能会造成时间成本的上升,而且也不一定真的能够完全可靠,所以我们今天的话题就是标题上的内容。

在我刚入行的时候我就问过大佬:到底单元测试要怎么写?

这里分成两部分含义:

  1. 单元测试的代码怎么写?
  2. 单元测试的 case 怎么挑?

其实上面已经提到了一些了,不过还是再说一次:这篇文章就从这两个方面来讲这个问题。

单元测试的代码怎么写

这是一个比较简单的问题,唯一的难度在于,对于一个比较复杂的应用,可能抽丝剥茧取出依赖才是最难的,当然,我们先从简单的开始吧,本文都以 ava 为例,因为最近一直在用 ava,之前的 mocha + chai 的经典组合自然是依旧有效的,从「测试」的角度他们是一样的,只是运行效率上可能有所差距。

一个最简单的 demo:

test(async function (t) {
    const value = await promiseFn();
    t.true(value);
});

// Async arrow function
test('promises the truth', async t => {
    const value = await promiseFn();
    t.true(value);
});

这是来自 ava 官方的一个用例,对于一些功能性 lib 而言,这样已经够测试了。

对于前端而言,我们经常会遇到的问题是「一些浏览器才有的函数在 Node 下跑不起来,该怎么办」,例如 localstorage。因此我们引入了一大堆 mock 库,mock windowdocument 就是前端常见的需求,它会把本来 window 中的一些内容挂载在 Node 的全局变量 global 上。当然,对于更常见的 DOM 操作操作,比较常见的是用 Chrome Headless 去测试,这里更多的讨论的是一种通用情况,这里对 Headless 的 API 不做讨论。

如果说前端 主要的问题在于浏览器的模拟上的话,后端则更为复杂,目前最简单的后端模型可能是由 API 层与数据库组成的,对于应用 API 而言,数据库是外部依赖,因此我们需要 mock 一个数据库,如果你使用的是一些比较常见的大型 ORM,很有可能他已经有成熟的测试方法,只要对照文档使用就可以了,从原理上来讲,一般会通过构造一个 mock 数据库以及 mock 表,将你伪造的数据提前填充完成初始化,然后开始进行测试。

另一个情况是你依赖了一些外部服务对你进行值的传入,比如说,我们会调用一些别的服务提供的 API(假设我们要通过淘宝提供的 API 查询快递的物流状态),那么就有可能需要考虑一个问题:「外部服务怎么办」,这里谨代表个人建议,尽可能的不要去 mock 外部服务,一旦 mock 了外部服务,意味着你要去维护外部服务所带来的变化,对于测试而言,就无法及时感知外部服务的变更了。

单元测试的 case 怎么挑

在开头我们也说到了单元测试的局限性,先来回答一下标题的疑问,「为什么单元测试覆盖率都到了 100% 了,还说不一定可靠」。

对于理想情况,也就是我们完全没有外部依赖的情况,比如我们写了一段逻辑处理的函数,那么自然,只要把覆盖率成功覆盖到 100%(这意味着所有分支都被测过了,可以正常落在分支的内容内)。但是假设我们有一行代码是 SELECT * WHERE item.category = 'test' AND NOT expired,那么即使写了一个正常的测试用例,将这一行代码覆盖了,也并不代表你的代码可以正常工作:因为从逻辑的角度,它可能是正常的,但是从业务的角度,可能并不符合需求。

直接以上面的 SQL 函数为例(请人脑自动将它映射为 ORM),我们要测试的可能是三个点:

  1. category 为 test 时的输出
  2. expired 为 true 时的输出
  3. category = test 以及 expired 为 true 时的输出
  4. category = test 以及 expired 为 false 时的输出

我们会发现,按照这样继续算下来,查询越复杂,测试样例的书写难度就会指数级上升。

同样的,程序越复杂,覆盖率越难达到 100%,那么我们就要有所考虑的去挑选测试用例,首先,最简单的一个优化方法是:

假定 result = funcB(funcA()),那么当 funcA 输出正常,而 funcB 也输出正常时,那么 result 的值就应该是正常的,也就是说,我们不用单独测试这一句,只需要将两个函数测完就可以了。

这一点其实在后端代码中我们也会经常遇到,我们的 Controller 层可能是非常薄的一层,很大情况下只是使用几个 utils 和 service 方法进行数据的拼接,那么我们的 Controller 实际上就不用单独进行测试了。

第二种情况比较特殊,主要是我个人口胡的一些情况,在一些内容上,我们会选择对业务有倾向性的测试,具体的来说,就是选择更重要的部分去进行测试,而对于一些不太重要分支,则可以不用进行处理,举一个简单而常见的例子,我们对某一个分支进行了一些特殊的错误处理,在全局兜底的错误处理方式是另一种,当然,两种大概是文案和状态码上的差异,那么,当我们确定在正常情况下能正常输出时,这个错误可能并不是我们重点的测试对象。

总结

当然,追求覆盖率越高越好是每个程序员的伟大理想,但毕竟还要考虑历史的进程,好,今天的文章总算写完了,解答了我刚入行时期的困惑,心满意足,皆大欢喜。