全面介紹單元測試

這是一篇全面介紹單元測試的經典之作,對理解單元測試和Visual Unit很有幫助,作者老納,收錄時作了少量修改

一 單元測試概述
  工廠在組裝一臺電視機之前,會對每個元件都進行測試,這,就是單元測試。
   其實我們每天都在做單元測試。你寫了一個函數,除了極簡單的外,總是要執行一下,看看功能是否正常,有時還要想辦法輸出些數據,如彈出信息窗口什麼的, 這,也是單元測試,老納把這種單元測試稱爲臨時單元測試。只進行了臨時單元測試的軟件,針對代碼的測試很不完整,代碼覆蓋率要超過70%都很困難,未覆蓋 的代碼可能遺留大量的細小的錯誤,這些錯誤還會互相影響,當BUG暴露出來的時候難於調試,大幅度提高後期測試和維護成本,也降低了開發商的競爭力。可以 說,進行充分的單元測試,是提高軟件質量,降低開發成本的必由之路。
  對於程序員來說,如果養成了對自己寫的代碼進行單元測試的習慣,不但可以寫出高質量的代碼,而且還能提高編程水平。
  要進行充分的單元測試,應專門編寫測試代碼,並與產品代碼隔離。老納認爲,比較簡單的辦法是爲產品工程建立對應的測試工程,爲每個類建立對應的測試類,爲每個函數(很簡單的除外)建立測試函數。首先就幾個概念談談老納的看法。
   一般認爲,在結構化程序時代,單元測試所說的單元是指函數,在當今的面向對象時代,單元測試所說的單元是指類。以老納的實踐來看,以類作爲測試單位,復 雜度高,可操作性較差,因此仍然主張以函數作爲單元測試的測試單位,但可以用一個測試類來組織某個類的所有測試函數。單元測試不應過分強調面向對象,因爲 局部代碼依然是結構化的。單元測試的工作量較大,簡單實用高效纔是硬道理。
  有一種看法是,只測試類的接口(公有函數),不測試其他函數,從面 向對象角度來看,確實有其道理,但是,測試的目的是找錯並最終排錯,因此,只要是包含錯誤的可能性較大的函數都要測試,跟函數是否私有沒有關係。對於C+ +來說,可以用一種簡單的方法區隔需測試的函數:簡單的函數如數據讀寫函數的實現在頭文件中編寫(inline函數),所有在源文件編寫實現的函數都要進 行測試(構造函數和析構函數除外)。
  什麼時候測試?單元測試越早越好,早到什麼程度?XP開發理論講究TDD,即測試驅動開發,先編寫測試代 碼,再進行開發。在實際的工作中,可以不必過分強調先什麼後什麼,重要的是高效和感覺舒適。從老納的經驗來看,先編寫產品函數的框架,然後編寫測試函數, 針對產品函數的功能編寫測試用例,然後編寫產品函數的代碼,每寫一個功能點都運行測試,隨時補充測試用例。所謂先編寫產品函數的框架,是指先編寫函數空的 實現,有返回值的隨便返回一個值,編譯通過後再編寫測試代碼,這時,函數名、參數表、返回類型都應該確定下來了,所編寫的測試代碼以後需修改的可能性比較 小。
  由誰測試?單元測試與其他測試不同,單元測試可看作是編碼工作的一部分,應該由程序員完成,也就是說,經過了單元測試的代碼纔是已完成的代碼,提交產品代碼時也要同時提交測試代碼。測試部門可以作一定程度的審覈。
   關於樁代碼,老納認爲,單元測試應避免編寫樁代碼。樁代碼就是用來代替某些代碼的代碼,例如,產品函數或測試函數調用了一個未編寫的函數,可以編寫樁函 數來代替該被調用的函數,樁代碼也用於實現測試隔離。採用由底向上的方式進行開發,底層的代碼先開發並先測試,可以避免編寫樁代碼,這樣做的好處有:減少 了工作量;測試上層函數時,也是對下層函數的間接測試;當下層函數修改時,通過迴歸測試可以確認修改是否導致上層函數產生錯誤。

二 測試代碼編寫
  多數講述單元測試的文章都是以Java爲例,本文以C++爲例,後半部分所介紹的單元測試工具也只介紹C++單元測試工具。下面的示例代碼的開發環境是VC6.0。

產品類:
class CMyClass
{
public:
    int Add(int i, int j);
    CMyClass();
    virtual ~CMyClass();

private:
    int mAge;      //年齡
    CString mPhase; //年齡階段,如"少年","青年"
};

建立對應的測試類CMyClassTester,爲了節約編幅,只列出源文件的代碼:
void CMyClassTester::CaseBegin()
{
    //pObj是CMyClassTester類的成員變量,是被測試類的對象的指針,
    //爲求簡單,所有的測試類都可以用pObj命名被測試對象的指針。
    pObj = new CMyClass();
}

void CMyClassTester::CaseEnd()
{
    delete pObj;
}
測試類的函數CaseBegin()和CaseEnd()建立和銷燬被測試對象,每個測試用例的開頭都要調用CaseBegin(),結尾都要調用CaseEnd()。

接下來,我們建立示例的產品函數:
int CMyClass::Add(int i, int j)
{
    return i+j;
}
和對應的測試函數:
void CMyClassTester::Add_int_int()
{
}
把參數表作爲函數名的一部分,這樣當出現重載的被測試函數時,測試函數不會產生命名衝突。下面添加測試用例:
void CMyClassTester::Add_int_int()
{
    //第一個測試用例
    CaseBegin();{              //1
    int i = 0;                //2
    int j = 0;                //3
    int ret = pObj->Add(i, j); //4
    ASSERT(ret == 0);          //5
    }CaseEnd();                //6
}
第1和第6行建立和銷燬被測試對象,所加的{}是爲了讓每個測試用例的代碼有一個獨立的域,以便多個測試用例使用相同的變量名。
第2 和第3行是定義輸入數據,第4行是調用被測試函數,這些容易理解,不作進一步解釋。第5行是預期輸出,它的特點是當實際輸出與預期輸出不同時自動報錯, ASSERT是VC的斷言宏,也可以使用其他類似功能的宏,使用測試工具進行單元測試時,可以使用該工具定義的斷言宏。

  示例中的格式 顯得很不簡潔,2、3、4、5行可以合寫爲一行:ASSERT(pObj->Add(0, 0) == 0);但這種不簡潔的格式卻是老納極力推薦的,因爲它一目瞭然,易於建立多個測試用例,並且具有很好的適應性,同時,也是極佳的代碼文檔,總之,老納建 議:輸入數據和預期輸出要自成一塊。
  建立了第一個測試用例後,應編譯並運行測試,以排除語法錯誤,然後,使用拷貝/修改的辦法建立其他測試用例。由於各個測試用例之間的差別往往很小,通常只需修改一兩個數據,拷貝/修改是建立多個測試用例的最快捷辦法。

三 測試用例
   下面說說測試用例、輸入數據及預期輸出。輸入數據是測試用例的核心,老納對輸入數據的定義是:被測試函數所讀取的外部數據及這些數據的初始值。外部數據 是對於被測試函數來說的,實際上就是除了局部變量以外的其他數據,老納把這些數據分爲幾類:參數、成員變量、全局變量、IO媒體。IO媒體是指文件、數據 庫或其他儲存或傳輸數據的媒體,例如,被測試函數要從文件或數據庫讀取數據,那麼,文件或數據庫中的原始數據也屬於輸入數據。一個函數無論多複雜,都無非 是對這幾類數據的讀取、計算和寫入。預期輸出是指:返回值及被測試函數所寫入的外部數據的結果值。返回值就不用說了,被測試函數進行了寫操作的參數(輸出 參數)、成員變量、全局變量、IO媒體,它們的預期的結果值都是預期輸出。一個測試用例,就是設定輸入數據,運行被測試函數,然後判斷實際輸出是否符合預 期。下面舉一個與成員變量有關的例子:
產品函數:
void CMyClass::Grow(int years)
{
    mAge += years;

    if(mAge < 10)
        mPhase = "兒童";
    else if(mAge <20)
        mPhase = "少年";
    else if(mAge <45)
        mPhase = "青年";
    else if(mAge <60)
        mPhase = "中年";
    else
        mPhase = "老年";
}

測試函數中的一個測試用例:
    CaseBegin();{
    int years = 1;
    pObj->mAge = 8;
    pObj->Grow(years);
    ASSERT( pObj->mAge == 9 );
    ASSERT( pObj->mPhase == "兒童" );
    }CaseEnd();
在 輸入數據中對被測試類的成員變量mAge進行賦值,在預期輸出中斷言成員變量的值。現在可以看到老納所推薦的格式的好處了吧,這種格式可以適應很複雜的測 試。在輸入數據部分還可以調用其他成員函數,例如:執行被測試函數前可能需要讀取文件中的數據保存到成員變量,或需要連接數據庫,老納把這些操作稱爲初始 化操作。例如,上例中 ASSERT( ...)之前可以加pObj->OpenFile();。爲了訪問私有成員,可以將測試類定義爲產品類的友元類。例如,定義一個宏:
#define UNIT_TEST(cls) friend class cls##Tester;
然後在產品類聲明中加一行代碼:UNIT_TEST(ClassName)。

   下面談談測試用例設計。前面已經說了,測試用例的核心是輸入數據。預期輸出是依據輸入數據和程序功能來確定的,也就是說,對於某一程序,輸入數據確定 了,預期輸出也就可以確定了,至於生成/銷燬被測試對象和運行測試的語句,是所有測試用例都大同小異的,因此,我們討論測試用例時,只討論輸入數據。
   前面說過,輸入數據包括四類:參數、成員變量、全局變量、IO媒體,這四類數據中,只要所測試的程序需要執行讀操作的,就要設定其初始值,其中,前兩類 比較常用,後兩類較少用。顯然,把輸入數據的所有可能取值都進行測試,是不可能也是無意義的,我們應該用一定的規則選擇有代表性的數據作爲輸入數據,主要 有三種:正常輸入,邊界輸入,非法輸入,每種輸入還可以分類,也就是平常說的等價類法,每類取一個數據作爲輸入數據,如果測試通過,可以肯定同類的其他輸 入也是可以通過的。下面舉例說明:
  正常輸入
  例如字符串的Trim函數,功能是將字符串前後的空格去除,那麼正常的輸入可以有四類:前面有空格;後面有空格;前後均有空格;前後均無空格。
  邊界輸入
  上例中空字符串可以看作是邊界輸入。
  再如一個表示年齡的參數,它的有效範圍是0-100,那麼邊界輸入有兩個:0和100。
  非法輸入
  非法輸入是正常取值範圍以外的數據,或使代碼不能完成正常功能的輸入,如上例中表示年齡的參數,小於0或大於100都是非法輸入,再如一個進行文件操作的函數,非法輸入有這麼幾類:文件不存在;目錄不存在;文件正在被其他程序打開;權限錯誤。
   如果函數使用了外部數據,則正常輸入是肯定會有的,而邊界輸入和非法輸入不是所有函數都有。一般情況下,即使沒有設計文檔,考慮以上三種輸入也可以找出 函數的基本功能點。實際上,單元測試與代碼編寫是“一體兩面”的關係,編碼時對上述三種輸入都是必須考慮的,否則代碼的健壯性就會成問題。

四 白盒覆蓋
   上面所說的測試數據都是針對程序的功能來設計的,就是所謂的黑盒測試。單元測試還需要從另一個角度來設計測試數據,即針對程序的邏輯結構來設計測試用 例,就是所謂的白盒測試。在老納看來,如果黑盒測試是足夠充分的,那麼白盒測試就沒有必要,可惜“足夠充分”只是一種理想狀態,例如:真的是所有功能點都 測試了嗎?程序的功能點是人爲的定義,常常是不全面的;各個輸入數據之間,有些組合可能會產生問題,怎樣保證這些組合都經過了測試?難於衡量測試的完整性 是黑盒測試的主要缺陷,而白盒測試恰恰具有易於衡量測試完整性的優點,兩者之間具有極好的互補性,例如:完成功能測試後統計語句覆蓋率,如果語句覆蓋未完 成,很可能是未覆蓋的語句所對應的功能點未測試。
  白盒測試針對程序的邏輯結構設計測試用例,用邏輯覆蓋率來衡量測試的完整性。邏輯單位主要 有:語句、分支、條件、條件值、條件值組合,路徑。語句覆蓋就是覆蓋所有的語句,其他類推。另外還有一種判定條件覆蓋,其實是分支覆蓋與條件覆蓋的組合, 在此不作討論。跟條件有關的覆蓋就有三種,解釋一下:條件覆蓋是指覆蓋所有的條件表達式,即所有的條件表達式都至少計算一次,不考慮計算結果;條件值覆蓋 是指覆蓋條件的所有可能取值,即每個條件的取真值和取假值都要至少計算一次;條件值組合覆蓋是指覆蓋所有條件取值的所有可能組合。老納做過一些粗淺的研 究,發現與條件直接有關的錯誤主要是邏輯操作符錯誤,例如:||寫成&&,漏了寫!什麼的,採用分支覆蓋與條件覆蓋的組合,基本上可以發 現這些錯誤,另一方面,條件值覆蓋與條件值組合覆蓋往往需要大量的測試用例,因此,在老納看來,條件值覆蓋和條件值組合覆蓋的效費比偏低。老納認爲效費比 較高且完整性也足夠的測試要求是這樣的:完成功能測試,完成語句覆蓋、條件覆蓋、分支覆蓋、路徑覆蓋。做過單元測試的朋友恐怕會對老納提出的測試要求給予 一個字的評價:暈!或者兩個字的評價:狂暈!因爲這似乎是不可能的要求,要達到這種測試完整性,其測試成本是不可想象的,不過,出家人不打逛語,老納之所 以提出這種測試要求,是因爲利用一些工具,可以在較低的成本下達到這種測試要求,後面將會作進一步介紹。
  關於白盒測試用例的設計,程序測試領 域的書籍一般都有講述,普通方法是畫出程序的邏輯結構圖如程序流程圖或控制流圖,根據邏輯結構圖設計測試用例,這些是純粹的白盒測試,不是老納想推薦的方 式。老納所推薦的方法是:先完成黑盒測試,然後統計白盒覆蓋率,針對未覆蓋的邏輯單位設計測試用例覆蓋它,例如,先檢查是否有語句未覆蓋,有的話設計測試 用例覆蓋它,然後用同樣方法完成條件覆蓋、分支覆蓋和路徑覆蓋,這樣的話,既檢驗了黑盒測試的完整性,又避免了重複的工作,用較少的時間成本達到非常高的 測試完整性。不過,這些工作可不是手工能完成的,必須藉助於工具,後面會介紹可以完成這些工作的測試工具。

五 單元測試工具
  現在開始介紹單元測試工具,老納只介紹三種,都是用於C++語言的。
  首先是CppUnit,這是C++單元測試工具的鼻祖,免費的開源的單元測試框架。由於已有一衆高人寫了不少關於CppUnit的很好的文章,老納就不現醜了,想了解CppUnit的朋友,建議讀一下Cpluser 所作的《CppUnit測試框架入門》,網址是:http://blog.csdn.net/cpluser/archive/2004/09/21/111522.aspx。該文也提供了CppUnit的下載地址。
   然後介紹C++Test,這是Parasoft公司的產品。[C++Test是一個功能強大的自動化C/C++單元級測試工具,可以自動測試任何C/C ++函數、類,自動生成測試用例、測試驅動函數或樁函數,在自動化的環境下極其容易快速的將單元級的測試覆蓋率達到100%]。[]內的文字引自http://www.superst.com.cn/softwares_testing_c_cpptest.htm, 這是華唐公司的網頁。老納想寫些介紹C++Test的文字,但發現無法超越華唐公司的網頁上的介紹,所以也就省點事了,想了解C++Test的朋友,建議 訪問該公司的網站。華唐公司代理C++Test,想要購買或索取報價、試用版都可以找他們。老納幫華唐公司做廣告,不知道會不會得點什麼好處?
   最後介紹Visual Unit,簡稱VU,這是國產的單元測試工具,據說申請了多項專利,擁有一批創新的技術,不過老納只關心是不是有用和好用。[自動生成測試代碼 快速建立功能測試用例 程序行爲一目瞭然 極高的測試完整性 高效完成白盒覆蓋 快速排錯 高效調試 詳盡的測試報告]。[]內的文字是VU開發商的網頁上摘錄的,網址是:http://www.unitware.cn。 前面所述測試要求:完成功能測試,完成語句覆蓋、條件覆蓋、分支覆蓋、路徑覆蓋,用VU可以輕鬆實現,還有一點值得一提:使用VU還能提高編碼的效率,總 體來說,在完成單元測試的同時,編碼調試的時間還能大幅度縮短。算了,不想再講了,老納顯擺理論、介紹經驗還是有興趣的,因爲可以滿足老納好爲人師的虛榮 心,但介紹工具就覺得索然無味了,畢竟工具好不好用,合不合用,要試過才知道,還是自己去開發商的網站看吧,可以下載演示版,還有演示課件。




Linux 下 CppUnit的安裝與使用 <script language="javascript" type="text/javascript">document.title="Linux 下 CppUnit的安裝與使用 - "+document.title</script>

 

OS:linux
CppUnit:cppunit-1.11.6
1、下載、解壓
     到http://sourceforge.net/projects/cppunit下載,然後複製cppunit-1.11.6.tar.gz到/usr/src;  
    運行:tar -xf cppunit-1.10.2.tar.gz 解壓縮;或者在WIN下直接解壓
2、安裝
進入cppunit-1.11.6目錄下。依次運行下列命令
      A :./configure;  
B :make;
      C :make check;
      D:make install
3、copy *.h文件
    .o, .a文件已經安裝到/usr/local/lib中去了,但頭文件沒安裝到/usr/include中去
把cppunit-1.10.2的cppunit目錄複製到/usr/include下
4、導入lib
運行時要先設置環境變量LD_LIBRARY_PATH到cppunit的安裝目錄,也就是/usr/local/lib,命令如下:
       export LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH
5、編寫測試程序
testApp.cpp
#include <iostream>
 
#include <cppunit/TestRunner.h>
#include <cppunit/TestResult.h>
#include <cppunit/TestResultCollector.h>
#include <cppunit/extensions/HelperMacros.h>
#include <cppunit/BriefTestProgressListener.h>
#include <cppunit/extensions/TestFactoryRegistry.h>
 
 
class Test : public CPPUNIT_NS::TestCase
{
 CPPUNIT_TEST_SUITE(Test);
 CPPUNIT_TEST(testHelloWorld);
   CPPUNIT_TEST_SUITE_END();
 
 public:
   void setUp(void) {}
   void tearDown(void) {}
 
 protected:
   void testHelloWorld(void) { std::cout << "Hello, world!" << std::endl; }
 };
 
 CPPUNIT_TEST_SUITE_REGISTRATION(Test);
 
 int main( int argc, char **argv )
 {
   // Create the event manager and test controller
   CPPUNIT_NS::TestResult controller;
 
   // Add a listener that colllects test result
   CPPUNIT_NS::TestResultCollector result;
 controller.addListener( &result );       
 
   // Add a listener that print dots as test run.
   CPPUNIT_NS::BriefTestProgressListener progress;
   controller.addListener( &progress );     
 
   // Add the top suite to the test runner
   CPPUNIT_NS::TestRunner runner;
   runner.addTest( CPPUNIT_NS::TestFactoryRegistry::getRegistry().makeTest() );
   runner.run( controller );
 
   return result.wasSuccessful() ? 0 : 1;
 }
6、編譯,運行
   有兩種方法
    (a) 鏈接靜態庫。編譯命令:
      g++ -L/usr/local/lib/libcppunit.a testApp.cpp -lcppunit -ldl -o testApp
    運行:
      ./ testApp
    結果:
     
      : OK
    (b) 鏈接動態庫。編譯命令:
       g++ testApp.cpp -lcppunit -ldl -o testApp
    然後運行:
       ./ testApp
    結果:
       Test::
         : OK
7、其他例子
在cppunit-docs-1.11.6中有文檔money_example.html,該文檔詳細的介紹了測試、開發的過程;


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章