CppUnit 是個基於 LGPL 的開源項目,最初版本移植自 JUnit,是一個非常優秀的開源測試框架。CppUnit 和 JUnit 一樣主要思想來源於極限編程(XProgramming)。主要功能就是對單元測試進行管理,並可進行自動化測試。這樣描述可能沒有讓您體會到測試框架的強大威力,那您在開發過程中遇到下列問題嗎?如果答案是肯定的,就應該學習使用這種技術:
- 測試代碼沒有很好地維護而廢棄,再次需要測試時還需要重寫;
- 投入太多的精力,找 bug,而新的代碼仍然會出現類似 bug;
- 寫完代碼,心裏沒底,是否有大量 bug 等待自己;
- 新修改的代碼不知道是否影響其他部分代碼;
- 由於牽扯太多,導致不敢進行修改代碼;
...
這些問題下文都會涉及。這個功能強大的測試框架在國內的 C++ 語言開發人員中使用的不是很多。本文從開發人員的角度,介紹這個框架,希望能夠使開發人員用最少的代價儘快掌握這種技術。下面從基本原理,CppUnit 原理,手動使用步驟,通常使用步驟,其他實際問題等方面進行討論。以下討論基於 CppUnit1.8.0。
對於上面的問題僅僅說明 CppUnit 的使用是沒有效果的,下面先從測試的目的,測試原則等方面簡要說明,然後介紹 CppUnit 的具體使用。
首先要明確我們寫測試代碼的目的,就是驗證代碼的正確性或者調試 bug。這樣寫測試代碼時就有了針對性,對那些容易出錯的,易變的編寫測試代碼;而不用對每個細節,每個功能編寫測試代碼,當然除非有過量精力或者可靠性要求。
編碼和測試的關係是密不可分的,推薦的開發過程並不要等編寫完所有或者很多的代碼後再進行測試,而是在完成一部分代碼,比如一個函數,之後立刻編寫測試代碼進行驗證。然後再寫一些代碼,再寫測試。每次測試對所有以前的測試都進行一遍。這樣做的優點就是,寫完代碼,也基本測試完一遍,心裏對代碼有信心。而且在寫新代碼時不斷地測試老代碼,對其他部分代碼的影響能夠迅速發現、定位。不斷編碼測試的過程也就是對測試代碼維護的過程,以便測試代碼一直是有效的。有了各個部分測試代碼的保證,有了自動測試的機制,更改以前的代碼沒有什麼顧慮了。在極限編程(一種軟件開發思想)中,甚至強調先寫測試代碼,然後編寫符合測試代碼的代碼,進而完成整個軟件。
根據上面說的目的、思想,下面總結一下平時開發過程中單元測試的原則:
- 先寫測試代碼,然後編寫符合測試的代碼。至少做到完成部分代碼後,完成對應的測試代碼;
- 測試代碼不需要覆蓋所有的細節,但應該對所有主要的功能和可能出錯的地方有相應的測試用例;
- 發現 bug,首先編寫對應的測試用例,然後進行調試;
- 不斷總結出現 bug 的原因,對其他代碼編寫相應測試用例;
- 每次編寫完成代碼,運行所有以前的測試用例,驗證對以前代碼影響,把這種影響儘早消除;
- 不斷維護測試代碼,保證代碼變動後通過所有測試;
有上面的理論做指導,測試行爲就可以有規可循。那麼 CppUnit 如何實現這種測試框架,幫助我們管理測試代碼,完成自動測試的?下面就看看 CppUnit 的原理。
在 CppUnit 中,一個或一組測試用例的測試對象被稱爲 Fixture(設施,下文爲方便理解儘量使用英文名稱)。Fixture 就是被測試的目標,可能是一個對象或者一組相關的對象,甚至一個函數。
有了被測試的 fixture,就可以對這個 fixture 的某個功能、某個可能出錯的流程編寫測試代碼,這樣對某個方面完整的測試被稱爲TestCase(測試用例)。通常寫一個 TestCase 的步驟包括:
- 對 fixture 進行初始化,及其他初始化操作,比如:生成一組被測試的對象,初始化值;
- 按照要測試的某個功能或者某個流程對 fixture 進行操作;
- 驗證結果是否正確;
- 對 fixture 的及其他的資源釋放等清理工作。
對 fixture 的多個測試用例,通常(1)(4)部分代碼都是相似的,CppUnit 在很多地方引入了 setUp 和 tearDown 虛函數。可以在 setUp 函數裏完成(1)初始化代碼,而在 tearDown 函數中完成(4)代碼。具體測試用例函數中只需要完成(2)(3)部分代碼即可,運行時 CppUnit 會自動爲每個測試用例函數運行 setUp,之後運行 tearDown,這樣測試用例之間就沒有交叉影響。
對 fixture 的所有測試用例可以被封裝在一個 CppUnit::TestFixture 的子類(命名慣例是[ClassName]Test)中。然後定義這個fixture 的 setUp 和 tearDown 函數,爲每個測試用例定義一個測試函數(命名慣例是 testXXX)。下面是個簡單的例子:
class MathTest : public CppUnit::TestFixture { protected: int m_value1, m_value2; public: MathTest() {} // 初始化函數 void setUp () { m_value1 = 2; m_value2 = 3; } // 測試加法的測試函數 void testAdd () { // 步驟(2),對 fixture 進行操作 int result = m_value1 + m_value2; // 步驟(3),驗證結果是否爭取 CPPUNIT_ASSERT( result == 5 ); } // 沒有什麼清理工作沒有定義 tearDown. } |
在測試函數中對執行結果的驗證成功或者失敗直接反應這個測試用例的成功和失敗。CppUnit 提供了多種驗證成功失敗的方式:
CPPUNIT_ASSERT(condition) // 確信condition爲真 CPPUNIT_ASSERT_MESSAGE(message, condition) // 當condition爲假時失敗, 並打印message CPPUNIT_FAIL(message) // 當前測試失敗, 並打印message CPPUNIT_ASSERT_EQUAL(expected, actual) // 確信兩者相等 CPPUNIT_ASSERT_EQUAL_MESSAGE(message, expected, actual) // 失敗的同時打印message CPPUNIT_ASSERT_DOUBLES_EQUAL(expected, actual, delta) // 當expected和actual之間差大於delta時失敗 |
要把對 fixture 的一個測試函數轉變成一個測試用例,需要生成一個 CppUnit::TestCaller 對象。而最終運行整個應用程序的測試代碼的時候,可能需要同時運行對一個 fixture 的多個測試函數,甚至多個 fixture 的測試用例。CppUnit 中把這種同時運行的測試案例的集合稱爲 TestSuite。而 TestRunner 則運行測試用例或者 TestSuite,具體管理所有測試用例的生命週期。目前提供了 3 類TestRunner,包括:
CppUnit::TextUi::TestRunner // 文本方式的TestRunner CppUnit::QtUi::TestRunner // QT方式的TestRunner CppUnit::MfcUi::TestRunner // MFC方式的TestRunner |
下面是個文本方式 TestRunner 的例子:
CppUnit::TextUi::TestRunner runner; CppUnit::TestSuite *suite= new CppUnit::TestSuite(); // 添加一個測試用例 suite->addTest(new CppUnit::TestCaller<MathTest> ( "testAdd", testAdd)); // 指定運行TestSuite runner.addTest( suite ); // 開始運行, 自動顯示測試進度和測試結果 runner.run( "", true ); // Run all tests and wait |
對測試結果的管理、顯示等功能涉及到另一類對象,主要用於內部對測試結果、進度的管理,以及進度和結果的顯示。這裏不做介紹。
下面我們整理一下思路,結合一個簡單的例子,把上面說的思路串在一起。
首先要明確測試的對象 fixture,然後根據其功能、流程,以及以前的經驗,確定測試用例。這個步驟非常重要,直接關係到測試的最終效果。當然增加測試用例的過程是個階段性的工作,開始完成代碼後,先完成對功能的測試用例,保證其完成功能;然後對可能出錯的部分,結合以前的經驗(比如邊界值測試、路徑覆蓋測試等)編寫測試用例;最後在發現相關 bug 時,根據 bug 完成測試用例。
比如對整數加法進行測試,首先定義一個新的 TestFixture 子類,MathTest,編寫測試用例的測試代碼。後期需要添加新的測試用例時只需要添加新的測試函數,根據需要修改 setUp 和 tearDown 即可。如果需要對新的 fixture 進行測試,定義新的 TestFixture 子類即可。注:下面代碼僅用來表示原理,不能編譯。
/// MathTest.h // A TestFixture subclass. // Announce: use as your owner risk. // Author : liqun ([email protected]) // Data : 2003-7-5 #include "cppunit/TestFixture.h" class MathTest : public CppUnit::TestFixture { protected: int m_value1, m_value2; public: MathTest() {} // 初始化函數 void setUp (); // 清理函數 void tearDown(); // 測試加法的測試函數 void testAdd (); // 可以添加新的測試函數 }; /// MathTest.cpp // A TestFixture subclass. // Announce: use as your owner risk. // Author : liqun ([email protected]) // Data : 2003-7-5 #include "MathTest.h" #include "cppunit/TestAssert.h" void MathTest::setUp() { m_value1 = 2; m_value2 = 3; } void MathTest::tearDown() { } void MathTest::testAdd() { int result = m_value1 + m_value2; CPPUNIT_ASSERT( result == 5 ); } |
然後編寫 main 函數,把需要測試的測試用例組織到 TestSuite 中,然後通過 TestRuner 運行。這部分代碼後期添加新的測試用例時需要改動的不多。只需要把新的測試用例添加到 TestSuite 中即可。
/// main.cpp // Main file for cppunit test. // Announce: use as your owner risk. // Author : liqun ([email protected]) // Data : 2003-7-5 // Note : Cannot compile, only for study. #include "MathTest.h" #include "cppunit/ui/text/TestRunner.h" #include "cppunit/TestCaller.h" #include "cppunit/TestSuite.h" int main() { CppUnit::TextUi::TestRunner runner; CppUnit::TestSuite *suite= new CppUnit::TestSuite(); // 添加一個測試用例 suite->addTest(new CppUnit::TestCaller<MathTest> ( "testAdd", testAdd)); // 指定運行TestSuite runner.addTest( suite ); // 開始運行, 自動顯示測試進度和測試結果 runner.run( "", true ); // Run all tests and wait } |
按照上面的方式,如果要添加新的測試用例,需要把每個測試用例添加到 TestSuite 中,而且添加新的 TestFixture 需要把所有頭文件添加到 main.cpp 中,比較麻煩。爲此 CppUnit 提供了 CppUnit::TestSuiteBuilder,CppUnit::TestFactoryRegistry 和一堆宏,用來方便地把 TestFixture 和測試用例註冊到 TestSuite 中。下面就是通常的使用方式:
/// MathTest.h // A TestFixture subclass. // Announce: use as your owner risk. // Author : liqun ([email protected]) // Data : 2003-7-5 #include "cppunit/extensions/HelperMacros.h" class MathTest : public CppUnit::TestFixture { // 聲明一個TestSuite CPPUNIT_TEST_SUITE( MathTest ); // 添加測試用例到TestSuite, 定義新的測試用例需要在這兒聲明一下 CPPUNIT_TEST( testAdd ); // TestSuite聲明完成 CPPUNIT_TEST_SUITE_END(); // 其餘不變 protected: int m_value1, m_value2; public: MathTest() {} // 初始化函數 void setUp (); // 清理函數 void tearDown(); // 測試加法的測試函數 void testAdd (); // 可以添加新的測試函數 }; /// MathTest.cpp // A TestFixture subclass. // Announce: use as your owner risk. // Author : liqun ([email protected]) // Data : 2003-7-5 #include "MathTest.h" // 把這個TestSuite註冊到名字爲"alltest"的TestSuite中, 如果沒有定義會自動定義 // 也可以CPPUNIT_TEST_SUITE_REGISTRATION( MathTest );註冊到全局的一個未命名的TestSuite中. CPPUNIT_TEST_SUITE_NAMED_REGISTRATION( MathTest, "alltest" ); // 下面不變 void MathTest::setUp() { m_value1 = 2; m_value2 = 3; } void MathTest::tearDown() { } void MathTest::testAdd() { int result = m_value1 + m_value2; CPPUNIT_ASSERT( result == 5 ); } /// main.cpp // Main file for cppunit test. // Announce: use as your owner risk. // Compile : g++ -lcppunit MathTest.cpp main.cpp // Run : ./a.out // Test : RedHat 8.0 CppUnit1.8.0 // Author : liqun ( a litthle modification. [email protected]) // Data : 2003-7-5 // 不用再包含所有TestFixture子類的頭文件 #include <cppunit/extensions/TestFactoryRegistry.h> #include <cppunit/ui/text/TestRunner.h> // 如果不更改TestSuite, 本文件後期不需要更改. int main() { CppUnit::TextUi::TestRunner runner; // 從註冊的TestSuite中獲取特定的TestSuite, 沒有參數獲取未命名的TestSuite. CppUnit::TestFactoryRegistry ®istry = CppUnit::TestFactoryRegistry::getRegistry("alltest"); // 添加這個TestSuite到TestRunner中 runner.addTest( registry.makeTest() ); // 運行測試 runner.run(); } |
這樣添加新的測試用例只需要在類定義的開始聲明一下即可。
通常包含測試用例代碼和被測試對象是在不同的項目中。應該在另一個項目(最好在不同的目錄)中編寫 TestFixture,然後把被測試的對象包含在測試項目中。
對某個類或者某個函數進行測試的時候,這個 TestFixture 可能引用了別的類或者別的函數,爲了隔離其他部分代碼的影響,應該在源文件中臨時定義一些樁程序,模擬這些類或者函數。這些代碼可以通過宏定義在測試項目中有效,而在被測試的項目中無效。