han's bolg - 年糕記

谈一谈单元测试

引子

这是我们平时的开发流程:

image

即:需求分析 -> ui 设计 -> 项目代码开发 -> 编写测试用例 –> 运行测试用例 -> 修复代码 BUG -> 上线 -> 上线回测

当然,测试部分的工作有时候会提前开发,比如在需求分析、应用开发阶段就介入进来做一些工作。但是测试工作主要还是放在开发完成之后再进行。

这样子有什么问题呢:

  • 测试时间依赖于开发时间

真正用于测试的时间,在整个项目的后期,并且严重依赖于开发时间。

当项目整体排期不变的情况下,如果开发时间 delay 了,必然导致压缩测试时间,即使测试和开发同学加班 debug,也会影响项目质量。

  • 开发质量依赖于测试质量

开发对于代码质量也没有把握,依赖于测试的水平。

image

  • 不确定新代码是否正常工作
  • 不确定新代码是否影响别的功能
  • 不确定重构后的代码是否正常工作
  • 不确定重构后的代码是否影响别的功能
  • 不确定上线后的代码是否正常工作
  • 不确定上线后的代码是否一直正常工作

如果你的项目或你本人存在以上问题,那么你可能需要采用一些措施了。

  • 线上监控

通过 log 日志,自动化测试,对线上环境比如数据库、服务器的性能指标进行监控,及时报警,这些策略保证了环境的问题。

  • 代码质量

提高代码质量的方式有:

  1. 静态代码分析
    规范写好的代码风格,减少出错风险,不能发现 bug
  2. 代码 review
    人力时间成本高
  3. 单元测试

什么是单元测试

定义

在计算机编程中,单元测试(英语:Unit Testing)又称为模块测试, 是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法

备注:单元测试对软件中的最小可测试单元进行检查和验证,并非检查程序单元之间是否能够合作良好

特点

  1. 独立性

单元测试用例之间不存在互相调用,也不依赖执行的先后次序,可以在多线程中并行执行。

反例:method2 需要依赖 method1 的执行,将执行结果作为 method2 的输入。
集成测试里可以这么干,为了进行场景测试(先登录,后买课)

  1. 确定性

确定的输入得到肯定的输出

  1. 可重复性

单元测试可以重复执行,不依赖于特定的时间、特定的环境、特定的测试数据。在不同的机器上都能得到相同的运行结果。

这里的环境包括操作系统的类型,网络,本地文件等。
说明:单元测试通常会被放到持续集成中,每次有代码 check in 时单元测试都会被执行。如
果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。
正例:为了不受外界环境影响,要求设计代码时就把 SUT 的依赖改成注入,在测试时用 spring
这样的 DI 框架注入一个本地(内存)实现或者 Mock 实现。

  1. 细粒度

单元规模较小,复杂性较低,粒度一般是方法级别,至多是类级别,不负责检查跨类或者跨系统的。因而发现错误后容易隔离和定位,有利于调试工作。

  1. 自动化

单元测试可以全自动执行的,并且是非交互式的,自动化的单元测试有助于进行回归测试。
输出结果需要人工检查的测试不是一个好的单元测试。单元测试中不准使用 System.out 来进行人肉验证,必须使用 assert 来验证。

  1. 易用性

单元测试中用到的,无非是白盒测试中覆盖分析在内的许多测试技术。

  1. 文档性

测试用例用于测试接口、模块的重要性,那么在测试用例中就会涉及如何使用这些 API。其他开发人员如果要使用这些 API,那阅读测试用例是一种很好地途径,有时比文档说明更清晰。

本质

单元测试不是目的,不是为了覆盖率达到 100%。它是一种手段,一种方法,一种规范代码编写、辅助提高代码质量的方法

为什么要做单元测试

对于单元测试的误解

  1. 单元测试应该让测试人员写

单元测试常常和编码同步进行,软件的开发者总是应当负责程序的单个单元的测试,保证每个单元能够完成设计的功能。

如果测试人员来做,

  • 不清楚项目代码逻辑

读别人的代码尚且费劲,还要写单元测试。
备注:我曾经想写精品课后台的单元测试,但是却连项目怎么运行都不清楚

  • 代码同步不及时

让测试人员来编写单元测试,必然存在后置性。
开发因为添加新功能、bugfix、重构而更新代码了,你只有在开发推送到 git 上,才知晓代码更新了。

  • 质量无法保证

单元测试是用来保证代码质量的,一方面是保证写好的代码不出错,另一方面是保证写“好的代码”。

测试人员写单元测试,只能他去适应开发人员的代码写测试用例。他能打断开发人员的思路,让他按照自己的方式去写代码吗?

  • 不方便交接

本来接手别人的项目就够困难的,还要接手另一个编写的单元测试,难上加难。

  1. 单元测试没有用

编写单元测试,使我们静下心来审视、观察、思考项目,把程序设计成易于调用和可测试的,并努力降低代码的耦合度;可以提前发现代码设计中不合理的地方,减少由于错误而引起的连锁反应。

  1. 没有时间写单元测试

为什么没有时间写?

  • 想快点开发完代码完成项目

但是我们忘记的一点是,项目时间不仅仅依赖于开发时间,还依赖于测试时间。如果代码质量不过关的话,bugfix 阶段会让你花费时间去查原因,修复 bug,也会延长项目的整体时间。
而且,当 bugfix 变成一种永无止境的任务时,从写代码中获取的兴奋感也会离你而去。

  • 深层次原因,其实是不会写

但就像你新学一门语言一样,刚开始的时候写的慢,写长了不就快了。最好的学习方法就是动手去敲啊。
而且从长期来看,写了单元测试降低了维护成本,在新需求和重构的时候不会陷入拆东墙补西墙的恐慌中,可以更自信的交出产品。

  1. 我写代码水平很高,不需要进行单元测试

人都会犯错,大神也不例外。

凡是可能出错的事就一定会出错 -- 墨菲定律

计算机按照你写出来的方式运行,而不是你想象出来的。编码不是可以一次性通过的,必须经过各种各样的测试,单元测试只是其中一种。

缺乏测试的程序代码可能包含许多 Bug,程序员在没有测试保护的情况下修改 Bug,会引发更多的 Bug,忙于除虫,于是更没有时间测试。如此循环往往会导致项目的崩溃。

为避免产生恶性循环,代码必须有一张安全网来保护,随时进行的单元测试就是这张安全网。

  1. 不管怎样,集成测试将会抓住所有的 Bug

image

“测试金字塔”模型按照运行速度和投入成本两个维度对不同阶段的测试工作进行非常直观的可视化,可以看到单元测试是位于“测试金字塔”的最底部,很明显“单元测试”相对于其它不同阶段的测试工作,拥有速度快(运行效率),成本低(维护成本)的优势,同时也是作为上层测试工作的支撑,体现了“单元测试”的重要程度。

效率问题。举例:学习报告项目,有一个数据不显示的问题,先提给前端,前端查了是接口的问题,提给后端,后端检查代码发现是直播服务端的问题,再提给直播服务端。

集成测试的目标是把通过单元测试的模块整合在一起,构造一个具体的使用场景,通过测试发现问题。它要求项目的所有流程是走的通的。

如果测试过程中的 Bug,非常严重,阻塞测试流程,以至于不能再测试其他功能,进行错误修改,回归测试时又发现其他新的问题,使得测试工作很难开展下去。这样子会拖延项目进展。

举例:学习报告项目,集合了精品课主站、题库、客户端、前端、后台管理系统多个服务,因为某个服务的问题,导致所有流程都被 block 了。

而且,有些 bug 是集成阶段很难去 debug 的。你要去排除各种各样的原因,前端的问题还是服务端的问题,还是客户端的问题。

举例:(想个别的例子)

题库模考添加新题目,添加第三个分卷时,添加题目异常。

  1. 单元测试不需要维护

一年半载后,那么单元测试几乎处于废弃状态。

  1. 单元测试与线上故障没有辩证关系

好的单元测试能够最大限度地规避线上故障。

  1. 单元测试保障一切

单元测试不是万能的,单元测试只是测试代码功能,不包含复杂的业务逻辑。

image

举例:登录逻辑没有问题,但用错接口服务了(测试服)。这个单元测试就发现不了了。

单元测试的意义:

对于整个项目

单元是整个软件的构成基础,单元测试是整个软件质量的基础。

就像硬件系统中的零部件一样,只有保证零部件的质量,这个设备的质量才有基础。因此,单元测试的效果会直接影响软件的后期测试,做好单元测试,后期的系统集成联调或集成测试和系统测试会很顺利,节约很多时间,最终在很大程度上影响到产品的质量。

对于开发人员

  1. 提高架构能力

编写单元测试将使我们从调用者的角度观察、思考,特别是要先考虑测试,这样就可把程序设计成易于调用和可测试的,并努力降低软件中的耦合,使得代码清晰、易于阅读,提高可维护性。
在开发过程中进行测试,可以提前发现代码设计中不合理的地方,提高代码的配置性,可扩展性。
比开发完再被叫去修 bug 要快许多,便于项目维护。

  1. 减少失误

增加新功能的时候,往往一改一大片。加入单元测试,这种修改幅度让你很难写测试。
单元测试强迫你降低修改幅度,每次一点一点的修改。

  1. 保证重构

互联网行业产品迭代速度很快,没有一成不变的代码,迭代后必然存在代码重构的过程,后续任何代码更新也必须跑通单元测试用例,保障预期功能的实现,也不会对其他功能产生干扰。

  1. 上线不慌

之前每次上线都小心翼翼,生怕修改之后,不符合原来的要求。单元测试用例能给你一颗定心丸。

为什么要做单元测试?

什么时候做单元测试

并不是所有的项目都值得引入测试框架,也不是任何时刻都可以写单元测试,毕竟维护测试用例也是需要成本的。

不适合

  • 需求频繁变更、复用性较低的内容

比如活动页面,让开发专门抽出人力来写测试用例确实得不偿失。

  • 刚刚接手的老项目

老项目能够运行起来就很不错了,更不用说修改代码,这个要慢慢来重构。

  • 不再更新,运行稳定的老服务

  • 不再维护,准备重构、放弃的项目

适合

  • 新框架、新服务、新项目

  • 基础服务、通用组件

被多次复用的部分,比如一些通用组件和库函数,更要测试来保障代码可维护性、功能的稳定性。
给它们写单元测试用例,维护成本低。

  • 核心业务、核心应用、核心模块

支付、登录、退款。。。

怎么做单元测试

TDD(Test-driven development):

介绍一下 TDD,测试驱动开发是敏捷开发中的一项核心实践和技术,也是一种设计方法论。
测试驱动开发的基本思想就是通过测试来推动整个开发的进行,在开发功能代码之前,先编写测试代码。也就是说在明确要开发某个功能后,首先思考如何对这个功能进行测试,并完成测试代码的编写,然后编写相关的代码满足这些测试用例。然后循环进行添加其他功能,直到完全部功能的开发。

单元测试的首要目的不是为了能够编写出大覆盖率的全部通过的测试代码,而是需要从使用者(调用者)的角度出发,尝试函数逻辑的各种可能性,进而辅助性增强代码质量

测试是手段而不是目的。测试的主要目的不是证明代码正确,而是帮助发现错误,包括低级的错误。

开发流程

  1. 明确当前要完成的功能。可以记录成一个 TODO 列表

需求分析,思考实现。考虑如何“使用”产品代码,是一个实例方法还是一个类方法,是从构造函数传参还是从方法调用传参,方法的命名,返回值等。这时其实就是在做设计,而且设计以代码来体现。

  1. 快速完成针对此功能的测试用例编写

只关注需求,程序的输入输出,不关心中间过程

  1. 测试代码编译不通过

  2. 编写对应的功能代码

不考虑别的需求,用最简单的方式满足当前这个小需求即可

  1. 测试通过

对代码进行重构,并保证测试通过。

  1. 循环完成所有功能的开发。

写完,手动测试一下,基本没什么问题,有问题补个用例,修复。

最终符合所有要求即:

  • 每个概念都被清晰的表达
  • 代码中无自我重复
  • 没有多余的东西
  1. 信心满满地提交,通过测试

更概括的来说,可以分为三部曲:

红灯(代码还不完善,测试挂)-> 绿灯(编写代码,测试通过)-> 重构(优化代码并保证测试通过)
image

image

TDD 的本质

  • 分离关注点,一次只戴一顶帽子

在我们编程的过程中,有几个关注点:需求,实现,设计。
TDD 给了我们明确的三个步骤,每个步骤关注一个方面。
红:写一个失败的测试,它是对一个小需求的描述,只需要关心输入输出,这个时候根本不用关心如何实现。
绿:专注在用最快的方式实现当前这个小需求,不用关心其他需求,也不要管代码的质量多么惨不忍睹。
重构:既不用思考需求,也没有实现的压力,只需要找出代码中的坏味道,并用一个手法消除它,让代码变成整洁的代码。

  • 注意力控制

人的注意力既可以主动控制,也会被被动吸引。注意力来回切换的话,就会消耗更多精力,思考也会不那么完整。

使用 TDD 开发,我们要主动去控制注意力,写测试的时候,发现一个类没有定义,IDE 提示编译错误,这时候你如果去创建这个类,你的注意力就不在需求上了,已经切换到了实现上,我们应该专注地写完这个测试,思考它是否表达了需求,确定无误后再开始去消除编译错误。

单元测试的本质

我不是强制向大家推行 TDD,说实话 TDD 很复杂,不是一句两句就学会的,何况我也没什么经验。
我们要借鉴 TDD 的思想,那就是写代码之前,先别急着动手,先想。考虑周全了再写。
这个时候,即使先写功能,再写测试也是没问题的。一定不要把功能都写完了,再补测试,你会发现很痛苦。
可能为了某个测试用例的写法,需要重构你的代码,这样子就相当于花费了更多的精力。

拆分项目

TDD 之前要拆分任务,把一个大需求拆成多个小需求。

原则

  • 结合设计文档,关注于完成当前功能点,防止过度设计

仅凭这个就可以节省大量精力了。那些仅仅是“可能”会发生的事情,我们不会去做。过多的考虑后期的扩展,其他功能的添加,无疑增加了过多的复杂性,容易产生问题。

单元测试不仅是证明这些代码做了什么,是如何做的,而且证明是否做了它该做的事情而没有做不该做的事情。

(小字)拆分不是让我们把任务想的非常全,在后续兼容上方便。后续添加的时候,一看,哎,我之前想到了,这里留着参数等着用。万一用不着呢?那不就浪费时间了嘛。

那么需求真的变了怎么办?改呗。有整套测试用例做基础,会让应对变化变得容易,但它会让你有底气去做出修改。

还是那句话,天下武功,唯快不破,改得越快,成本越低。

  • 任务维持在单元级别

当前功能太过复杂,继续分解成小的任务来完成。

举例:既要获取用户个人信息,又要获取用户购课信息。

单元测试

工欲善其事必先利其器

测试框架

  • Java: Junit, TestNG
  • Javascript: Jest, Mocha
  • Android/Kotlin: Junit
  • Swift: XCTest
  • Python: Unittest
  • C++: gtest
  • Go: go test

覆盖率分析

  • Java: Emma, Jacoco
  • Javascript: istanbul(Jest 内置)
  • Python: Coverage.py
  • C++: LCOV
常用的覆盖率
  • 语句覆盖率/行覆盖率

语句覆盖指被测单元中每条可执行语句都被测试用例所覆盖。语句覆盖是强度最低的覆盖要求

  • 分支覆盖率

用来度量程序中每一个判定的分支是否都被测试到,即代码中每个分支语句取真值和取假值至少各覆盖一次

  • 函数覆盖率

覆盖率只是单元测试的一种衡量标准,一种基于定量的衡量标准,不足以说明测试的稳健性。不要过度追求覆盖率,100%的覆盖率不代表你的代码就没有问题了。统计代码覆盖率的目的是找出哪些类型的代码未经过测试,并针对性的补充测试用例。

就像那些正在出差并出示护照印章的人一样 - 这并不能证明他做过任何工作,只是他访问过几个机场和酒店。

1
2
3
4
5
6
7
8
9
10
11
function addNewOrder(newOrder) {
logger.log(`Adding new order ${newOrder}`);
DB.save(newOrder);
Mailer.sendMail(newOrder.assignee, `A new order was places ${newOrder}`);

return {approved: true};
}

it("Test addNewOrder, don't use such test names", () => {
addNewOrder({asignee: "John@mailer.com",price: 120});
});//Triggers 100% code coverage, but it doesn't check anything

断言库 Assert

有的语言还有单独的断言库,有的自带了。

Mock 框架

mock 就是在测试过程中,对于某些不容易构造或者不容易获取的对象,用一个虚拟的对象来创建以便测试。

Lint

Lint 是检查代码风格/错误的小工具,作用是提高代码质量。

Javascript 编写有 eslint, 而单元测试编写有 eslint-plugin-jest, eslint-plugin-mocha

持续集成

  • Travis-ci
  • Jenkins

单元测试原则

测试隔离

  • 不同代码的测试应该相互隔离
  • 单元测试之间没有依赖关系
  • 对一个代码单元只考虑此代码的测试

不考虑第三方调用
不考虑内部方法
不考虑子类

// todo:代码片段
例:react 中父子组件之间/一个函数调用了其他函数/controller 和 service

1
2
3
4
5
function A(value) {
const a = 10
const b = B(value)
return a + b
}

输入输出

  • 数据尽量使用真实数据、边界数据

  • 不要用随机数,使用确定的数值

  • 不要使用开发代码里的命名常量

他们可能是错误的,可能会更改

  • 不要共享全局数据,否则一个出错会导致多个测试用例出错(?)

  • 定义好时间(跨时区问题)、光速、重力等物理常量

  • 不要在单元测试里做逻辑问题(条件、选择逻辑)

把他们写到多条单元测试里

  • 在合适的位置使用合适的注解

  • 一个单元测试里只有一个 assert

  • 通过的测试不产生任何输出

  • 未通过的测试只产生必要的输出

  • 不要为了通过一项测试用例, 而写一个无意义的测试, 比如仅有一句 assert(true)

  • 在测试中使用适当的 Mock 技术

可以降低测试的相互依赖性, 更快速的编写测试用例. 一般的 Mock 对象包括: 数据库, 其他产品和服务等.
避免在测试代码中使用基类, 即尽量不要有过多的继承层次. 目的是保持测试代码的简洁易读, 无依赖性

环境

  • 不要访问网络

  • 不要访问文件系统

  • 不要访问预设的外部资源

单元测试代码不应该假定外部的执行环境, 以便在任何时候/任何地方都能执行. 为了向测试提供必需的资源, 这些资源应该由测试本身提供.

比如一个解析某类型文件的类, 可以把文件内容嵌入到测试代码里, 在测试的时候写入到临时文件, 测试结束再删除, 而不是从预定的地址直接读取.

执行

  • 一个单元测试应该在 1s 内完成
  • 一组测试集应该在 1 分钟内完成
  • 把一个大的测试集分成多个小的测试集
  • 把执行最慢的用例放在最后执行

更新代码时

测试列表。需要测试的功能点很多。应该在任何阶段想添加功能需求问题时,把相关功能点加到测试列表中,然后继续手头工作。然后不断的完成对应的测试用例、功能代码、重构。一是避免疏漏,也避免干扰当前进行的工作。

生产环境或发布版本中不能带有测试代码

单元测试方法

命名习惯

测试代码 Package 的命名和结构应该保持和实现代码的保持一致. 这样可以提高测试代码的可维护性

  • 测试代码与开发代码隔离

许多构建和测试工具都需要将测试代码放在一个特定的源码目录中. Maven 和 Gradle 默认 src/main/java 中是实现代码, 而 src/test/java 中是测试代码.

  • 实现代码和测试代码的包名结构保持一致

  • 测试类的命名和实现类命名有规律

通常是用 Test 或其他类似词汇作为前缀或后缀
// todo:截图
比如 src/controller/Login.java

test/controller/Login.test.java

  • 使用有意义的测试用例名称, 要能够大致描述测试的对象或行为。

每个测试名称包含 3 个部分

(1)正在测试什么?例如,ProductsService.addNewProduct 方法

(2)在什么情况和情况下?例如,没有价格传递给方法

(3)预期结果是什么?例如,新产品未获批准

好的测试报告类似于需求文档

1
2
3
4
5
6
describe('ProductsService', function() { // testsuite测试用例集
it('addNewProduct, when no price is specified, then the product status is pending approval', ()=> { // testcase测试用例
const newProduct = new ProductService().add(...);
expect(newProduct.status).to.equal('pendingApproval');// assert
});
});

测试技术

在单元测试时要根据“白盒”测试和“黑盒”测试用例设计方法设计测试用例

黑盒测试

等价类划分法
边界值分析法
错误推测法
因果图法
功能图法

白盒测试

主要是逻辑驱动法和基本路径法

语句覆盖。
判定覆盖。
条件覆盖。
判定/条件覆盖。
条件组合覆盖。
路径覆盖。

等级
  1. Level1:正常流程可用,即一个函数在输入正确的参数时,会有正确的输出
    正确的输入,并得到预期的结果
  2. Level2:异常流程可抛出逻辑异常,即输入参数有误时,不能抛出系统异常,而是用自己定义的逻辑异常通知上层调用代码其错误之处
    强制错误信息输入(如:非法数据、异常流程、非业务允许输入等),并得到预期的结果。
  3. Level3:极端情况和边界数据可用,对输入参数的边界情况也要单独测试,确保输出是正确有效的
    边界值测试,包括循环边界、特殊取值、特殊时间点、数据顺序
  4. Level4:所有分支、循环的逻辑走通,不能有任何流程是测试不到的
  5. Level5:输出数据的所有字段验证,对有复杂数据结构的输出,确保每个字段都是正确的

单元测试的实施规则

  1. 安排测试优先次序,核心业务、核心应用、核心模块的增量代码确保单元测试通过。

    说明:新增代码及时补充单元测试,如果新增代码影响了原有单元测试,请及时修正。

  2. 单元测试的基本目标:语句覆盖率达到 70%;核心模块的语句覆盖率和分支覆盖率都
    要达到 100%

高的代码覆盖率不一定能保证代码的质量,但是低的代码覆盖率一定不能保证代码的质量。

  1. 对于不可测的代码建议做必要的重构,使代码变得可测,避免为了达到测试要求而
    书写不规范测试代码。

  2. 在设计评审阶段,开发人员需要和测试人员一起确定单元测试范围,单元测试最好
    覆盖所有测试用例。

  3. 单元测试作为一种质量保障手段,不建议项目发布后补充单元测试用例,建议在项
    目提测前完成单元测试。

  4. 所有的测试都必须通过:这一点很重要,不能因为懒惰而产生 Failed 掉的 Case。

  5. 最简单的测试也远远胜过完全没有测试。

总结

单元测试写起来不容易,且写且珍惜。

参考文献: