1. 为什么要做单元测试?
在实际研发与测试工作中,单元测试是代码走向高质量的必经之路,也是效能优化实践的重要一环。单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类、超类、抽象类等中的方法。单元测试就是软件开发中对最小单位进行正确性检验的测试工作。
不同地方对单元测试有的定义可能会有所不同,但有一些基本共识:
- 单元测试是比较底层的,关注代码的局部而不是整体。
- 单元测试是开发人员在写代码时候写的。
- 单元测试需要比其他测试运行得快。
回答Why的问题
从软件/项目工程上面来说:
- 单元测试是提高代码质量的工具。代码测试都是为了帮助开发人员发现问题从而解决问题,提高代码质量。
- 单元测试可以尽早发现问题。问题越早发现,解决的难度和成本就越低。
- 单元测试可以保证重构正确性。随着功能的增加,重构(修改老代码)几乎是无法避免的。很多时候我们不敢重构的原因,就是担心其它模块因为依赖它而不工作。有了单元测试,只要在改完代码后运行一下单测就知道改动对整个系统的影响了,从而可以让我们放心的重构代码。
- 单元测试可以简化调试过程。单元测试让我们可以轻松地知道是哪一部分代码出了问题。
- 单元测试可以简化集成过程。由于各个单元已经被测试,在集成过程中进行的后续测试会更加容易。
- 单元测试可以优化代码设计。编写测试用例会迫使开发人员仔细思考代码的设计和必须完成的工作,有利于开发人员加深对代码功能的理解,从而形成更合理的设计和结构。
- 单元测试是最好的文档。单元测试覆盖了接口的所有使用方法,是最好的示例代码。而真正的文档包括注释很有可能和代码不同步,并且看不懂。
软件工程的3个基本要素就是过程,方法,工具, 而质量作为软件工程的根基,是必须保证的。
单元测试可以检查软件工程中3要素的质量,是软件工程必须要经历的一个环节。
随着业务复杂度的上升,软件工程至少经历了3次变革,从早期的瀑布模型到后来发展的V模型再到原型实现模型。
不管软件开发过程如何变化,测试始终是一个非常重要的阶段。可以很直接地说没有做过测试的软件开发,就是假开发, 也没有质量一说。
从工程师自己来说:
- 提高程序员对于产品的信心,每个人都需要有自信, 但不是自大。
- 给代码做测试,是一种工程素养,泥瓦匠和建筑工程师是不一样的。 image.png
- 测试可以减少不必要的加班改bug时间, 间接提高程序员幸福指数,防脱发。事后验收,存在着较长的验收周期和较高的修正成本 微软的统计数据:bug在单元测试阶段被发现,平均耗时3.25小时,如果漏到系统测试阶段,要花费11.5小时。 image.png指数级别的增高。所以,在早期的单元测试就能发现bug,省时省力,一劳永逸,何乐而不为呢 image.png
2. 单元测试的重点和难点是什么?
2.1. 如何区分项目中的测试重点?
分析需求,明确测试重点和难点
2.2. 如何把握单元测试的粒度?
a. 优先考虑对核心逻辑代码进行测试
b.优先针对于容易出错、没有信心的部分代码做测试
C. 优先考虑对存在状态变化的代码进行测试
d. 优先保证产品周期,非致命问题可以右移
2.3. 如何破除外部依赖(mock,stub 技术)?
使用monkey等mock/stub工具
2.4. 如何保证测试用例的有效性?
使用覆盖率,变异测试, bug率等指标。
2.5. 产品变化快,如何维护好单元测试 ?
遇到需求变动,先改测试用例,再改逻辑。
2.6. 代码太乱,不好测试?
先重构代码,在考虑如何写测试。
编程时, 应该保证代码的可测性, 需要遵循至少以下3点:
- 代码设计的SOLID原则 【推荐】单一职责原则,一个类应当有且仅有一个引起它变化的原因。简单来说就是一个应当只包含一组相关性很高的函数和数据的封装,避免将其它非必要职责也被包裹进去 【推荐】开闭原则,软件中的模块对扩展开放,对修改关闭,应当在设计阶段就考虑清楚这个问题,选择合适的架构 【推荐】里氏替换原则,任何父类出现的地方,都可以使用子类对象替换 【推荐】依赖倒置原则,抽象不该依赖于细节,细节应当依赖于抽象,简单来说就是,应当尽量针对接口编程,而不是针对细节编程,某种程度上,依赖导致原则也是开闭原则的一个实践 【推荐】接口隔离原则,客户端不应该被强迫实现一些他们不会使用的接口,应该把这种接口分组,然后用多个专用接口替代它,简单来说就是尽量使用多个专门的接口来取代单个接口 【推荐】最少知识原则,又称迪米特法则,一个类对于其他类的了解应该越少越好
- 业务代码编写规范 在六大基本原则的基础上,为了更好的提升业务代码的可测性,此处给出如下建议: 【必须】不要在包初始化时,请求外部资源 【推荐】业务代码应当是功能代码的组合 【推荐】函数的圈复杂度不宜过高
- 编程语言的编写规范 《golang 编程规范》 《golang 代码安全规范》
2.7. 开发人员测试意识不高,不想写?
可以按阶段来推进这一部分工作并且借助高层的力量。我们可以分如下2个阶段:
- 万事开头难,先要会写,先把自动化流水线搞起来,覆盖率报告收集起来,没有写单侧可以通过报表查看到。
- 稳重求进,追求质量和效率,同时关注可测性问题,对测试用例质量进行要求。
3. 如何写好测试用例?
3.1 理论原则
好的单元测试需要遵循测试的FIRST原则
- F-Fast:快速 单元测试是回归测试,可以在开发过程的任何时间段运行,可能会重复多次,因此运行速度必须快
- I-Isolated:隔离 好的单元测试应当做到每个测试只关注逻辑的一个方面,即单一职责
- R-Repeatable:可重复 单元测试需要保持稳定,不可重复的测试结果会导致反复的尝试,也会导致结果的不可靠性
- S-Self Verifying:自我验证 单元测试需要用断言(assert)函数进行自我验证
- T-Timely & Thorough:及时 & 全面 等代码稳定运行再来补齐单元测试无疑是十分低效的,最有效的方式是在写好功能函数接口后(实现函数功能前)进行单元测试,并且单元测试的测试用例应当尽可能全面 image.png
除了FIRST中提到这几点,一个好的单元测试还应该具备以下能力:
- 回归保护(Protection against regression) 在代码修改后,单元测试应该能够及时地发现软件中的bug,保证代码功能的正常
- 强重构耐受力(Resistance to refractoring) 代码重构后,如果功能正常,但单元测试会出现误报的情况,误报越多说明重构耐受力越差,因此因此应当将单元测试与业务实现具体细节进行解耦,只关注业务的可观测行为
- 快速反馈(Fast feedback) 一个好的单元测试应当保证尽量快的给予开发者反馈
- 高可维护性(Maintainability) 对于单元测试的可维护性可以从两个方面来进行,在理解难度方面,单元测试应尽量保证良好的可读性(readability),执行难度方面单元测试对外部依赖应当有
- 强可做操性(Actionable) 如果测试失败,应该能清晰的描述什么失败了,能够快速定位到发生错误的位置
- 良好的可测试性(Testability) 一个好的单元测试应当具有良好的可测试性,不去过多地进行mock,一些没有IO、RPC的环境应尽量避免进行mock,以便代码发生变更时能更及时地抛出问题
3.2 规约原则
在实际编写代码过程中,不同的团队会有不同团队的风格,只要团队内部保持有一定的规约即可,比如:
- 单元测试文件名必须以xxx_test.go命名
- 方法必须是TestXxx开头,建议风格保持一致(驼峰或者下划线)
- 方法参数必须 t *testing.T
- 测试文件和被测试文件必须在一个包中
规范可以自定义, 也可以参考《golang测试用例规范》
3.3 衡量原则
单元测试是要写额外的代码的,这对开发同学的也是一个不小的工作负担,在一些项目中,我们合理的评估单元测试的编写,我认为我们不能走极端,当然理论上来说全写肯定时好的,但是从成本,效率上来说我们必须做出权衡,衡量原则如下:
- 优先编写核心组件和逻辑模块的测试用例
- 逻辑类似的组件如果存在多个,优先编写其中一种逻辑组件的测试用例
- 发现Bug时一定先编写测试用例进行Debug
- 关键util工具类要编写测试用例,这些util工具适用的很频繁,所以这个原则也叫做热点原则,和第1点相呼应。
- 测试用户应该独立,一个文件对应一个,而且不同的测试用例之间不要互相依赖。
- 测试用例的保持更新
4. 设计方法
4.1 规范(规格)导出法
规范(规格)导出法将需求”翻译“成测试用例。
例如,一个函数的设计需求如下:
函数:一个计算平方根的函数
输入: 实数
输出: 实数
要求: 当输入一个0或者比0大的实数时,返回其正的平方根;当输入一个小于0的实数时,显示错误信息“平方根非法—输入之小于0”,并返回0;库函数 printf()
可以用来输出错误信息。 |
在这个规范中有3个陈述,可以用两个测试用例来对应:
- 测试用例1:输入4,输出2。
- 测试用例2:输入-1,输出0。
4.2 等价类划分法
等价类划分法假定某一特定的等价类中的所有值对于测试目的来说是等价的,所以在每个等价类中找一个之作为测试用例。
- 按照 输入条件无效等价类 建立等价类表,列出所有划分出的等价类
- 为每一个等价类规定一个唯一的编号
- 设计一个新的测试用例,使其尽可能多地覆盖尚未被覆盖地有效等价类。重复这一步,直到所有的有效等价类都被覆盖为止
- 设计一个新的测试用例,使其仅覆盖一个尚未被覆盖的无效等价类。重复这一步,直到所有的无效等价类都被覆盖为止 例如,注册邮箱时要求用6~18个字符,可使用字母、数字、下划线,需以字母开头。
有效等价类 | 无效等价类 |
---|---|
6~18个字符(1) | 少于6个字符(2) 多余18个字符(3) 空(4) |
包含字母、数字、下划线(5) | 除字母、数字、下划线的特殊字符(6) 非打印字符(7) 中文字符 (8) |
以字母开头(9) | 以数字或下划线开头(10) |
测试用例:
编号 | 输入数据 | 覆盖等价类 | 预期结果 |
---|---|---|---|
1 | test_111 | (1)、(5)、(9) | 合法输入 |
2 | t_11 | (2)、(5)、(9) | 非法输入 |
3 | testtesttest_12345678 | (3)、(5)、(9) | 非法输入 |
4 | NULL | (4) | 非法输入 |
5 | test!@1111 | (1)、(6)、(9) | 非法输入 |
6 | test 1111 | (1)、(7)、(9) | 非法输入 |
7 | test测试1111 | (1)、(8)、(9) | 非法输入 |
8 | _test111 | (1)、(5)、(10) | 非法输入 |
4.3 边界值分析法
边界值分析法使用与等价类测试方法相同的等价类划分,只是边界值分析假定
错误更多地存在于两个划分的边界上。
边界值测试在软件变得复杂的时候也会变得不实用。边界值测试对于非向量类型的值(如枚举类型的值)也没有意义。
例如,和4.1相同的需求:
划分(ii)的边界为0和最大正实数;划分(i)的边界为最小负实数和0。由此得到以下测试用例:
- 输入 {最小负实数}
- 输入 {绝对值很小的负数}
- 输入 0
- 输入 {绝对值很小的正数}
- 输入 {最大正实数}
4.4 基本路径测试法
基本路径测试法是在程序控制流图的基础上,通过分析控制构造的环路复杂性,导出基本可执行路径集合,从而设计测试用例的方法。设计出的测试用例要保证在测试中程序的每个可执行语句至少执行一次。
基本路径测试法的基本步骤:
- 程序的控制流图:描述程序控制流的一种图示方法。
- 程序圈复杂度:McCabe复杂性度量。从程序的环路复杂性可导出程序基本路径集合中的独立路径条数,这是确定程序中每个可执行语句至少执行一次所必须的测试用例数目的上界。
- 导出测试用例:根据圈复杂度和程序结构设计用例数据输入和预期结果。
- 准备测试用例:确保基本路径集中的每一条路径的执行。
5. 如何评估单元测试的质量?
虽然目前并没有直接的指标去衡量单测的质量,但是我们可以通过一些间接手段保证单元测试的质量。
以下是一些常用的用来检查单元测试质量的的指标:
5.1 被测代码的质量的评估
- bug类指标(间接指标):连续迭代的bug总数趋势、迭代内新建bug的趋势、千行bug率等
- 单测case总数趋势,代码行增量趋势
- 单函数圈复杂度(低于40),单函数代码行数(低于80),扫描告警数
- 模块的代码规范数,代码缺陷数,安全漏洞数 可以使用公司的一些代码分析工具比如CodeCC进行扫描,基本上一些代码规范,代码缺陷,安全漏洞,圈选复杂度等。单元测试前请尽可能保证代码本身是没有问题的,且当前迭代不会做太大改动, 如果被测试的代码经常变动,那么对这段代码做单元测试将是噩梦。单侧前请保证项目代码至少是3星以上的, 否则请先解决单测前的质量问题。
5.2 测试代码的质量的评估
- 增量代码的行覆盖率(接入层80%,客户端30%)
- 单测的需求覆盖度(40%以上)
- 变异测试的用例有效性得分 测试覆盖率可以接入公司的覆盖率平台进行统计, 变异测试也是可以接入公司支持变异测试的平台集成到流水线里面,后面在自动化模块我会详细讲解。 只有被测代码和单元测试代码都是高质量的,单元测试的质量才是有保证的。
6. 单元测试的一些有哪些关键概念?
6.1. TDD:测试驱动开发(Test-Driven Development)
测试驱动开发是敏捷开发中的一项核心实践和技术,也是一种设计方法论,TDD首先考虑使用需求(对象、功能、过程、接口等)。主要是编写测试用例框架对功能的过程和接口进行设计,而测试框架可以持续进行验证。大行其道的一些模式对TDD的支持都非常不错,比如MVC和MVP等。
6.2. BDD:行为驱动开发(Behavior Driven Development)
BDD也就是行为驱动开发。这里的B并非指的是Business,实际上BDD可以看作是对TDD的一种补充,让开发、测试、BA以及客户都能在这个基础上达成一致,JBehave之类的BDD框架。
6.3. ATDD:验收测试驱动开发(Acceptance Test Driven Development)
通过单元测试用例来驱动功能代码的实现,团队需要定义出期望的质量标准和验收细则,以明确而且达成共识的验收测试计划(包含一系列测试场景)来驱动开发人员的TDD实践和测试人员的测试脚本开发。面向开发人员,强调如何实现系统以及如何检验。
6.4. 测试用例(Test Case)
测试用例(Test Case)是指对一项特定的软件产品进行测试任务的描述,体现测试方案、方法、技术和策略。其内容包括测试目标、测试环境、输入数据、测试步骤、预期结果、测试脚本等,最终形成文档。简单地认为,测试用例是为某个特殊目标而编制的一组测试输入、执行条件以及预期结果,用于核实是否满足某个特定软件需求
6.5. 测试报告(Test Report)
测试报告是指把测试的过程和结果写成文档,对发现的问题和缺陷进行分析,为纠正软件的存在的质量问题提供依据,同时为软件验收和交付打下基础。测试报告的内容可以总结为以下目录:
- 首页
- 引言(目的、背景、缩略语、参考文献)
- 测试概要(测试方法、范围、测试环境、工具)
- 测试结果与缺陷分析(功能、性能)
- 测试结论与建议(项目概况、测试时间 测试情况、结论性能汇总)
- 附录(缺陷统计)
6.6. 黑盒测试/白盒测试/灰盒测试
黑盒测试 (Black Box Testin)又叫数据驱动测试,本质上就是功能测试。把测试对象当做一个黑盒子,测试时,对程序内部的逻辑结构和内部特性,完全不需要考虑。根据需求说明书,测试程序的功能,是否符合它的说明。
白盒测试 (white-box testing)又称为结构测试或逻辑驱动测试。本质上就是通过代码检查的方式进行测试.把测试对象看做一个打开的盒子,测试人员用程序内部的逻辑结构、有关信息,设计或选择测试用例,对程序所有逻辑路径展开测试。在不同的点检查程序状态,确定实际状态,是否与预期的状态一致。
灰盒测试(Grey Box Testing)是介于白盒测试与黑盒测试之间。可以这样理解,灰盒测试关注输出对于输入的正确性,同时也关注内部表现,但这种关注不象白盒那样详细、完整,只是通过一些表征性的现象、事件、标志来判断内部的运行状态,有时候输出是正确的,但内部其实已经错误了。这种情况非常多,如果每次都通过白盒测试来操作,效率会很低,因此需要采取这样的一种灰盒的方法。
更多测试分类请参考https://www.cnblogs.com/findyou/p/6480411.html
6.7. Mock/Stub
在选择 Stub/Mock框架前简单说一下这2个词的意思。如果被测程序、系统或对象,我们称之为A,那么Stub和Mock指的并不是A,而是测A的过程中,A需要与之交互的程序、系统或对象B。为了测试A而又不会影响B,我们通常需要一个B的“替身”。
Stub,也即“桩”,很早就有这个说法了,也有人说“打桩”,主要出现在集成测试的过程中,从上往下的集成时,作为下方程序的替代。作用如其名,就是在需要时,能够发现它存在,即可。就好像点名,“到”即可。
Mock,主要是指某个程序的傀儡,也即一个虚假的程序,可以按照测试者的意愿做出响应,返回被测对象需要得到的信息。也即是要风得风、要雨得雨、要返回什么值就返回什么值。
6.8. 断言(assert)
编写代码时,我们总是会做出一些假设,断言就是用于在代码中捕捉这些假设。程序员相信在程序中的某个特定点该表达式值为真,可以在任何时候启用和禁用断言验证,因此可以在测试时启用断言而在部署时禁用断言。同样,程序投入运行后,最终用户在遇到问题时可以重新启用断言。
使用断言可以创建更稳定、品质更好且 不易于出错的代码。当需要在一个值为FALSE时中断当前操作的话,可以使用断言。单元测试必须使用断言(Junit/JunitX)。
6.9.测试左移和右移
测试左移可以降低成本提高效率,预防bug比修复bug的成本要低得多。
测试右移可以降低试错成本,提升问题拦截率,降低影响面。
测试左移和右移,构成一个完整的研发和运营质量闭环,前后贯穿整个质量体系,一起构建质量墙。
这里的执行者,并不限定只能是测试人员。包括产品、开发、测试、运维、运营等等,全员都可以承担里面的任务。
质量是没有边界的,项目是大家共同的。
7. go后台服务单元测试常用框架组件
7.1. 断言框架
由于Golang原生没有提供断言,所以我们需要考虑引入Golang的一些断言组件,下面是几种比较常见的测试框架
测试框架 | 推荐指数 |
---|---|
Go自带的testing包 | ★★☆☆☆ |
GoConvey | ★★★★★ |
Testify | ★★★★☆ |
- Testify使用简单,非常适合推广使用。
- GoConvey和其他Stub/Mock框架的兼容性相比Testify更好。
- Testify自带Mock框架,但是用这个框架Mock类需要自己写。像这样重复有规律的部分在GoMock中是一键自动生成的。
- GoConvey "Given-when-then"(如果-当-推断)的模式, 非常符合我们人的正常理解逻辑,但是这样会带来一定的圈选复杂度。
7.2 goconvey
github地址:https://github.com/smartystreets/goconvey
特性:
- 直接集成go test
- 可以管理和运行测试用例
- 提供了丰富的断言函数
- 支持很多 Web 界面特性(通过http://localhost:8080 访问)
- 设置界面主题
- 查看完整的测试结果
- 使用浏览器提醒
- 自动检测代码变动并编译测试
- 半自动化书写测试用例:http://localhost:8080/composer.html
- 查看测试覆盖率:http://localhost:8080/reports/
- 临时屏蔽某个包的编译测试 使用教程请参考:《golang测试框架goconvey的使用》
7.3 testify
github地址:https://github.com/stretchr/testify
特性:
- 在提供断言功能之外,还提供了mock的功能
- suite包可以给每个测试用例进行前置操作和后置操作的功能(例如初始化和清空数据库) 使用教程请参考:《golang测试框架testify的使用》
7.4. Stub/Mock框架
Golang有以下几种Stub/Mock框架:
测试框架 | 推荐指数 |
---|---|
GoStub | ★★☆☆☆ |
GoMock | ★★☆☆☆ |
GoMonkey | ★★★★★ |
考虑到gomonkey的功能比较齐全,对代码侵入小,故选择 GoMonkey框架做“替身”,下面简单列一下这几个框架的介绍。
7.5 gostub
github地址:https://github.com/prashantv/gostub
详见:《golang测试框架gostub的使用》
特性:
- 可以为全局变量、函数、过程打桩
- 比gomock轻量,不需要依赖接口
缺陷:
- 对项目源代码有侵入性,即被打桩方法必须赋值给一个变量,只有以这种形式定义的方法才能别打桩
7.6 gomock
github地址:https://github.com/golang/mock
详见:《go测试框架gomock的使用》
特性:
- golang官方开发维护的接口级别的mock方案
- 包含了GoMock包和mockgen工具两部分,其中GoMock包完成对桩对象生命周期的管理,mockgen工具用来生成interface对应的Mock类源文件。
缺陷:
- 只有以接口定义的方法才能mock
- 需要用mockgen生成源文件,然后用gomock去实现自己想要的数据,使用比较复杂。
7.7 gomonkey
github地址:https://github.com/bouk/monkey
详见:《go测试框架gomonkey的使用》
特性:
- 可以为全局变量、函数、过程、方法打桩,同时避免了gostub对代码的侵入
缺陷:
- 对inline函数打桩无效
- 不支持多次调用桩函数(方法)而呈现不同行为的复杂情况
7.8 sqlmock
github地址: https://github.com/DATA-DOG/go-sqlmock
特性:
- 适用于和数据库的交互场景。可以创建模拟连接,编写原生sql 语句,编写返回值或者错误信息并判断执行结果和预设的返回值
- 提供了完整的事务的执行测试框架,支持prepare参数化提交和执行的Mock方案
- 持久层框架底层一般都使用”github.com/go-sql-driver/mysql”,所以一般都能够使用sqlmock库进行mock
缺陷:
- 因为是正则匹配,所以可能漏掉sql的语法错误
- 写入后没法验证
sqlmock只适合用在简单的场景, 业务实际使用的时候更多还是建议在docker里起一个mysql, 然后把测试数据载入db里面并做成自动化流水线, 这种方式会比sqlmock高效很多, 不过这需要完善的基础设施和运维经验.
7.9 stub Redis
github地址: https://github.com/alicebob/miniredis
假如程序里用到 Redis,要伪造一个 Redis Client 用之前的办法也是可以的。miniredis
是在 Golang 程序中运行的 Redis Server,它实现了大部分原装 Redis 的功能,测试的时候miniredis.Run()
然后将 Redis Client 连向 miniredis 就可以了。
import (
...
"github.com/alicebob/miniredis/v2"
...
)
func TestSomething(t *testing.T) {
s := miniredis.RunT(t)
// Optionally set some keys your code expects:
s.Set("foo", "bar")
s.HSet("some", "other", "key")
// Run your code and see if it behaves.
// An example using the redigo library from "github.com/gomodule/redigo/redis":
c, err := redis.Dial("tcp", s.Addr())
_, err = c.Do("SET", "foo", "bar")
// Optionally check values in redis...
if got, err := s.Get("foo"); err != nil || got != "bar" {
t.Error("'foo' has the wrong value")
}
// ... or use a helper for that:
s.CheckGet(t, "foo", "bar")
// TTL and expiration:
s.Set("foo", "bar")
s.SetTTL("foo", 10*time.Second)
s.FastForward(11 * time.Second)
if s.Exists("foo") {
t.Fatal("'foo' should not have existed anymore")
}
}
7.10 mock http request
详情参见:《Go测试框架-Mock http请求》
单元测试中还有个难题是如何伪造 HTTP 请求的结果。如果像上面那样封装一下,可能会漏掉一些极端情况的测试,比如连接网络出错,失败的状态码。Golang 有个 httptest 库,可以在 test 时创建一个 server,让 client 连上 server。这样做会有点绕,事实上 Golang 的 http.Client 有个 Transport 成员,输入输出都通过它,通过篡改 Transport 就可以返回我们需要的数据。