天天看點

《實時控制軟體設計》之單元測試

《建構之法》第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作為測試架構程式不需要随着單元測試數量的變化進行代碼的修改,可以自動執行并生成測試結果報告,這裡也充分展現了設計模式的威力。