文章目录
测试替身初探
自包含或说不依赖其他部分的代码是系统开发中最简单的,最可共享的和最可重用的部分。
TDD中更可怕的挑战是测试中层的代码,这些代码需要与其他模块、函数和数据进行交互来完成工作。
协作者
协作者(Collaborators)就是被测代码(Code Under Test,CUT)依赖的一些外部的函数、数据、模块或设备。比如下图中,Led Driver是被测代码,Leds Address就是Led Driver的协作者。
测试替身概念
测试替身(test double)在测试中扮演一些函数、数据、模块或库。CUT不知道使用的是替身;它按与collaborator交互同样的方式与替身交互。
测试替身并不是完全模拟它替代的那个东西,这使得替身能简单很多。
测试替身可以给CUT提供间接的输入(返回值)或以捕捉的方式,并且能够检查CUT发送给测试替身的间接输出(参数)。
测试替身最简单的形式就是替代真实产品代码的一个测试桩(stub)。
TDD中,测试替身在产品代码的整个生命周期中一直被使用和维护,促进自动化单元测试。
测试替身的类型
Gerard Meszaros的书xUnit Testing Patterns中定义了多种测试替身。
- 测试傀儡(Test dummy):只是为了链接器不报警而创建的测试替身。dummy是一个永远不会被调用的桩。它是用来满足编译器、链接器或运行时依赖的。
- 测试桩(Test stub):按照当前测试用例的指示返回一些值。
- 测试间谍(Test spy):捕获从CUT传来的参数,这样测试用例就可以验证正常的参数被传给了CUT。spy也可以像测试桩一样喂给CUT返回值。
- Mock对象:验证被调用的函数,调用顺序以及从CUT传给DOC的参数。同样被编程为返回特定的值给CUT。mock对象常用于处理那种需要多次调用并且每次的调用和响应可能不同的情况。
- Fake对象:提供被替换组件的部分实现。fake的实现通常相对于被替换的那个会简单的多。
- Exploding fake:如果被调用会导致测试失败。
当讨论测试替身需要的不同行为和能力时,这些名词很有用。但是通常在实践中我们并不需要特意区分它们。你会发现人们常常随意地使用fake、mock和stub。
依赖性网络
真正的代码是有依赖性的,如上图左。如对上图中灰色的模块进行测试时,其依赖关系如上图右。
没有测试替身的情况下,测试的依赖关系就像一个章鱼,如上图。测试作为CUT的用户,然后测试还依赖于DOC。
DOC(Depended on Components):CUT直接的协作模块或数据结构
TDOC(Transitively DOC):CUT间接的协作模块或数据结构
因为测试四步骤的要求,测试用例必须设置和清理DOC们,递归地还要设置和清理传递TDOC。然后在exercise后,还得递归地验证所有DOC和TDOC。
除了使得测试变得复杂,对直接和间接依赖的知识也使得测试十分脆弱。对设计的一个小改动可能牵连甚大。未加管理的依赖性很危险。
使用测试替身打开依赖
依赖性给自动化测试带来困难,打破依赖性的关键是更加严格地使用接口、封装和数据隐藏,更少的依赖于不受保护的全局数据。
为了设计更模块化和可测试的C,我们使用头文件来公布模块的接口。可测试的模块是通过模块接口来与其他模块交互的模块。
当使用接口来与模块交互,就可以使用测试替身来替代collaborator。使用替身是因为真正的产品代码collaborator可能会妨碍你的测试,比如测试时你需要控制它的行为,但使用真的那个难以控制它的行为;如果不会,那就应该直接用真的。
复杂的依赖关系可以通过使用一个或多个测试替身来破解。
测试替身通过替代真正的collaborator来简化依赖,对CUT来说用不用替身并没有什么差别。
使用测试替身的时机
不是总无脑用测试替身。能用真的代码时就不要用替身。需要自己判断要不要欺骗CUT。
使用测试替身的一些常见原因:
- 硬件解耦:这样就不需要硬件就能测试了,还能提供难以实现的多种输入。
- 注入难以产生的输入:通过调整测试替身的返回值,以测试难以测试到的执行路径。
- 加速一个慢速collaborator:如数据库、网络服务等。
- 依赖于一些易变的东西:如时钟。
- 依赖于开发中的东西:这时使用替身使得测试得以继续,同时有助于发现CUT需要,但当前尚未实现的服务需求。
- 依赖于一些难以配置的东西:如果DOC难以配置为想要的状态,如数据库。
C中三种主要替代机制
链接时替代
链接器替代用于在单元测试可执行程序中替代整个DOC。
特别有用于脱离目标平台的测试,消除对第三方库的依赖,依赖硬件的模块,或者操作系统。
链接时测试替身很有用,但有时你需要更大的灵活性和可配置性,这时就得把一些直接的依赖转换为运行时依赖了。
链接时测试替身意味着,如果一些builds需要include被测试替身覆写的代码,那你可能需要多个测试builds。
函数指针替代
如果只想在部分测试用例中替换DOC的话可以使用函数指针替代。
这会稍微复杂点,占一点点RAM,降低一点函数声明的可读性。
预处理器替代
当链接器和函数指针都无法完成需求时可以使用预处理器替代。如,可以使用预处理器破坏include链;选择性或临时地重载名称。
比如CppUTest的内存泄露检测的原理就是用预处理器把malloc相关函数替代为了自己的实现。
#include <stdlib.h>
void* cpputest_malloc(size_t size, const char *, int);
void* cpputest_calloc(size_t count, size_t size, const char *, int);
void* cpputest_ralloc(void *, size_t, const char *, int);
void cpputest_free(void* mem, const char *, int);
#define malloc(a) cpputest_malloc(a, __FILE__, __LINE__)
#define calloc(a, b) cpputest_calloc(a, b, __FILE__, __LINE__)
#define realloc(a, b) cpputest_realloc(a, b, __FILE__, __LINE__)
#define free(a) cpputest_free(a, __FILE__, __LINE__)
预处理器替代是最终杀器。问题是CUT中编译的代码实际上是不同的。它替换的太多了。如果你考虑使用预处理替身的话,先考虑下另一种选择:封装代码并提供一个可以通过链接器或函数指针替换来控制的新接口。
结合链接时替代和函数指针替代
还可以结合链接器替代和函数指针替代。在链接桩中包含一个初始化为NULL的函数指针,这样就可以在测试用例中方便的替换功能了。
TDD实践
如设计一个灯调度器,最开始的设计如下图
所有访问都要通过接口
为了管理CUT执行环境的依赖性,所有对执行环境的访问都要通过定义好的接口,如下图。这样就可以通过测试替身拦截和检查接口调用。这样测试用例就可以控制测试替身的返回值,间接驱动CUT。本质就是测试用例和测试替身一起作为CUT的软件测试夹具,驱动其输入并监视和检查它的输出。
然后,建议使用OS抽象层和硬件抽象层来分别消除对OS和硬件的抽象。
使用链接时替代
测试用例的角色是客户,通过输入,直接驱动CUT;测试替身的角色是DOC,其监控给予DOC的数据并以返回值的形式提供间接的输入来驱动CUT,这是测试用例的需求。
一种组织测试build的好方法是把所有产品代码编译到一个库中,测试替身则编译为目标文件。当生成测试时,makefile在连接到产品代码库前精确地连接测试替身目标文件。这样就能覆盖产品代码库中那些同名的实现了。
spy要做拦截工作。它拦截来自产品代码的输入,之后将其提供给测试用例。作为拦截任务的一部分,它还将返回值喂给客户端代码,使CUT依从测试的指示。
spy的头文件会include它要替换接口的那个头文件。因为它是被替换对象的一个实现。
spy自己用到的文本常量和接口等应该放在自己的头文件中,不然就污染了产品代码。
在测试中写定时的事件实在太费时间,测试必须接管时钟。
RTOS常使用非标准时间函数,这带来移植性问题,如果想要代码可移植,那需要对时钟进行抽象。同时抽象层也很方便你插入假时钟。
如何设计接口
当我们不确定最终使用的对象的实现时,根据我们的需求定义它的接口。测试反过来帮助驱动设计。
但你不知道实现细节时,一个微妙的好处是这会导向更抽象的接口,和低级实现细节无关的那种,这样就能支持更多可能的目标实现了。
测试随机性
一些函数生成随机的东西。
随机性测试的两个子问题:
- 随机数生成器是否生成了正确范围内的足够随机的数。
- 模块是否合理地使用了随机数生成器
测试代码参考:
TEST(RandomMinute, GetIsInRange){
for (int i = 0; i < 100; i++){
minute = RandomMinute_Get();
AssertMinuteIsInRange();
}
}
TEST(RandomMinute, AllValuesPossible){
int hit[2*BOUND + 1];
int i;
memset(hit, 0, sizeof(hit));
for (i = 0; i < 225; i++){
minute = RandomMinute_Get();
AssertMinuteIsInRange();
hit[minute + BOUND]++;
}
for (i = 0; i < 2* BOUND + 1; i++){
CHECK(hit[i] > 0);
}
}
使用函数指针替代
有时候一些函数是要生成随机的东西,但是实际上测试中如果是真的随机的东西是很难进行测试的。
当产品代码依赖于一些不可预测性时,就应该使用测试替身。
原接口:
void RandomMinute_Create(int bound);
int RandomMinute_Get(void);
为了实现函数指针替代,需要转换直接的函数调用为对函数指针的调用:
void RandomMinute_Create(int bound);
extern int (*RandomMinute_Get)(void);
.c文件中:
int RandomMinute_GetImpl(void){
return bound - rand() % (bound * 2 + 1);
}
int (*RandomMinute_Get)(void) = RandomMinute_GetImpl;
这样就可以在测试用例中重写RandomMinute_Get来控制返回的“随机”值了。
TEST_GROUP(LightSchedulerRandomize){
void setup(){
LightController_Create();
LightScheduler_Create();
// 每次测试用例开始前将RandomMinute_Get指向FakeRandomMinute_Get
UT_PTR_SET(RandomMinute_Get, FakeRandomMinute_Get);
}
void teardown(){
LightScheduler_Destroy();
LightController_Destroy();
}
}
通过函数指针的方式,测试可以拦截CUT对外的函数调用,这是精准拦截对特定函数的调用的一个高效的方法。
把直接调用改为通过函数指针调用对调用者没有影响,调用语法是一样的。
链接时测试替身适用于替换目标平台依赖性和(有时)整个第三方库。函数指针则能够在运行时精准切割依赖性。如果在测试工程中需要包含一段代码,但它妨碍了一些测试了,那就用函数指针。
函数指针还可以用于当你想部分替换被编译单元的函数时,但这个工作也可以通过分隔编译单元,然后配合链接时绑定来完成。
链接时部分替代
为了在C中模拟“部分抽象”概念以及实现链接时测试替身,我们需要把源文件拆分为两个文件。这样就需要三个文件:
- 存放平台特定代码的文件,可以被链接器覆写为文件2
- 测试替身的实现
- 存放平台无关的代码
使用这种技术的原因:
- 这样在基于主机的测试中就不需要切进依赖OS的代码了。
- 更重要的是,很有可能依赖于目标的代码根本没法在开发环境中编译。
Mock对象
Mock对象是一个测试替身。它允许测试用例描述其期望的模块间调用。在测试执行期间,mock会检查所有的调用按照正确的顺序使用了正确的参数。还可以指示mock为CUT按照指定顺序返回特定的值。mock不是一个模拟器,但它允许测试用例模拟特定的场景或事件序列。
测试用例告诉mock期望怎么调用哪个接口。然后在exercise阶段,mock检查每个真正的调用是否如期望的那样。就好像在四步测试模式中加了一小步:配置、建立期望、实验(与检查)、检查和清理。
mock不是一个模拟器,它是用于模拟和验证一个特定使用情景下的一系列交互的。mock并不知道你要其模拟的东西是什么,每个测试用例按需编程mock,mock一次模拟一个情景,而不是模拟整个设备。
Mock严格检查交互的顺序,这对于那些交互顺序不是很重要的测试来说就不是很合适了。
比如对于一个Flash驱动模块,我们设计了IO_Read和IO_Write接口来隔离硬件实现。驱动内部会调用这两个接口来与硬件交互。为了测试其与IO接口的交互,我们创建了一个MockIO测试替身,实现了这两个接口。
可能根据Flash的手册,我们就写出了这么个测试用例:
TEST(Flash,WriteSucceeds_ReadyImmediately){
int result = 0;
MockIO_Expect_Write(CommandRegister, ProgramCommand);
MockIO_Expect_Write(address, data);
MockIO_Expect_ReadThenReturn(StatusRegister, ReadyBit);
MockIO_Expect_ReadThenReturn(address, data);
result = Flash_Write(address, data);
LONGS_EQUAL(FLASH_SUCCESS, result);
MockIO_Verify_Complete();
}
在测试用例中我们说我们希望会先调用IO_Write往CommandRegister地址写ProgramCommand,然后调用IO_Write往address地址写data,然后应该调用IO_Read读取StatusRegister处的值,并返回ReadyBit,最后还应该调用IO_Read读取address处的值,并返回data。
然后我们调用了Flash_Write函数并验证结果为FLASH_SUCCESS。
这之间如果有任何一步调用顺序出错或者传递的参数不对,测试就会失败。
最后的MockIO_Verify_Complete()则验证所有期望的调用都调用过了,否则测试失败。
如何写mock对象
可以直接手写实现,书中给出了示例,但是比较复杂。
CppUTest提供了CppUMock来支持mock对象,使用者要按照要求写各个测试替身函数。CppUMock不检查调用顺序,这很适合无调用顺序要求的交互,但是有顺序要求时就有问题了。
CMock是一个基于Unity的mock生成器。Unity的包中提供了Ruby脚本来自动生成Mock对象。
后面可能会写博客来介绍怎么使用,先占坑。
健壮、可扩展、可测试的设计
在软件系统的寿命中,会有变化的需求,系统最佳实现的想法也会变。明白SOLID将帮我们组织代码为高内聚低耦合的模块。它们对想法进行划分,这样代码的修改就趋于更本地化、设计趋于更可测试。
TDD能帮你看到设计开始变差的转折点,在转折点,你会发现测试很难写。TDD是差代码雷达,SOLID设计原则则帮你想到更好的设计,这样你就能避免差代码。
为了构建良好的设计,需要将设计评估方式从NIH(Not Invented Here)转变为使用SOLID设计原则。
SOLID
Bob Martin书中(Agile Software Development,Principles, Patterns, and Practices)的SOLID原则:
- S Single Responsibility Principle单一职责原则
- O Open Closed Principle开放封闭原则
- L Liskov Substitution Principle里氏替换原则
- I Interface Segregation Principle接口隔离原则
- D Dependency Inversion Principle依赖反转原则
这些相互关联的原则给我们提供了方法论,以避免C程序中常见的混乱的数据结构函数调用。
单一职责原则S
单一职责原则SRP:一个模块有单个职责,只做一件事,只有一个要修改的原因。
效果:模块高内聚,内部的函数和数据目标一致。
如果模块和其函数命名的很好,那职责也应该会很清晰。几乎不需要复杂的解释。看一眼模块和其测试就知道它是干什么的了。
开放封闭原则O
开放封闭原则OCP:对扩展开放,对修改封闭
当设计遵从OCP原则,它可以通过增加新代码来扩展,而不是修改现存代码。
里氏替换原则L
里氏替换原则LSP:客户端模块不应关心它所交互的服务器模块。只要两个服务器模块的接口是一致的,就应该能够替换,而且不用修改调用处的代码。
OCP和LSP是硬币的两面,所以看起来很像。但LSP还多了一层意义,它不只是指兼容的接口或函数指针,还指调用的意义对于两端都应该一致。
接口隔离原则I
接口分离原则ISP:客户端模块不应该依赖于“肥”接口。
通过裁剪接口,我们限制了依赖性,让代码更容易移植,使测试使用接口的代码更容易。
依赖反转原则D
依赖反转原则DIP:高级模块不应该依赖低级模块。二者都应该依赖于抽象。抽象不应该依赖于细节。细节应该依赖于抽象。我们可以打破与抽象和接口的依赖。
在C中,我们常常通过使用函数指针打开不想要的直接依赖以实现DIP。
回调函数是依赖反转的其中一种形式。
抽象数据类型也是在应用DIP。
DIP只是一种思想,并不是说一定要用到函数指针或ADT。
我们在使用DIP,当:
- 实现细节隐藏在一个接口后面
- 接口没有揭示实现细节
- 客户端通过一个函数指针调用服务器
- 服务器通过函数指针回调客户端
- 一个ADT隐藏了数据类型的细节
SOLID C 设计模型
函数指针是个常被程序员忽视的重要C语言特性。它对于C语言中应用SOLID承担了一个很重要的角色,使得代码更灵活和可测试。它是消除重复的条件逻辑的一个有用的工具。
会讲用到的四个模型:
- 单例模型:当只需要模块的一个实例时,封装模块的内部状态。
- 多利模型:封装模块的内部状态并让你创建模块数据的多个实例。
- 动态接口:实现模块的内部函数在运行时被赋值
- 预类型动态接口:实现模块使用相同接口的多个类型版本拥有一致的接口函数。
单例模型
对于单例模型,头文件中会定义与模块交互所需的所有东西,包括字面值常量以及函数原型等。
模块中用到的数据结构作为作用域为文件的变量隐藏在.c文件中。这样使得其他模块不可能依赖于内部结构并确保了模块维护自身的完整性。
多例模型
对于多例模型,我们会用到抽象数据类型,使用typedef语句声明指向ADT结构体的指针,接口中要求使用这个指针来指定是对哪个实例调用接口,实例的数据成员隐藏在结构体中,而结构体的定义则隐藏在.c文件中。
如以下环形缓冲区模块。
#ifndef D_CircularBuffer_H
#define D_CircularBuffer_H
typedef struct CircularBufferStruct * CircularBuffer;
CircularBuffer CircularBuffer_Create(int capacity);
void CircularBuffer_Destroy(CircularBuffer);
int CircularBuffer_IsEmpty(CircularBuffer);
int CircularBuffer_IsFull(CircularBuffer);
int CircularBuffer_Put(CircularBuffer, int);
int CircularBuffer_Get(CircularBuffer);
int CircularBuffer_Capacity(CircularBuffer);
void CircularBuffer_Print(CircularBuffer);
#endif /* D_CircularBuffer_H */
C的多态实现
管理相关数据结构的常见技术之一是将通用结构体放在所有相关数据结构体的最前面,这样在内存分布中最前面几个字节就一致了。
如为了实现多个light驱动,在通用的LightDriver.h中:
……
typedef struct LightDriverStruct * LightDriver;
typedef enum LightDriverType{
TestLightDriver,
X10,
AcmeWireless,
MemoryMapped
} LightDriverType;
typedef struct LightDriverStruct{
LightDriverType type;
int id;
} LightDriverStruct;
……
然后其中一个X10实现中(X10LightDriver.h):
……
typedef struct X10LightDriverStruct * X10LightDriver;
typedef struct X10LightDriverStruct{
LightDriverStruct base;
X10_HouseCode house;
int unit;
char message[MAX_X10_MESSAGE_LENGTH];
} X10LightDriverStruct;
……
这样就可以用LightDriver来正确地访问X10LightDriver中的type和id成员。
void LightController_TurnOn(int id){
LightDriver driver = lightDrivers[id];
if (NULL == driver)
return;
switch (driver->type){
case X10:
X10LightDriver_TurnOn(driver);
break;
case AcmeWireless:
AcmeWirelessLightDriver_TurnOn(driver);
break;
case MemoryMapped:
MemMappedLightDriver_TurnOn(driver);
break;
case TestLightDriver:
LightDriverSpy_TurnOn(driver);
break;
default:
/* now what? */
break;
}
}
可以理解LightDriver为其他所有实现的虚父类,而所有实现都是其子类,子类继承了其数据成员并得按要求实现其虚方法(自己的理解)。
即使是用到了这样的技术,为了兼容处理不同之类,可能会导致大量重复的switch语句,这样每加一个类型就得在所有switch语句中做修改。
如上图所示,当前Controller的操作直接依赖于每一个实现。
重复的swith语句正是开闭原则要解决的问题。
动态接口
动态接口使用一个或多个函数指针以实现给定函数在运行时实现。这提供了运行时灵活性。(其实就是部分实现了虚函数)
定义接口结构体(LightDriverPrivate.h):
typedef struct LightDriverInterfaceStruct{
void (*TurnOn)(LightDriver);
void (*TurnOff)(LightDriver);
void (*Destroy)(LightDriver);
} LightDriverInterfaceStruct;
在父类中定义安装动态接口的接口(LightDriver.c):
static LightDriverInterface interface = NULL;
void LightDriver_SetInterface(LightDriverInterface i){
interface = i;
}
这样,成员函数的具体实现就被隐藏到了子类中。
通过在父类中进行通用的接口安全检查,子类就不需要进行检查了。
void LightDriver_TurnOn(LightDriver self){
if (isValid(self)) // if(interface != NULL && self != NULL)
interface->TurnOn(self);
}
通过使用动态接口,之前含有一堆switch的语句就简化为了
void LightController_TurnOn(int id){
LightDriver_TurnOn(lightDrivers[id]);
}
预类型动态接口
以上方法十分的灵活,但是还是有点遗憾;因为接口是以单例模式安装到父类的静态成员中的,所以同时只能有一套接口,换句话说就是同时只能运行一类驱动。
为了实现子类方法的多态,每个子类都要有个指向接口函数表(虚表)的指针。这其实就是C++中实现虚函数的机制。
这样,父类的成员就要改成:
typedef struct LightDriverStruct{
LightDriverInterface vtable;
const char * type;
int id;
} LightDriverStruct;
然后每个子类在初始化时指向自己的虚表(CountingLightDriver.c)。
LightDriver CountingLightDriver_Create(int id){
CountingLightDriver self = calloc(1, sizeof(CountingLightDriverStruct));
self->base.vtable = &interface;
self->base.type = "CountingLightDriver";
self->base.id = id;
return (LightDriver)self;
}
还得在父类的虚方法中略作修改:
void LightDriver_TurnOn(LightDriver self){
if (self && self->vtable && self->vtable->TurnOn)
self->vtable->TurnOn(self);
}
这样就非常nb地在C语言中实现了多态。
设计到什么程度?
开发中永远会存在不确定性,所以就别等了,直接开始写吧。
我们需要为未来考虑,但也要注意不要假设的过多了。如果你开始在假设上进行假设,可能就想的太过了。
当软硬件边界不明确时,可以从解决应用需求的角度来考虑,通过应用代码的对硬件的需求来进行设计。
需求驱动接口设计的好处是设计的解耦程度会更高,应用代码会更加不用知道硬件的实现细节。
我们无法预测未来所有的产品需求,因此设计必须随着需求而革新。
设计只要完美满足当前需求就行。提早添加复杂性会拖延进度。如果提前设计的东西是错的,那就浪费了时间。设计总是在演变的。我们需要保持设计对当前所支持的特性的正确性。
极限编程的简单设计原则
- 通过所有的测试:代码必须做它该做的事情,如果测试都通过不了,那其他就免谈了
- 表达我们想表达的每个想法:代码应该是自文档的,表达出程序员的意图。
- 将每个事情说一遍且仅有一遍:必须移除冗余,这样当事情变化时,同一个想法不需要在多个地方修改。
- 不要多余的部分:这条原则阻止我们实现目前还不需要的东西。