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