單元測(cè)試本身并不嚴(yán)格限制過程式還是OOP,白盒還是黑盒,因而測(cè)試用例的寫法具有很大的隨意性。一些程序員對(duì)于C++/Java/C#等OO語法特性津津樂道,但卻沒有掌握OOP的基本思想。怎么知道呢?就從編寫的單元測(cè)試用例就能看出來。單元測(cè)試用例的編寫可以直接反映一個(gè)程序員是否真正理解了什么是過程式編程,什么是OOP。我甚至覺得,如果在面試中要考察面試者對(duì)OOP的掌握程度,考察編寫單元測(cè)試是一種最好的方法。所以,本文打算介紹單元測(cè)試中狀態(tài)驗(yàn)證和行為驗(yàn)證兩種不同的方式,并分析其背后的過程式思想和OOP思想。
過程式和狀態(tài)驗(yàn)證
以機(jī)器語言和匯編語言為代表的早期命令式程序設(shè)計(jì)語言是von Neumann體系結(jié)構(gòu)“存儲(chǔ)程序”(Stored Program)思想的直接體現(xiàn)。在命令式程序設(shè)計(jì)中,用變量表示數(shù)據(jù),用語句表示由計(jì)算機(jī)執(zhí)行的指令;程序的執(zhí)行效果體現(xiàn)在語句對(duì)變量值的改變上。后來,以C語言為代表的高級(jí)語言在此的基礎(chǔ)上引入了過程抽象(Procedure Abstraction),通過定義過程/函數(shù)/子程序(Procedure/Function/Subroutine)對(duì)一系列的語句進(jìn)行抽象,形成了過程式程序設(shè)計(jì)。變量和函數(shù)這兩種基本元素構(gòu)成了“變量+函數(shù)”的二元結(jié)構(gòu)。函數(shù)的設(shè)計(jì)一般采用自頂向下分而治之的方式,大函數(shù)套小函數(shù),層層細(xì)化。
圖1,過程式“變量+函數(shù)”的二元結(jié)構(gòu)
過程式程序的單元測(cè)試用例多與函數(shù)對(duì)應(yīng),在一個(gè)用例中專門測(cè)試某一個(gè)函數(shù)。單元測(cè)試的準(zhǔn)備工作好包括:設(shè)置全局變量和輸入變量等非被測(cè)函數(shù)局部變量的值;檢查內(nèi)容包括:檢查函數(shù)的返回值,以及非被測(cè)函數(shù)局部變量的值。我們稱這種通過檢查非被測(cè)函數(shù)局部變量值的方式驗(yàn)證函數(shù)正確性的單元測(cè)試方法為狀態(tài)驗(yàn)證(State Verification)。
下面我們以一個(gè)經(jīng)典的堆棧(Stack)為例說明在過程式程序中單元測(cè)試的基本方法:
/*C語言*/
void test_push(){
Stack *pStack = create_stack();//創(chuàng)建結(jié)構(gòu)體stack
push(pStack, 1);
ASSERT_EQUAL(1, pStack->items[0]); /*狀態(tài)驗(yàn)證*/
push(pStack, 2);
ASSERT_EQUAL(2, pStack->items[1]); /*狀態(tài)驗(yàn)證*/
}
void test_pop(){
Stack *pStack = create_stack();/*創(chuàng)建結(jié)構(gòu)體stack*/
pStack->size = 2;
pStack->items[0] = 1;
pStack->items[1] = 2;/*狀態(tài)準(zhǔn)備*/
int item2 = pop(pStack));
ASSERT_EQUAL(2, item2);
int item2 = pop(pStack));
ASSERT_EQUAL(2, item2);
}
OOP和行為驗(yàn)證
上面堆棧的例子中,我們注意到push和pop兩個(gè)函數(shù)是由同一組變量而關(guān)聯(lián)起來的,它們共同協(xié)作才實(shí)現(xiàn)了堆棧的先入后出(FILO)功能。那么,我們能不能提供一種抽象機(jī)制,把原先分離的操作關(guān)聯(lián)起來,通過定義一個(gè)新的類型形成一個(gè)有機(jī)整體呢?這就是數(shù)據(jù)抽象(Data Abstraction)的基本思想,也是OOP的根源。用化學(xué)的語言,如果把int, char等基本類型比喻為單質(zhì),那么OOP通過數(shù)據(jù)抽象形成的抽象數(shù)據(jù)類型(Abstract Data Type)就好像一種化合物。類型(Type)是數(shù)學(xué)概念,強(qiáng)調(diào)語義,類(Class)是C++/Java/C#等OOP語言為定義類型而提供的語法機(jī)制。其中,類的封裝性(Encapsulation)是其根本特性。相比過程式程序,封裝對(duì)數(shù)據(jù)實(shí)現(xiàn)了信息隱藏,把“變量+函數(shù)”的二元結(jié)構(gòu)變成了對(duì)象的一元結(jié)構(gòu),只允許對(duì)象通過public方法與外部通信。
圖2,OOP對(duì)象的一元結(jié)構(gòu)
相應(yīng)的,OOP程序的單元測(cè)試也以對(duì)象的行為驗(yàn)證為主。行為驗(yàn)證(Behavior Verification)是指從類型規(guī)范出發(fā),通過一個(gè)場(chǎng)景驗(yàn)證對(duì)象行為符合類型規(guī)范。比如:對(duì)于堆棧,其類型規(guī)范即FILO,那么行為驗(yàn)證就是要構(gòu)造一個(gè)場(chǎng)景,檢驗(yàn)堆棧對(duì)象的push和pop方法符合FILO規(guī)范。下面是用C++語言實(shí)現(xiàn)的基于行為驗(yàn)證的單元測(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*/
}
編寫行為驗(yàn)證測(cè)試用例的首要條件是理解類型規(guī)范,一般來講類型規(guī)范應(yīng)包括幾個(gè)方面:1.各個(gè)方法的Precondition和Postcondition,例如:輸入?yún)?shù)值為[0, 1000),返回值不為NULL;2.類的Invariant,例如:兒童的年齡屬性小于18;3.類各方法的關(guān)系不變式,例如:堆棧的FILO;4.類與外部類的交互關(guān)系,例如:Socket發(fā)生錯(cuò)誤的時(shí)候向外界發(fā)出事件。這些都屬于在行為驗(yàn)證中應(yīng)該檢查的。
狀態(tài)驗(yàn)證 vs 行為驗(yàn)證
狀態(tài)驗(yàn)證側(cè)重于檢驗(yàn)函數(shù)對(duì)數(shù)據(jù)狀態(tài)的改變,更加靠近實(shí)現(xiàn),是一種基于內(nèi)部狀態(tài)的白盒測(cè)試;行為驗(yàn)證側(cè)重于檢驗(yàn)對(duì)象的外部行為,更加靠近需求,是一種基于外部接口的黑盒測(cè)試。從重構(gòu)的角度看,二者也有顯著的不同:狀態(tài)驗(yàn)證和具體實(shí)現(xiàn)是緊密相關(guān)的,在需求不變的情況下,重構(gòu)實(shí)現(xiàn)很可能會(huì)使原有的測(cè)試用例失效;而行為驗(yàn)證和具體實(shí)現(xiàn)沒有關(guān)系,在需求不變的情況下,重構(gòu)實(shí)現(xiàn)不會(huì)使原有測(cè)試用例失效,而且還能利用原有測(cè)試用例作為回歸測(cè)試,防止重構(gòu)過程引入bug。在實(shí)際的軟件開發(fā)中,尤其是采用OOP開發(fā)的情況下,我們提倡采用行為驗(yàn)證。不過,狀態(tài)驗(yàn)證也有用武之地,有時(shí)為了構(gòu)造一個(gè)不易出現(xiàn)的程序狀態(tài),通過狀態(tài)驗(yàn)證可以輕易實(shí)現(xiàn),而通過行為驗(yàn)證則很難寫出相應(yīng)的場(chǎng)景。這是由白盒和黑盒測(cè)試的差別所決定的,白盒測(cè)試好比手術(shù),黑盒測(cè)試好比吃藥,各有適用的場(chǎng)景。
過程式語言可以做行為驗(yàn)證嗎?
答案是肯定的!其實(shí),C++/Java/C#提供的class僅僅是一種語法手段,如果真正理解了數(shù)據(jù)抽象思想,用C語言同樣可以做行為驗(yàn)證:
/*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*/
}
在過程式語言中做行為驗(yàn)證的要點(diǎn)在于:忽略數(shù)據(jù),重視函數(shù)間的關(guān)系!
OOP語言可以做狀態(tài)驗(yàn)證嗎?
答案也是肯定的!不過,需要三思而后行。很多時(shí)候,OOP語言中出現(xiàn)狀態(tài)驗(yàn)證并非有意為之,而是程序員沒有理解數(shù)據(jù)抽象思想,雖然在用OOP語言,但本質(zhì)上還是在寫過程式程序。下面的程序就是典型:
//C++
void test_push(){
std::vector<int> items;
Stack stack(items); //構(gòu)造函數(shù)依賴注入
stack.push(1);
ASSERT_EQUAL(1, items[0]);//檢查狀態(tài)
}
有一個(gè)簡(jiǎn)單的辦法來提醒我們檢查是不是在用OOP語言寫過程式代碼:如果對(duì)象的行為依賴于其它對(duì)象的狀態(tài),那么就應(yīng)該審視一下是不是破壞了封裝滑落到了過程式設(shè)計(jì)。上面的例子中,stack對(duì)象的狀態(tài)是由一個(gè)vector對(duì)象來保管的,stack的push/pop行為顯然依賴于其它對(duì)象的狀態(tài),這時(shí)我們就應(yīng)該回過頭來檢查自己的設(shè)計(jì)是不是有問題。
本文介紹了狀態(tài)驗(yàn)證和行為驗(yàn)證兩種單元測(cè)試的基本方式,以及背后的過程式和OO程序設(shè)計(jì)思想。本文所講的狀態(tài)驗(yàn)證/行為驗(yàn)證與Martin Fowler文章Mocks Aren’t Stubs中的State Verification/Behavior Verification所強(qiáng)調(diào)的方面并不完全相同,讀者可進(jìn)行比較。