最近消失了一个月,主要是都在学习《测试驱动的嵌入式C语言开发》这本书。刚读完,收获颇丰,感觉以前的代码都要大改了。
里头讲了很多测试驱动开发TDD的思想和概念,一些自动化测试工具(CppUTest和Unity)的使用,用了大量的代码带领你体验了整个先写出一个测试用例,驱动写出最小的产品代码,重构去冗余再不断循环,最终写出一个健壮的模块的整个过程。
最难得的是这本书是针对嵌入式C的,当前市面上讲TDD的书也蛮多的了,但是能结合到嵌入式C中怎么实践的却很少。书中讲了许多嵌入式C中实践TDD的技术,“双目标平台”、测试替身等。看得十分过瘾,对于一个TDD小白来说还是十分开眼界的。
最后几章略微有点跑题,开始教怎么写出好代码、什么是好的设计、怎么重构之类的了。当然按作者说法,要写出好的测试,以及TDD的最终目的就是更好的代码设计,所以确实也应该讲讲。那几章每个都对应着一本或几本更加全而且也十分nb的书,打算后面都去学学。当做《良好代码设计入门》来看也蛮好的。
我是直接看的英文版的,顺带练练英语阅读嘛,所以有些翻译可能跟中文版中的不同。
另外,不完全是按照书中的章节来记的笔记。
TDD综述
TDD是什么
测试驱动开发(Test-Driven Development,TDD)是一种将测试整合进软件开发架构中的一种高效方法。
我们需要TDD,因为我们是人,人就会犯错。编程是个很复杂的事情。需要TDD来系统地让我们的代码按意愿工作,产生自动化测试用例来保证代码能工作。
TDD是一种递增式构建软件的技术,在写产品代码前先写一个失败的测试,逻辑上由测试驱动。
TDD中,并不按通常认为的那样先解决最困难的部分,而是先做小的简单的事情,然后一步步迈向目标。每一步都是可验证的。每个测试增加产品代码的一点功能,最终形成一个健全的,良好测试的解决方案。
软件十分脆弱,任何修改都可能造成无意的后果。人工测试所有代码是不现实的,TDD解决了这个问题。
自动化测试是TDD的关键。写出产品代码的同时会产生一系列单元测试套件,这些测试与产品代码本身一样很有价值。每次改变代码时都运行测试,检查新代码的功能同时检查对现存代码的影响。
TDD并不是一项测试技术,虽然你要写很多测试。它是一个解决编程问题的方法,帮助开发者做出好的设计决定。测试能在方案出问题时给出警告,捕捉到不想要的行为。
TDD就像个游戏,你做出一系列技术决定最终完成一个健全的软件,同时避开了调试的泥潭。每次测试都带来成就感,清楚地知道你离目标越来越近。
两种编程方式比较
后调试编程
图:Debug-Later Programming
传统的后调试编程DLP中,先“完成”程序,然后再测试。
一般来说,需要越长时间探测到之前有的一个bug,发现这个bug所在位置的时间也会戏剧性的增长。修复bug的时间常不受探测到bug时间的影响,但如果上层有其他代码的话,修复时间也会戏剧性地增加。
测试驱动开发
而在TDD中,探测到bug的时间趋近于0,定位并修复bug的时间也会趋近于0。所以这甚至称不上bug,只能叫做被阻止的bug。
TDD微循环
TDD的核心是TDD微循环(Microcycle),由小步骤组成的重复循环。
- 添加一个小测试
- 运行所有测试并故意看到新的那个失败,甚至编译不了。
- 做很小的修改使得测试通过。
- 运行所有测试并看到新的一个通过。
- 重构,移除冗余并提升代码可读性。
每一步都设计为只要花几秒到几分钟。递增地增加新测试与代码,立刻得到反馈,确定刚写的代码是按你希望的那样执行的。然后就这样一步步实现完整复杂的行为。
TDD的过程中,不只是解决了问题,你还构建了对你解决的问题的知识。一个个测试形成了对细节需求的描述。随着工作的增加,测试代码和产品代码捕捉到问题的定义和它的解决方案。知识以非易失的形式捕捉下来。
Red-Green-Refactor
TDD的节奏也被叫做Red-Green-Refactor,这是因为在Java的测试用具JUnit中,测试失败时会给你显示个红色的栏,然后你努力让他通过变成绿色的,然后再进行重构,让其保持在绿色。重构完成后再添加测试,于是又变成红色,以此往复。
关于重构
代码不只是运行正确就行,还需要保持整洁与良好的结构,这样才能体现专业性,更重要的是使代码在未来易于维护。
微循环的最后一步是重构。重构就是改变程序的结构却不改变其行为。目的是通过创造易于理解、易于进化和易于维护的代码来减少工作量。
混乱在刚出现时最容易清理,也就是“所有测试通过”的这个时候。
TDD的好处
- 更少的bug
- 更少的调试时间(逻辑上)
- 更少的副作用缺陷:测试会捕获假设、约束并阐述标记法。
- 测试本身是不会说谎的文档
- 更好的心情:全面测试的代码带给你信心,晚上睡的更好。
- 更好的设计
- 进度监视器:测试让你清楚地知道正在干什么,已经做了多少。给你另一种估计进度的方法。
- 愉快与成就感:TDD立刻给予开发者满足感。
TDD入门
一些术语
Code under test被测代码:正被测试的代码
Production code产品代码:是(或将是)发布的产品的一部分的代码
Test code测试代码:用于测试产品代码的代码,不是发布的产品的一部分
Test case测试用例:描述了正被测试的代码的行为的测试代码。会建立先决条件(输入一些值),然后检查结果。
Test fixture:为一系列测试用例提供合适的环境的代码。如提供一个通用的配置(setup)方法以建立测试产品代码的环境
单元测试
Unit Test:指对软件中的最小可测试单元进行检查和验证。
单元:根据实际情况判定具体含义,如C语言中指一个函数,Java里指一个类,图形化的软件中可以指一个窗口或一个菜单等。总的来说,单元就是人为规定的最小的被测功能模块。
每个测试都应该是独立的小实验,不应该依赖于测试顺序。
每个测试应该要短小且聚焦。可以想做是一个小实验,这个实验通过时是静默的,失败时则制造噪音。
FIRST原则
FIRST(from Book :Agile in a Flash)让测试更加高效。
- F快速:测试是极其快的,快到开发者每次一进行小的更改就运行测试也不会打断工作。
- I独立:测试是独立的。测试间不存在相互配置,测试的失败也是独立的。
- R可重复:测试是可重复的;可重复就是自动化。循环执行测试会得到相同的结果。
- S自验证:测试验证自己的输出,当通过时简单的说句“OK”,失败时精确地提供细节。
- T适时的:测试是适时的。程序员适时地写它,即刚好在写产品代码前写,这避免了bug。
四步测试模式:
Four-Phase Test Pattern(Gerard Meszaros’ book, xUnit Testing Patterns):
- Setup:建立测试的预环境(precondition)
- Exercise:做一些事情
- Verify:检查输出是否如期望的
- Cleanup:在测试后把系统还原为最初状态
单元测试用具
单元测试用具(Unit Test Harness)是一个软件包,其让程序员能表达产品代码应该有什么样的行为。
一个单元测试工具应该提供:
- 一个普通语言来表达测试用例。
- 一个普通语言来表达想要的结果
- 对产品代码编程语言特性的访问
- 一个为工程、系统或子系统收集所有单元测试用例的地方。
- 一个运行全部或部分测试用例的机制。
- 准确报告测试成功或失败
- 详细报告任何测试失败。
作者介绍并在后续的代码中使用了两个单元测试用具:
Unity
Unity是一个使用起来很直接的小型单元测试用具。完全用纯C写成。只包含几个文件,其中最核心的文件也就个.h和.c文件。然后为了使用方便,一般还会用到作者执笔写的配套fixture文件。
具体繁琐的使用就不在读书笔记中写了。后面可能专门写篇博客来记载使用。
CppUTest
CppUTest是一个用于C和C++的测试用具,开发来支持跨多操作系统,特别是用于嵌入式环境。相比Unity大了很多,当然也多了更多的功能,比如比Unity的测试省掉了手动维护各种RUNNER这事,还提供如内存检测等功能。
使用C++写成,需要编译器支持C++的主要子集的特性,所以无法用于一些只支持C语言的嵌入式系统。对不支持C++的编译器的情况,提供了Ruby脚本来转换为Unity的测试。
相对的,配置起来也比Unity麻烦。
这篇博文介绍了怎么配置CppUTest在VS2012中的使用:
https://blog.youkuaiyun.com/lin_strong/article/details/83819090
具体繁琐的使用就不在读书笔记中写了。后面可能专门写篇博客来记载使用。
可测试的C模块
模块化是代码可测试的必要条件。在创建模块化的C的时候,我们会基于抽象数据类型(Abstract Data Type,ADT)这个概念。
抽象数据类型
Barbara Liskov在Programming with Abstract Data Types中对ADT的定义:抽象数据类型由可能对其进行的操作以及这些操作的效果以及可能的代价来间接定义。
数据封装
ADT中,模块的数据是私有的,封装起来的。
一种方法是使用static关键字限制变量的作用域在.c文件中,只能通过.h文件中定义的公共接口间接访问数据。这种方法适用于单实例的模块。
当需要多实例模块时,可以声明一个指向结构体的指针,在模块内部维护这个结构体。以此隐藏实现细节。
typedef struct CircularBufferStruct * CircularBuffer;
约定
使用TDD来创建模块化C的时候,使用这些文件以及约定:
- 头文件定义模块的接口,对于单实例模块,头文件由函数原型组成。对于ADT,除了函数原型,还使用typedef定义一个指向预声明struct的指针。这样隐藏了模块的数据细节。
- 源文件包含接口的实现。它还包含各种辅助函数和隐藏的数据。模块实现负责管理模块数据的完整性。对于ADT,预声明的strcut成员定义在源文件中。
- 测试文件 存放测试用例,这样就分离了测试代码和产品代码。每个模块都有至少一个测试文件,测试文件中包含至少一个,偶尔多个测试组。测试组围绕组中所有测试通用的数据来组织。当一些测试用例的配置需求与其他测试用例明显不同时,我们会使用多个测试组,甚至可能会用多个测试文件。
- 模块初始化和清理函数。每个管理隐藏的数据的模块都应该有初始化和清理函数。ADT自然算在内。C++中,使用了构建器(constructors)和析构器(destructors.)。按照传统,每个模块中我都使用Create和Destory函数。由独立的函数构成的模块,比如strlen()和sprintf()这样的函数,它们没有内部状态,所以不需要初始化和清理。
TDD要点
需求清单
在开发前先花很小的时间列出要测试的项,注意边际收益递减效应,当开始写不动的时候就不要写了,直接开始测试驱动的设计。边写边会想到其他测试的。
TDD三原则(Bob Martin)
- 除非是要让一个失败的单元能测试通过,否则不要写产品代码。
- 不要写比足以失败更多的单元测试,构建失败也算。
- 不要写比足以让单元测试通过更多的产品代码。
不要提前写产品代码,即使你知道需要这个代码。一定要遵循先写测试再写代码的原则,这样才能产生一个综合全面测试的代码。
写多于当前测试所需的产品代码的问题就是你很有可能不会写完所有需要的测试,而这些测试是保证当前与未来不会出bug的关键。
TDD本质上就是有拖延症的。直到测试强迫我们去做,我们才会写正确的产品代码。实现最终那个完整代码的终极目标只会在完成所有的测试之后达到。
可以欺骗测试
可以硬编码来通过测试,即使你知道怎么一步写到位。每一步只要刚刚好能通过测试就行了。
当欺骗测试比直接实现真正的模块更困难的时候再直接实现它。
让测试小且专注
TDD新手经常在每个测试中放太多东西。但这有害于可读性和专注性。
一个测试用例的行数并没有限制。
保持测试的可读性,小型性和专注性。应该可以在每个测试用例中明确地看到四步测试模式的每一步。当测试太大或不干净时就会失去他们的文档价值。
理想情况下,单个代码问题只会导致单个测试失败。当然这只是理想状况。
记得在测试通过后立刻重构
在测试通过后是重构最安全的时机,不要在测试没通过时进行重构!
DRY原则
DRY(Don’t Repeat Yourself)原则:一个系统中的每一部分知识都必须有单个清晰专业的表达。
- 维护时可能会需要修改许多接口涉及的某个功能,这时如果是复制的代码,很容易就遗漏了。
- 重复使得代码抽象程度降低,增加了程序员的负担。
- 移走冗余的代码,可以降低整个代码的空间占用。
当然这同样涉及代码性能和设计优雅的取舍问题。
TDD状态机
嵌入式TDD
嵌入式开发中最大的问题是硬件依赖性,这是有解决方案的。
使用TDD一开始会让你觉得工作变慢了,因为你需要考虑的很小心周全,但这是为了更快,验证过的工作带来更高的质量。
嵌入式中TDD的好处
TDD用在嵌入式中又多了这些好处:
- 通过在硬件准备好之前,或者当硬件昂贵且稀少时,独立于硬件验证产品代码,降低了风险。
- 通过在开发系统上移除bug,减少了对目标平台编译、连接、上传的次数,这个过程常常很漫长。
- 减少了在目标硬件上的调试时间,在目标硬件上,问题更难定位和修复。
- 通过在测试中对硬件交互建模,隔离了软硬件交互问题。
- 通过对模块间和硬件的解耦,提升了软件设计。可测试的代码必然是模块化的。
目标硬件瓶颈
大部分嵌入式项目中会并行进行软硬件开发。如果软件只可以运行在目标硬件上,则你会在这些事情上浪费时间:
- 到项目后期才完成目标硬件,这拖延了软件测试。
- 目标硬件很贵且缺乏。使得开发者要等待并做很多未验证的工作。
- 当最终拿到了目标硬件,它可能有自己的bug。而未测试的软件自己也有bug。那就特别难调试了。
- 很长的生成和上传时间浪费了你整个过程的时间。
- 因为上传时间很长,你会在一次生成中做大量的修改,这就导致可能发生更多问题,更多的调试。
- 用于目标硬件的编译器一般比本地编译器贵。很可能你的团队的licenses不是很够用,然后导致浪费了更多时间。
嵌入式开发者为了避免目标硬件瓶颈,通常会使用评估板,这确实很有用,但还是没有解决生成和上传时间长的问题。
脱离目标硬件进行测试可以轻松地触发那些难以出现的错误情况。如果没有这个能力,会有大量的代码没有办法测试到,直到最后出现致命问题。
双目标平台
概念
“双目标平台(Dual-Targeting)”策略,一开始就把代码设计为在起码两个平台下运行:目标硬件和你的开发系统。
好处
双目标平台使你能在硬件准备好前测试代码,在开发周期中避免硬件瓶颈,避免软硬件开发人员相互甩锅。
双目标平台使你更注意软硬件的边界,这产生了更模块化的设计,或说硬件无关的设计。
风险(移植性问题)
理想情况下,你的产品代码应该在两种环境下以相同方式工作,但是世界不是理想的,你可能会遇到一些移植性问题:
- 编译器的语言特性支持可能不同。
- 目标编译器可能有些bug,而本地开发系统的编译器有另一些bug。
- 运行时库可能不一样。
- include文件名和特性可能不同
- 原始数据类型的大小可能不一样。
- 字节序和数据结构对齐方式可能不一样。
解决方法比如CppUTest中,把平台特定的代码单独放在一个地方。每个平台对应的特定代码都专门放一个路径。创建一个头文件,其中定义了要以平台特定的方式实现的函数原型。然后为每个平台创建一个实现,将他们使用路径进行隔离,这是使用了编译器加链接器的方式实现的移植性,而不是常见的预处理器方式。
还可以结合适配器模式。将平台特定的接口适配成你需要的接口。
嵌入式TDD循环
嵌入式TDD循环扩展了核心的TDD微循环。
简单来说。
阶段1:TDD微循环
先在开发系统上按照TDD微循环进行开发。这个阶段中要尽可能的写平台无关的代码,找到一切机会隔离硬件和软件(通过抽象层)。
阶段2:编译器兼容性检查
周期性地使用交叉编译器对目标平台进行编译。这可以提醒你一些移植性问题,比如有些头文件没有、不兼容的或者缺失的语言特性。
在嵌入式项目开发的早期,可能还没有决定好工具链,这时候尽可能地猜测你会使用的工具链。
不需要每次修改代码都进行这一步,每当你使用了新语言特性、include新的头文件或进行了新的库调用时才需要交叉编译进行兼容性检查。
也可以将这一步作为持续集成CI系统的一部分来自动地完成。
阶段3:在评估板上运行单元测试
存在一定的风险你的代码在开发系统和目标平台上的运行有差别。所以还需要在评估板上运行那些单元测试。
如果你的目标硬件已经准备好了,其实你可以略过这一步,直接在目标硬件上进行测试。这步只是为了目标硬件没有准备好的情况。
同样也可以将这一步作为持续集成CI系统的一部分来自动地每天完成。
阶段4:在目标硬件上运行单元测试
除了运行的对象是目标硬件,其他和阶段3没差别。
额外的一个好处是你可以运行目标硬件特定的测试。这些测试使你能够学习目标硬件的行为。
在目标硬件上进行单元测试可能会遇到内存不足的问题,可以拆分测试为不同的组,分别测试以解决这个问题。
阶段5:在目标硬件上进行验收测试
最终,我们通过在目标硬件上运行自动测试和人工的验收测试来确保产品特性能正常工作。
包括那些无法完全自动化测试的代码,经过前面几步你已经知道是哪些了。
硬件测试
实践中,硬件的测试也应该自动化。
自动硬件测试
很有可能有办法对硬件进行自动化测试,只要有可能,就要写,这能给予你信心。
比如有些硬件可以进行通讯来测试其功能。
半自动硬件测试
半自动化测试提供一些线索,使得测试人员能人工地与系统交互并观察系统的输出。比如控制LED灯按一定规律闪烁。
半自动化测试中涉及人工步骤,人工测试很贵,但是无法完全避免。
一般在硬件修订后,或者修改了硬件相关的代码后需要重新进行人工测试。
使用外部设备的自动硬件测试
有些硬件可以特殊设计些设备来辅助测试。
硬件整合测试
当硬件驱动最后放在硬件上时,很可能还是会遇到整合问题。我们可以写一些只允许在硬件上的硬件整合。当我们发现没有通过的硬件测试时,需要修订测试和产品代码来兼容真实世界。
TDD Q&A
我们没时间
如果人能不出错的以常量速度编程,那多写那么多测试代码确实好像很浪费时间,但人并没那么厉害。
使用TDD使得开发者拥有一个有生产力且持续的开发速度。这得益于减少了当前和未来的调试时间,得益于清晰的代码结构加上可执行的文档(测试代码)。
传统DLP中,测试调试时间能占50%。从整体来看,你可以把这部分时间匀来写TDD。
一些起码可以部分由TDD替代的常见单元测试方法:
- 人工测试:一次人工测试的成本可能低于写一个自动化测试,但是人工测试的回报是不可持续的,如果你改了代码,就得重新做一次测试,人工情况下你会倾向于做部分测试,这又带来了bug的风险。
- 定制的测试用具:自己写的测试工具确实很有用,但是在整合时常会出问题,难以在未来实现长期的回报,所以转向用像CppUTest或Unity这样的专业测试工具吧。
- 单步调试的单元测试:使用单步调试进行单元测试是一种特别慢且天生不可重复的过程。漫长且乏味。每改变一点就得所有重来。
- 写文档然后人工审核的单元测试:文档化并审核的人工单元测试过程让每个人都很舒服,因为你看上去在质量上投资了很多,但实际上回报率特别低。
deadline的压力趋于让我们降低代码质量而且不写自动化测试而使用人工测试,并且实际上我们之后并不会有时间还上技术债务。短时间内开发速度可能能提升,但由于缺陷积累越来越堆,开发的速度会越来越慢。
使用TDD的话,最开始进度会放缓,但是随着项目的进行,你通过TDD来提升代码和设计质量并释放一些人工测试压力的努力会得到回报,开发进度会平稳前进。
为什么不先写编码再写测试
先写代码再写测试(后测试开发)也可以给你很多好处,但不如让测试驱动产品代码的好处多。
TDD相对于后测试开发的好处:
- TDD会影响设计。而后测试开发没法给你那么多设计上的正向影响,TDD导向更好的API和更专一且松散偶合的模块。
- TDD预防缺陷。在你犯错时,TDD立刻就能找到它们。而对于后测试,你会一下发现好多错误,有些TDD能发现的错误 后测试可能发现不了。
- 后测试这种做法中,你需要花更多时间定位bug点,而TDD中,bug通常十分明显。
- TDD更严格,代码覆盖率更好。代码覆盖率不是TDD的目标,但是如果是后测试的话,代码覆盖率上容易出问题。
这样我们就得多花精力维护测试
是得维护测试。
不写测试是不用维护测试,但是你还是得要人工进行测试。真觉得合算了么?
单元测试无法发现所有的Bug
说的对。TDD确实无法避免所有的bug,但这并不是你不使用TDD的理由。
即使用了TDD,你仍然需要整合测试,验收测试,探索性测试和负载测试。TDD会消除大部分问题,这样更高阶的测试就能专注于合适的问题。
整合测试用来发现整合问题,验收测试保证代码满足它的需求,负载测试则帮助确定系统满足了它的设计极限。
TDD可能无法避免所有的bug,但它能很有效的避免无心之失。
Build时间太长了
为了get到TDD的节奏,你需要快速的递增构建。不需要构建整个系统。管理好依赖性后,可以独立地构建部分系统。
如果你的生成事件太长了,很可能需要分为多个单元测试以降低生成时间。
真正的挑战是你要写出模块化的代码。
有很多遗留代码
本来就不太可能凡事都完美,大部分情况下我们会基于遗留代码进行开发,也不大可能停止产品开发来专门写测试
对于遗留代码的推荐方法是当增加产品功能时递增地增加测试。
后面有章节专门讲怎么在遗留代码上添加测试。
内存有限
在内存受限时使用TDD的建议:
- 使用“双目标”,这样大量代码就不是在目标上测试的。
- 使用一个小的测试用具。Unity就蛮小的
- 为你的目标系统造一个拥有足够内存的实验室版本,足以放下所有产品代码和测试用例的那种。
- 可以建多个测试运行器,使得每个测试子集都可以装进受限的内存中。
- 追踪你的目标生成的内存使用率。
追踪内存使用率的方法:在持续集成系统中使用脚本自动读取map文件并计算空间占用,然后按时序生成大可视化图BVC,这样就很直观的发现什么时候出现了占用内存很大的峰值并解决内存老虎。同样也可以用于追踪CPU空闲时间、I/O数据率等。
我们不得不与硬件交互
与硬件交互的模块可以不在目标平台上写和测试。当然我们无法证明这个模块一定能最终与硬件正常工作,但测试能证明代码能按照我们对硬件的理解来工作。然后在整合之后可以再修改那些误解的地方。
为什么使用C++测试用具来测试C代码
前面说过C++相对C有一些优势。
如果目标编译器只支持纯C的话,可以在开发系统中使用CppUTest,然后使用提供的脚本转换测试代码为Unity版本来在目标平台上测试。这样说不定还能暴露出更多可能的bug。