《建構之法》第2章讨論了個人技術和流程,一個複雜的軟體都是由若幹個子產品組成的,提高個人的技戰術能力首先從編寫一個穩定的子產品開始,最基本的子產品就是一個類。要保證自己送出的子產品代碼的品質得到保證,需要進行單元測試。2.1.2節給出了好的單元測試的标準:
- 在最基本的功能/參數上驗證程式的正确性。
- 必須由最熟悉代碼的人(程式作者)來寫。
- 單元測試之後,機器狀态保持不變。
- 單元測試要快。
- 單元測試應該産生可重複、一緻的結果。
- 單元測試的獨立性--不依賴于别的測試。
- 單元測試應該覆寫所有代碼路徑。
- 單元測試應該內建到自動測試的架構中。
- 單元測試必須和産品代碼一起儲存和維護。
參見單元測試 & 回歸測試
本課程作業都基于C++代碼編寫,是以單元測試工具我們選用CPPUNIT,下面我們介紹使用方法。
要使用CPPUNIT庫進行單元測試,首先需要在你的開發環境中安裝CPPUNIT庫,比如在Ubuntu Linux環境下,隻需要執行如下指令
sudo apt-get install libcppunit-dev
就會把CPPUNIT的庫檔案及頭檔案安裝到Linux的系統目錄中,接下來在自己的測試代碼中隻要include相關的頭檔案,直接使用CPPUNIT函數了。
無論采用何種編譯環境,總可以通過下載下傳CPPUNIT的源代碼(下載下傳位址)進行編譯和編譯環境内的路徑參數設定來完成環境配置。
可以通過編譯如第1個最簡單的例程來測試CPPUNIT是否安裝好:
//mytest.cpp
#include <iostream>
#include <cppunit/TestCase.h>
class MyTest:public CppUnit::TestCase
{
public:
MyTest(std::string name): CppUnit::TestCase(name){}
void runTest()
{
CPPUNIT_ASSERT(1 == 1);
CPPUNIT_ASSERT_DOUBLES_EQUAL(2.11, 2.13, 0.01);
}
};
int main()
{
MyTest test1("Test1_Name");
std::cout << "This is the test: " << test1.getName() << std::endl;
std::cout << "The test has number:" << test1.countTestCases() << std::endl;
test1.runTest();
return 0;
}
在Ubuntu下可以用指令行編譯該程式:
g++ -o mytest mytest.cpp -I/opt/local/include -L/opt/locallib -lcppunit -ldl
編譯通過後運作該程式,會顯示結果如下:

接下來我們分析下mytest.cpp的代碼,裡面定義了一個測試用例類MyTest,繼承自CPPUNIT的TestCase類,并在runTest中定義了兩個測試宏:
CPPUNIT_ASSERT(1 == 1);
CPPUNIT_ASSERT_DOUBLES_EQUAL(2.11, 2.13, 0.01);
第一個是調用CPPUNIT_ASSERT(condition),判斷condition是否為真。
第二個是調用CPPUNIT_ASSERT_DOUBLES_EQUAL(expected, actual, delta),判斷兩個浮點數expected和actual之間的差是否大于delta。
在主程式中建立MyTest的執行個體test1,并執行測試函數test1.runTest()。運作結果顯示第二項測試沒有通過。
上面是最簡單的單元測試代碼,但實際的單元測試往往不是直接使用 TestCase類,而是用TestFixture類,TestFixture類擁有TestSuite,每個TestSuite又可以擁有多個TestCase。下面是第2個示例代碼:
#include <cppunit/extensions/HelperMacros.h>
#include <cppunit/ui/text/TestRunner.h>
class MyTests : public CppUnit::TestFixture
{
CPPUNIT_TEST_SUITE( MyTests );
CPPUNIT_TEST( testAdd );
CPPUNIT_TEST( testEquals );
CPPUNIT_TEST_SUITE_END();
double m_value1;
double m_value2;
public:
void setUp()
{
m_value1 = 2.0;
m_value2 = 3.0;
}
void tearDown()
{
}
void testAdd()
{
double result = m_value1 + m_value2;
CPPUNIT_ASSERT( result == 6.0 );
}
void testEquals()
{
long* l1 = new long(12);
long* l2 = new long(12);
CPPUNIT_ASSERT_EQUAL( 12, 12 );
CPPUNIT_ASSERT_EQUAL( 12L, 12L );
CPPUNIT_ASSERT_EQUAL( *l1, *l2 );
delete l1;
delete l2;
CPPUNIT_ASSERT( 12L == 12L );
CPPUNIT_ASSERT_EQUAL( 12, 13 );
CPPUNIT_ASSERT_DOUBLES_EQUAL( 12.0, 11.99, 0.5 );
}
};
int main()
{
CppUnit::TextUi::TestRunner runner;
runner.addTest( MyTests::suite() );
runner.run();
return 0;
}
分析下這個程式,我們發現在類MyTests的定義中采用了如下宏:
CPPUNIT_TEST_SUITE( MyTests );
CPPUNIT_TEST( testAdd );
CPPUNIT_TEST( testEquals );
CPPUNIT_TEST_SUITE_END();
其作用就是定義了兩個TestCase: testAdd和testEquals,并把這兩個TestCase添加到一個叫MyTests的TestSuite中去。
在主程式中執行個體化了一個TestRunner類對象runner,把MyTests這個測試用例集合添加到runner,然後調用runner.run()就自動執行所有的測試用例了。
從這個程式中,我們也可以體會到面向對象程式設計的一些思想方法。每次具體的單元測試的内容都是變化的,但是單元測試的基本原理和流程是不變的,我們的程式設計應該把不變的部分和變化的部分有效地區隔開來。在上面程式中,TestRunner對象相當于工廠裡的質檢員,他隻按照标準的測試流程工作,是以我們不需要重新定義它,而是直接用CPPUNIT的類定義執行個體化一個對象,但TestRunner具體執行什麼測試取決于我們提供給它什麼測試用例集,是以我們隻需要定義一個具體的測試用例集,并把該測試用例集傳遞給TestRunner,TestRunner通過調用它的标準作業流程run(),去執行每一個測試用例并傳回結果。是以我們看主函數中的三行代碼:
CppUnit::TextUi::TestRunner runner;
runner.addTest( MyTests::suite() );
runner.run();
第1行和第3行都不需要随着測試用例集的變化而變化,隻有在第2行中,要把自定義的測試用例集名傳遞給TestRunner。
為了實作自動化的單元測試,我們希望當開發人員編寫新的子產品并增加或修改單元測試程式時,測試架構程式不随之變化。我們來看看KDL庫裡是如何實作這一點的。
framestest.hpp和framestest.cpp是一個具體的單元測試代碼,裡面具體的測試用例用來測試KDL的frames子產品,如下面的一個測試宏:
CPPUNIT_ASSERT_DOUBLES_EQUAL((R*v).Norm(),v.Norm(),epsilon);
是為了測試一個向量v進行旋轉操作R後是否保持模不變,從數學上講模是完全不變的,但數值運算上肯定有精度問題,這裡用epsilon來測試運算精度是否在epsilon範圍内。
整個代碼結構和上面第2個例程基本類似,稍有不同的是在framestest.cpp的開始部分,有一行代碼
CPPUNIT_TEST_SUITE_REGISTRATION( FramesTest );
在KDL的tests目錄中,有多個與FramesTest類似的單元測試代碼,其結構和風格都類似,如JacobianTest,在其cpp實作檔案中也有一行代碼:
CPPUNIT_TEST_SUITE_REGISTRATION(JacobianTest);
test-runner.cpp則給出了測試架構程式:
#include <cppunit/XmlOutputter.h>
#include <cppunit/extensions/TestFactoryRegistry.h>
#include <cppunit/ui/text/TestRunner.h>
#include <iostream>
#include <fstream>
int main(int argc, char** argv)
{
// Get the top level suite from the registry
CppUnit::Test *suite = CppUnit::TestFactoryRegistry::getRegistry().makeTest();
// Adds the test to the list of test to run
CppUnit::TextUi::TestRunner runner;
runner.addTest( suite );
#ifndef TESTNAME
std::ofstream outputFile(std::string(suite->getName()+"-result.xml").c_str());
#else
std::ofstream outputFile((std::string(TESTNAME)+std::string("-result.xml")).c_str());
#endif
// Change the default outputter to a compiler error format outputter
runner.setOutputter( new CppUnit::XmlOutputter( &runner.result(),outputFile ) );
// Run the tests.
bool wasSucessful = runner.run();
outputFile.close();
// Return error code 1 if the one of test failed.
return wasSucessful ? 0 : 1;
}
主函數中有幾行代碼用于建立了一個log檔案用于記錄單元測試的結果,有兩行代碼
runner.addTest( suite );
bool wasSucessful = runner.run();
和第2個例子是完全相同的,不同的是在第2個例子中,我們需要直接把具體TestSuite名添加到runner中,但在這個架構程式中,使用了一行代碼自動收集KDL所有的單元測試用例添加到suite中:
CppUnit::Test *suite = CppUnit::TestFactoryRegistry::getRegistry().makeTest();
這個就是設計模式中的工廠方法模式,每個具體的單元測試類通過CPPUNIT_TEST_SUITE_REGISTRATION宏把自己注冊到工廠類中去,然後架構程式通過工廠類的方法自動建立所有的測試用例集對象,在項目開發過程中,test-runner.cpp作為測試架構程式不需要随着單元測試數量的變化進行代碼的修改,可以自動執行并生成測試結果報告,這裡也充分展現了設計模式的威力。