C++單元測試一:並非看上去那麼簡單——幾個很實際的問題
理想與現實
為Java和C#做單元測試,基本上都有比較統一的工具、模式可用,IDE的支援也非常到位;可是到了C++這裡,一切就變的那樣的“不走尋常路”,各種單元測試框架盛行,例如CppUnit, CppUnitLite, CxxUnit,Google Test等等,以及微軟在VS2012中也加入了對原生C++程式碼單元測試的支援MSTest。面對如此諸多的測試框架,選擇哪一個其實無所謂,只要順手就好,用習慣了,什麼都好;因為,單元測試的程式碼總歸還是要自己來寫——無論你用哪一個框架。
以前寫過不少程式碼,可是都沒怎麼注意單元測試,現在終於認真對待起來,就開始在網路上搜尋資料,看看各種框架下的單元測試如何寫。幸運的是,這方面的資料真不少,很多框架也都會帶有示例或者文件告訴你怎麼寫;不幸的是,這些文件或者示例都太遠離工程實踐,他們一般遵循一個這樣的模式:寫出一個待測類CMyClass,定義一定的成員變數和方法,然後給出針對CMyClass的測試類如CMyClassTest;呵呵,看起來示例是夠好了,可是很少涉及到這樣的實際問題:
- 工程實踐中,一個專案往往有很多類,而且相互之間,總有這或多或少的依賴、包含等關係;
- 實際的測試專案,該如何組織被測程式碼和測試程式碼(注意,這裡所說的組織不是指單元測試資料如何組織,而是指工程中的測試程式碼和產品程式碼的組織)
- 被測程式碼如何引入測試工程中
- 一個測試工程如何測試多個被測類
實際的程式碼
好吧,我們來看一下一個“較為”實際的工程以及我在為該工程編寫單元測試過程中所遇到的問題;該工程的程式碼來自於boost.asio中的一個示例程式碼:
我把該cpp中的幾個類分拆開了,放在一個VisualStudio 2012工程中,程式碼結構看起來是:
其中幾個主要類Message,ChatSession,ChatRoom之間的關係如下圖:
在我做單元測試過程中,首先從軟柿子Message入手,然後為ChatRoom寫UT。所以,先把這兩個類的相關程式碼貼上來;其實貼不貼程式碼無關緊要,上面的類圖已經可以說明問題,不過為了方便較真,還是貼出來吧。
Message類程式碼
/// Message.h #pragma once /// class represents the message transferred between the client and the server /// the message consists of two part: header + message body /// the header part, 4 bytes, stores the length of the message body /// the max length of the message box is : 512 class Message { public: enum { HeaderLength = 4, MaxBodyLength = 511 }; public: Message(void); ~Message(void); public: void EncodeHeader(); bool DecodeHeader(); const char* Data() const; char* Data() ; const char* Body() const; char* Body() ; int SetData(const char* src, const int srclength); int Length() const; int BodyLength()const; void Reset(); private: void CheckBodyLength(); private: /// stores the whole message char Data_[HeaderLength + MaxBodyLength + 1]; /// the body length int BodyLength_; }; /// Message.cpp #include "Message.h" #include <cstdio> #include <boost/lexical_cast.hpp> #include <algorithm> #include "TraceLog.h" Message::Message(void) { Reset(); } Message::~Message(void) { } void Message::CheckBodyLength() { BodyLength_ = BodyLength_ > MaxBodyLength ? MaxBodyLength : BodyLength_; } void Message::EncodeHeader() { /// Check the body length CheckBodyLength(); /// wirte the body length to the message header /// we make sure that the buffer is enough after we call CheckBodyLength() ::_snprintf_s( Data_, HeaderLength, HeaderLength, "%d", BodyLength_ ); } bool Message::DecodeHeader() { int bodyLength = 0; bool ret = false; /// get the message body length from the message try { char buf[HeaderLength + 1] = ""; std::strncat( buf, Data_, HeaderLength ); bodyLength = boost::lexical_cast<int> (buf); if( bodyLength > MaxBodyLength ) { bodyLength = MaxBodyLength; } else { ret = true; } } catch(boost::bad_lexical_cast& e) { /// cast error happens bodyLength = 0; TraceLog::WriteLine("Message::DecodeHeader(),error:%s, orinal message:%s", e.what(), Data_ ); } /// set the value and return BodyLength_ = bodyLength; return ret; } char* Message::Data() { return Data_ ; } const char* Message::Data() const { return Data_ ; } char* Message::Body() { return Data_ + HeaderLength; } const char* Message::Body() const { return Data_ + HeaderLength; } int Message::SetData(const char* src, const int srclength) { /// check the length of source int length = srclength; if( length > MaxBodyLength ) { length = MaxBodyLength; } /// copy the data into the local buffer /// std::snprintf is unavailable in this c++ compiler int ret = ::_snprintf_s(Data_+HeaderLength, MaxBodyLength + 1, length, "%s", src ); /// set the length of the message body BodyLength_ = length; /// return the length of copied return ret; } int Message::Length() const { return BodyLength_ + HeaderLength; } int Message::BodyLength() const { return BodyLength_; } void Message::Reset() { BodyLength_ = 0; /// just for using the lamda std::for_each(Data_, Data_ + HeaderLength + MaxBodyLength + 1, [](char& p) { p = '\0'; } ); }
ChatRoom類程式碼
/// ChatRoom.h
#pragma once
#include "ChatSession.h"
#include "Message.h"
#include <set>
#include <queue>
/// class that manages the clients
/// deliver the messages from one client to the others
class ChatRoom
{
public:
ChatRoom(void);
~ChatRoom(void);
public:
/// a client joins in the room
void Join(ChatParticipantPtr participant);
/// a client leaves the room
void leave(ChatParticipantPtr participant);
/// deliver the message from one client to all of the users in the room
void Deliver(const Message& msg);
private:
/// all of the participants are stored here
std::set<ChatParticipantPtr> Participants_;
/// recent messages
/// questions, how to synchronize this object in threads
typedef std::deque<Message> MessageQueue;
MessageQueue RecentMessages_;
enum { MaxRecentMsgs = 100 };
};
/// ChatRoom.cpp
#include "ChatRoom.h"
#include <boost/bind.hpp>
#include <algorithm>
#include "TraceLog.h"
ChatRoom::ChatRoom(void)
{
}
ChatRoom::~ChatRoom(void)
{
}
/// a client joins in the room
void ChatRoom::Join(ChatParticipantPtr participant)
{
TraceLog::WriteLine("ChatRoom::Join(), a new user joins in");
/// add into the queue
Participants_.insert( participant );
/// sending the recent message to the client
std::for_each(RecentMessages_.begin(), RecentMessages_.end(),
boost::bind( &ChatParticipant::Deliver, participant, _1 ) );
}
/// a client leaves the room
void ChatRoom::leave(ChatParticipantPtr participant)
{
TraceLog::WriteLine("ChatRoom::leave(), a user leaves");
/// remove it from the queue
Participants_.erase( participant );
}
/// deliver the message from one client to all of the users in the room
void ChatRoom::Deliver(const Message& msg)
{
TraceLog::WriteLine("ChatRoom::Deliver(), %s", msg.Body() );
/// add the msg to queue
RecentMessages_.push_back( msg );
/// check the length
while( RecentMessages_.size() > MaxRecentMsgs )
{
RecentMessages_.pop_front();
}
/// deliver the msg to clients
std::for_each(Participants_.begin(), Participants_.end(),
boost::bind( &ChatParticipant::Deliver, _1, boost::ref(msg) ) );
}
開始單元測試
由於到手了VisualStudio 2012,這貨已經原始支援了C++Native程式碼的單元測試,就用這貨開始做UT吧。
如何引入被測程式碼
好了,我們開始單元測試。首先建立一個C++單元測試的工程,這個很easy。接著我們就要讓測試工程能夠“看到”被測的程式碼,這如何搞呢?有這樣幾種方法:
- 如果被測程式碼是靜態庫或者動態庫,包含對應的.h檔案,讓測試工程連結DLL及LIB,這樣測試工程。
- 或者,讓測試工程連結對應的obj檔案,直接編譯進測試工程
- 或者,直接把被測是的程式碼,如上述的Message.h和Message.cpp包含進測試工程(注意這裡不要拷貝一份Message.h和Message.cpp,用“Add->ExsitingItem”將他們包含進去,這樣只保留一份程式碼)
- 或者在單元測試程式碼檔案,如TestMessage.cpp中直接用#include把Message.h和Message.cpp包含進來,如:
#include "../ChatroomServer/ChatRoom.h"
#include "../ChatroomServer/ChatRoom.cpp"
上面這幾種方法,其實原理都是一樣的,反正就是讓測試工程能夠看到到被測的程式碼,我們使用把被測程式碼引入測試工程的方法,這樣測試工程的程式碼結構看起來是這樣:
Ok,現在在測試工程裡面,可以看到Message類的宣告和定義了,然後你的單元測試程式碼,該怎麼寫,就怎麼寫了。
一個測試工程只能測一個類嗎?
使用VS2012中的單元測試框架,寫完了對Message的的單元測試,在TestExplorer中RunAll,一切正常;好了,至此,一切還算順利,那我們繼續吧,來對ChatRoom類編寫單元測試;
繼續按照前面的方法,我們很容易讓測試工程看到ChatRoom的被測程式碼;然而從ChatRoom的實現來看,這個類和Message類有著關聯關係,而且在ChatRoom的方法中,也的確使用了Message類的方法,從單元測試的角度來看,我們應該將他們倆之間的關係隔斷,從而保證我們只對ChatRoom進行測試,那這樣,我們就需要Mock一份Message的實現。
可是問題來了,由於之前我們在測試Message類的時候,已經引入了Message.cpp檔案,使得測試工程中,已經有了一份Message的實現,那麼我們如何再能為Message提供一份“偽”實現呢??(使用其他幾種引入方式,結果都是一樣的)
是的,慣用的DependencyInjection在這裡不起作用。查了不少資料,也沒找到一個像樣的說明如何解決這個問題;現在唯一可以採用的,就是在一個測試工程裡面,只測試一個被測類,雖然可以工作,但是,未免又過於繁瑣和“愚蠢”,那聰明的方法,又是什麼呢?不瞞你說,這正是目前困擾我的問題之一。
追加一個後記:
其實,在關於如何為Message提供一份“偽”實現的問題上,原來想法是在測試工程中包含Message的標頭檔案,然後在測試工程裡面,直接寫Message::Message()等方法,事實上是在測試工程裡面定義一個Message的實現;這樣由於我們已經引入了Message的真正實現,從而必然導致連結器報符號重複定義的錯誤,因此,這種思路並不可行,故而強迫我去建立另外一個工程;後來想一想,其實也不必真的去建立一個新工程,他們是可以在一個工程裡面完成的,方法是新建一個MockMessage類,讓他從Message繼承下來,然後重新定義自己想Mock的方法就可以了。但是,這種方法創建出來的Mock,還是真的Mock嗎,他首先已經是一個“真”的Message了啊?