单元测试本身并不严格限制过程式还是OOP,白盒还是黑盒,因而测试用例的写法具有很大的随意性。一些程序员对于C++/Java/C#等OO语法特性津津乐道,但却没有掌握OOP的基本思想。怎么知道呢?就从编写的单元测试用例就能看出来。单元测试用例的编写可以直接反映一个程序员是否真正理解了什么是过程式编程,什么是OOP。我甚至觉得,如果在面试中要考察面试者对OOP的掌握程度,考察编写单元测试是一种最好的方法。所以,本文打算介绍单元测试中状态验证和行为验证两种不同的方式,并分析其背后的过程式思想和OOP思想。
过程式和状态验证
以机器语言和汇编语言为代表的早期命令式程序设计语言是von Neumann体系结构“存储程序”(Stored Program)思想的直接体现。在命令式程序设计中,用变量表示数据,用语句表示由计算机执行的指令;程序的执行效果体现在语句对变量值的改变上。后来,以C语言为代表的高级语言在此的基础上引入了过程抽象(Procedure Abstraction),通过定义过程/函数/子程序(Procedure/Function/Subroutine)对一系列的语句进行抽象,形成了过程式程序设计。变量和函数这两种基本元素构成了“变量+函数”的二元结构。函数的设计一般采用自顶向下分而治之的方式,大函数套小函数,层层细化。
图1,过程式“变量+函数”的二元结构
过程式程序的单元测试用例多与函数对应,在一个用例中专门测试某一个函数。单元测试的准备工作好包括:设置全局变量和输入变量等非被测函数局部变量的值;检查内容包括:检查函数的返回值,以及非被测函数局部变量的值。我们称这种通过检查非被测函数局部变量值的方式验证函数正确性的单元测试方法为状态验证(State Verification)。
下面我们以一个经典的堆栈(Stack)为例说明在过程式程序中单元测试的基本方法:
/*C语言*/
void test_push(){
Stack *pStack = create_stack();//创建结构体stack
push(pStack, 1);
ASSERT_EQUAL(1, pStack->items[0]); /*状态验证*/
push(pStack, 2);
ASSERT_EQUAL(2, pStack->items[1]); /*状态验证*/
}
void test_pop(){
Stack *pStack = create_stack();/*创建结构体stack*/
pStack->size = 2;
pStack->items[0] = 1;
pStack->items[1] = 2;/*状态准备*/
int item2 = pop(pStack));
ASSERT_EQUAL(2, item2);
int item2 = pop(pStack));
ASSERT_EQUAL(2, item2);
}
OOP和行为验证
上面堆栈的例子中,我们注意到push和pop两个函数是由同一组变量而关联起来的,它们共同协作才实现了堆栈的先入后出(FILO)功能。那么,我们能不能提供一种抽象机制,把原先分离的操作关联起来,通过定义一个新的类型形成一个有机整体呢?这就是数据抽象(Data Abstraction)的基本思想,也是OOP的根源。用化学的语言,如果把int, char等基本类型比喻为单质,那么OOP通过数据抽象形成的抽象数据类型(Abstract Data Type)就好像一种化合物。类型(Type)是数学概念,强调语义,类(Class)是C++/Java/C#等OOP语言为定义类型而提供的语法机制。其中,类的封装性(Encapsulation)是其根本特性。相比过程式程序,封装对数据实现了信息隐藏,把“变量+函数”的二元结构变成了对象的一元结构,只允许对象通过public方法与外部通信。
图2,OOP对象的一元结构
相应的,OOP程序的单元测试也以对象的行为验证为主。行为验证(Behavior Verification)是指从类型规范出发,通过一个场景验证对象行为符合类型规范。比如:对于堆栈,其类型规范即FILO,那么行为验证就是要构造一个场景,检验堆栈对象的push和pop方法符合FILO规范。下面是用C++语言实现的基于行为验证的单元测试:
//C++
void test_FILO(){
Stack stack;
int input1 = 1;
int input2 = 2;
push(stack, input1);
push(stack, input2);
int output1 = stack.pop();
ASSERT_EQUAL(output1, input2); /* 检查FILO*/
int output2 = stack.pop();
ASSERT_EQUAL(output2, input1); /*检查FILO*/
}
编写行为验证测试用例的首要条件是理解类型规范,一般来讲类型规范应包括几个方面:1.各个方法的Precondition和Postcondition,例如:输入参数值为[0, 1000),返回值不为NULL;2.类的Invariant,例如:儿童的年龄属性小于18;3.类各方法的关系不变式,例如:堆栈的FILO;4.类与外部类的交互关系,例如:Socket发生错误的时候向外界发出事件。这些都属于在行为验证中应该检查的。
状态验证 vs 行为验证
状态验证侧重于检验函数对数据状态的改变,更加靠近实现,是一种基于内部状态的白盒测试;行为验证侧重于检验对象的外部行为,更加靠近需求,是一种基于外部接口的黑盒测试。从重构的角度看,二者也有显著的不同:状态验证和具体实现是紧密相关的,在需求不变的情况下,重构实现很可能会使原有的测试用例失效;而行为验证和具体实现没有关系,在需求不变的情况下,重构实现不会使原有测试用例失效,而且还能利用原有测试用例作为回归测试,防止重构过程引入bug。在实际的软件开发中,尤其是采用OOP开发的情况下,我们提倡采用行为验证。不过,状态验证也有用武之地,有时为了构造一个不易出现的程序状态,通过状态验证可以轻易实现,而通过行为验证则很难写出相应的场景。这是由白盒和黑盒测试的差别所决定的,白盒测试好比手术,黑盒测试好比吃药,各有适用的场景。
过程式语言可以做行为验证吗?
答案是肯定的!其实,C++/Java/C#提供的class仅仅是一种语法手段,如果真正理解了数据抽象思想,用C语言同样可以做行为验证:
/*C语言*/
void test_FILO(){
Stack *pStack = create_stack();
push(pStack, 1);
push(pStack, 2);
int item2 = pop(pStack));
ASSERT_EQUAL(2, item2); /*检查FILO*/
int item1 = pop(pStack);
ASSERT_EQUAL(1, item2); /*检查FILO*/
}
在过程式语言中做行为验证的要点在于:忽略数据,重视函数间的关系!
OOP语言可以做状态验证吗?
答案也是肯定的!不过,需要三思而后行。很多时候,OOP语言中出现状态验证并非有意为之,而是程序员没有理解数据抽象思想,虽然在用OOP语言,但本质上还是在写过程式程序。下面的程序就是典型:
//C++
void test_push(){
std::vector<int> items;
Stack stack(items); //构造函数依赖注入
stack.push(1);
ASSERT_EQUAL(1, items[0]);//检查状态
}
有一个简单的办法来提醒我们检查是不是在用OOP语言写过程式代码:如果对象的行为依赖于其它外部对象的状态,那么就应该审视一下是不是破坏了封装滑落到了过程式设计。上面的例子中,stack对象的状态是由一个vector外部对象来保管的,stack的push/pop行为显然依赖于其它对象的状态,这时我们就应该回过头来检查自己的设计是不是有问题。一些程序员意识不到这个例子实际上是过程式程序,反而以为运用了依赖注入进行解耦,所以是一种良好的设计。
总结
本文介绍了状态验证和行为验证两种单元测试的基本方式,以及背后的过程式和OO程序设计思想。本文所讲的状态验证/行为验证与Martin Fowler文章Mocks Aren’t Stubs中的State Verification/Behavior Verification所强调的方面并不完全相同,更接近于BDD(Behavior Driven Development)所讲的behavior,读者可进行比较。
参考