各位都是怎麼進行單元測試的?

1. 大牛們都是怎麼測的,要用什麼工具嗎?

2. 對於 GUI 的部分一般怎麼測?

3. 想系統的從零開始學學 UnitTest 應該怎麼做?


工作中寫C++,不敢自稱大神,也來斗膽分享(安利)一下經常使用的單元測試框架。

大家都對Google的C++ Style很熟悉了,但除了Coding Style之外,Google還有自己的單元測試框架:gtest (Google Test)和gmock (Google Mock)。

簡介gtest的英文blog Unit Testing C++ with Google Test - ReSharper C++ Blog,英文好的騷年可以直接食用,如果大家確實很需要,我也可以抽空翻譯一個。

相關的GitHub鏈接:google/googletest 可以閱讀源碼,check out下來使用。

————————正片————————

在Google工作,尤其是寫C++的程序員,常常離不開寫單元測試。所幸的是,Google提供了很成熟,也很使用的單元測試框架gtest和gmock。

gmock是gtest的一部分,也可以說是gtest中比較advanced的topic。至於使用場景(避免在單元測試代碼中向production服務發送rpc啦,讀取production的資源啦,繞過一些production環境中必須的許可權來測試代碼的邏輯啦)會在後面舉例說明。

正式開始寫長一點的C++程序之後,就會逐漸萌發出寫unit test來保證代碼正確性的需求。例如,一開始寫的單元測試可能是這樣的。有個函數實現加法:

int Add(int x, int y);

我們最開始會使用assert來做一些基本的測試:

assert(2 == Add(1, 1)); // 正常的用例

assert(1 != Add(1, 1)); // 異常的用例

assert(sum &> Add(num1, num2)); // 溢出異常?如果num1和num2都是正整數的前提下,sum應該blablabla

其實在我們一開始學習寫單元測試的時候,老師或者有人生經驗的資深碼農就已經告訴了我們寫單元測試的一些原則:

1)測試用例能驗證函數的正確性(這條都通不過就……);

2)測試用例儘可能涵蓋邊界條件(例如遍歷一個鏈表,頭指針是空,只有一個節點,鏈表有N個節點,N是問題描述下允許的最大節點數等等);

3)一些異常和錯誤處理(例如往一個函數里傳入空指針,傳入空串,這個函數能否列印一些log,返回錯誤碼,實現加法的Add函數如何檢測和處理溢出等等)

最理想的情況下(時間很多!),應該盡量多寫測試用例,以保證代碼功能的正確性符合預期,具有良好的容錯性。如果代碼較複雜,條件分支較多,測試用例最好能覆蓋所有的分支路徑。

上述原則一般解決了很多「應該測試什麼」的問題。

工作了一些年頭,我覺得單元測試起到的最重要作用其實是:讓人在修改代碼之後能感到安心,踏實(單元測試跑過之後能比用飄柔更自信)。只要跑一把單元測試,就能自動化驗證程序邏輯的正確性,而無需在提交代碼之前提心弔膽、擔心會漏掉什麼情況沒有處理或者自己新加入的邏輯製造了bug。

其實,寫單元測試是很繁瑣的。因為要考慮的瑣碎的東西其實很多,有時候為了方便,可能還要修改原來已經寫好的介面(沒錯,我菜,我沒有先寫測試再寫實現,我不fashion,我沒貫徹test driven development原則,我活該_(:з」∠)_)。

所以,為了能在寫單測的時候可以偷點懶,也為了代碼讀起來舒服一點,gtest框架提供了很多宏

bool IsAI(const string name);

assert (false == IsAI("vczh")); // 不Fashion!

EXPECT_TRUE(IsAI("Chen meng meng")); // Fashion! 而且有比較直白的英語描述,可讀性up

諸如此類的還有:

EXPECT_NE(男朋友愛你, 隨時時刻都知道你在想什麼); // 真理,男朋友愛你真的不等於他就是你肚子里的蛔蟲,想要他買Chanel包包給你做禮物而不是勸你多喝熱水的話還是得開口直說!

EXPECT_GT(x, y); // x &> y

EXPECT_EQ("光頭能對空", GanSi("黃旭東"));

EXPECT_THAT(actual_proto_message, EqualsTo(expected_proto_message)); // EXPECT_THAT(value, matcher). 可以比較一些複雜一點的數據結構例如proto buf,vector里的內容:

vector& numlist1;

vector& numlist2;

....

// In test

EXPECT_THAT(numlist1, ::testing::ContainerEq(numlist2));

相反,如果寫成這樣就顯示不出我們fashion的一面了:

for (size_t i = 0; i &< numlist1.size(); ++i) {

assert(numlist1[i] == numlist[2]);

}

如果numlist1和numlist2里的元素的順序是亂的,上面那種寫法就不可行了。

有人問,你這樣寫也只不過是看起來好看罷了,還要多打幾個字元,沒什麼必要吧?這正是為什麼我們要使用gtest這個框架。因為框架給我們做了額外的事。試想下,如果測試掛了:

http://ai.cc:233 assert("錢贊企永不為奴", GanSi("黃旭東"));

core dump里也就只告訴你測試掛在了http://ai.cc這個文件的第233行罷了。但如何你使用的是:

EXPECT_EQ("錢贊企永不為奴", GanSi(「黃旭東」));

gtest就會告訴你:

[RUN ] XieXingTest.GanSi

http://SC2_test.cc:233 Failure

Value Of: GanSi(「黃旭東」)

Actual: "光頭能對空"

Expected: 「 錢贊企永不為奴」

[ Failed] ....

又例如:

http://www.slothparadise.com/wp-content/uploads/2016/08/SP-Test-6-300x202.png

高清無碼,4K全彩錯誤提示,你不僅知道哪個test case掛了,你還能知道當前輸出是啥,就不用再慢慢打log看了。

框架幫我們做了一些瑣碎的事情,我們就能提高工作效率,實現work life balance(提早下班,刪掉)。

而且在使用上,使用gtest也很方便快捷,只需要(不準確,只是大概這樣):

#include & // 當然偷懶也要include頭文件,按照基本【嗶~】

TEST(XieXing, GanSi) {

EXPECT_EQ(...);

EXPECT_NE(...);

...

}

int main(int ac, char* av[]){ testing::InitGoogleTest(ac, av); return RUN_ALL_TESTS();} // 在Google里,在BUILD文件(類似Make)中鏈接一個額外的gtest main的庫,還能省掉手寫main函數,輕鬆加愉快。

————————————GMock——————————————

簡單說說GMock

有些代碼,總會有些複雜的依賴。例如使用了某框架的API來發送RPC請求,讀取雲上的資源BigTable或者需要連接到某個服務請求一些許可權獲得一個token等等。

這些邏輯會產生一些IO和網路上的開銷,在production中使用無可厚非,但如果每個人寫代碼跑單元測試都要調用這些邏輯,從而又產生很多沒什麼實際意義的開銷(例如發送一些dummy的RPC請求),這對生產環境的資源會造成一定量的浪費。

例如有個類,叫TableManager,他的功能包括在雲端創建一張數據表,獲得一些表的屬性,支持對錶進行查找。一些代碼作為client使用了這個類,在雲端創建表和存取一些數據。但單元測試代碼並不希望真的去讀寫那些在生產環境里的資源,這時候就需要為TableManager類創建一個對應的Mock類來「繞過」生產環境了。

class TableManager {

public:

// 創建一個名為「table_name"的表,成功就返回true。如果table_name不合法,該表已經存在(這個方法已經被調用過了)或者TableManager本身沒有在這個方法調用之前獲得合法的token,創建表的請求被拒絕,這個方法就會返回false。

bool CreateTable(const string table_name);

private:

// 這個方法會發送一個RPC請求到服務端,根據some_params的內容獲得一個token,這樣客戶端代碼在調用這個類的其他關於操作table的方法的時候,才能獲得授權,對table資源進行操作。

void GetAuthorizeToken(const IDParams some_params);

// 如果token串可用(非空)就返回true。但在測試環境下並沒有真的去獲取token,所以token一定是空的,這樣CreateTable無論如何都不會返回true了,所以一會兒要做點tiny work。

bool IsTokenReady();

string token_;

};

我們不想真的去訪問production(生產環境)里的資源,這時我們就可以先創建一個Mock類:

#include "gmock/gmock.h" // 使用 Google Mock.

class MockTableManager : public TableManager {

public:

MOCK_METHOD1(GetAuthorizeToken, string(const IDParams some_params)); // 有一個參數的方法

MOCK_METHOD1(CreateTable, bool(const string table_name));

MOCK_METHOD0(IsTokenReady, bool()); // 0個參數的方法,METHOD0

};

一個使用TableManager的類的方法在準備數據,當數據全部準備之後,就會創建一張新表:

bool ClientClass::ProcessData(const string src_path, const string table_name, const string id) {

// Prepare data from src_path

...

// When preparation is done, store the data to the table.

IDParams params;

params.set_id(id);

... // Fill other fields

table_manager_.CreateTable(table_name);

}

在單元測試裡面,測試用例就可以這麼寫

TEST_F(ClientClassTest, ProcessData) { // 測試ClientClass里的ProcessData方法

// 初始化工作...

// 正片開始了!我們知道ProcessData方法會調用CreateTable,而CreateTable會先調用GetAuthorizeToken去獲得一個token,然後用IsTokenReady來驗證一下token是否可用,再做愛做的 事情,嗯嗯。

EXPECT_CALL(mock_table_manager, GetAuthorization(params)).Times(AtLeast(1)); // 在接下來的測試里,GetAuthorization至少被調用一次。

EXPECT_CALL(mock_table_manager, IsTokenReady()).WillRepeatedly(Return(true)); // 由於並沒有真正發rpc請求獲取token,token將是空的,但為了測試,經研究決定,就讓你「Ready(成為美利堅合眾國大總統,咦!?)」。

EXPECT_CALL(mock_table_manager, CreateTable("valid_table_name")).WillOnce(Return(true)).WillRepeatedly(Return(false)); // 在這個case里,第一次調用CreateTable肯定是成功的,因為我們從未創建表,操作會成功,當再調用一次就會返回失敗,因為理論上這張表已經創建過了。

// 準備好了,開車!

EXPECT_TRUE(client_class_obj.ProcessData("dummy_path", "valid_table_name", "1024_good_man_whole_life_safe")); // 沒問題,這裡測試的期望結果是true,因為是第一次創建表,應該會成功。換言之如果測試在這裡失敗了,就該看看有沒有bug了。

// 假設我們作死再調用一次,這時候應該失敗,因為相同更多表已經創建過了。

EXPECT_FALSE(client_class_obj.ProcessData("dummy_path", "valid_table_name", "1024_good_man_whole_life_safe"); // 如果實際上ProcessData返回的是true,那就得懷疑一下人生了,因為跟我們想好的不一樣啊!說好的「valid_table_name」只能被創建一次呢!?這種bug出現的原因有很多,一些時候可能純粹是因為手抖都copy了一行CreateTable。

}

Mock了TableManager類之後,我們既能測試代碼的功能,又能避免浪費生產環境的資源,是不是有點小愉快?

Google內部有很多功能豐富的開發工具和庫,而且文檔齊全,經得起歲月的考驗,很多優秀的工具和框架都已經開源(例如GTest框架,Protocol Buffers,GRPC等等),實在是居家旅行,殺人越貨,開礦推塔,仁義無雙,優勢很大,吃肉人族,上天入地的必備佳品。

作為一隻菜雞,東西寫得不是很好,就做一點微小的工作,分享給大家,謝謝各位 :D


單元測試,或者更大一些的自動化測試,對提高軟體質量是有很大幫助的。通過一系列預先設計的規則,就可以覆蓋大量的測試點。尤其是對重構一類的任務,確保修改前後系統行為不變很重要,而修改後的回歸測試工作量又極其繁重,此時單元測試,或者自動化測試就能體現出無以倫比的效率。

我在2005年學Python不久,就鬱悶於自己那點代碼手工測試很麻煩,恰好那時得知了很多Python工程師有做單元測試的習慣,於是就學習了一下,果然效果卓群。後來又經過數年整理出自己的一套單元測試的規範。

我做過的各類Python項目,代碼總量的50%左右是單元測試。經過這個級別的單元測試覆蓋,確保了底層函數基本不會出錯,這樣高層功能的調試才更方便。同時也是這個覆蓋程度確保了,被測試工程師發現bug的可能性已經很低了。

我給自己的單元測試設置了5個級別:

1. Level1:正常流程可用,即一個函數在輸入正確的參數時,會有正確的輸出

2. Level2:異常流程可拋出邏輯異常,即輸入參數有誤時,不能拋出系統異常,而是用自己定義的邏輯異常通知上層調用代碼其錯誤之處

3. Level3:極端情況和邊界數據可用,對輸入參數的邊界情況也要單獨測試,確保輸出是正確有效的

4. Level4:所有分支、循環的邏輯走通,不能有任何流程是測試不到的

5. Level5:輸出數據的所有欄位驗證,對有複雜數據結構的輸出,確保每個欄位都是正確的

如上的單元測試分級是我2007年整理出來的,後來在我做的各種項目中,一般只做到Level2,重要系統或者底層服務,要做到Level3或Level4。而很少做到Level5。即便如此,就已經實現了如上所說的,很難被測試工程師發現bug。

除了級別外,測試方法也要區分不同系統的玩法。比如基於WEB的系統,就需要確保單元測試里可以模擬發送請求,這個一般是WEB框架提供支持的。比如我常用的web.py、Flask、Django都有支持。不僅僅可以模擬簡單的請求,還可以模擬POST、cookie等。另外一般建議單獨寫個函數來模擬登錄過程,這樣系統登錄後行為的測試就不必反覆模擬登錄了。

單元測試一大痛苦是構造測試數據。我的看法是測試數據應該是人造的,而不是隨便從產品環境dump出來一份。只有人造的數據能確保環境可控,每次運行不會因為環境改變而頻繁修改testcase。我的常用玩法是測試數據分為基礎數據和附加數據兩部分。基礎數據是所有testcase共享的,比如建立幾個常用角色的用戶等等。附加數據是testcase內部自己建立的。這樣每次testcase運行時,先清空資料庫,導入基礎數據,導入附加數據,然後執行測試,驗證結果。

各類程序的函數可以分為純函數和副作用函數。純函數對應的是數學裡函數的概念,輸出和輸入是一一對應的。對一個輸入有確定的輸出。比如1+1=2。而副作用函數則相反,同樣的輸入,在不同時間和環境里,可能有不同的輸出。比如任何涉及IO、網路、資料庫的。副作用函數的測試比純函數麻煩的多,因為你必須要完整的構造其所依賴的所有環境,才能夠復現一個副作用函數的行為。也正因為如此,副作用函數出bug的概率比純函數高的多。理解這個概念以後,應該儘可能的把程序里的純函數和副作用函數進行拆解,降低副作用函數的比例和邏輯複雜度。還有,副作用函數是會傳染的,一個函數如果調用了副作用函數,那麼它也會變成副作用函數。


單元測試的好壞在於「單元」而不在「測試」。如果一個系統毫無單元可言,那就沒法進行單元測試,幾乎只能用Selenium做大量的E2E測試,其成本和穩定性可想而知。科學的單元劃分可以讓你擺脫mock,減少依賴,提高並行度,不依賴實現/易重構,提高測試對業務的覆蓋率,以及易學易用,大幅減少測試代碼。

最好的單元是返回簡單數據結構的函數:函數是最基本的抽象,可大可小,不需要mock,只依靠傳參。簡單數據結構可以判等。 最好的測試工具是Assert.Equal這種的:只是判等。判等容易,判斷發生了什麼很難。你可以看到後面對於DOM和非同步操作這些和副作用相關的例子都靠判等測試。把作用冪等於數據,拿到數據就一定發生作用,然後再測數據,是一個基本思路。

首先,函數是個好東西,測函數不等同「測1+1=2」這種沒營養的單元,函數是可以包含很大上下文的。這種輸入輸出的模型既簡單又有效。

我們消滅了mock,減少了依賴,並發了測試,加快了速度,降低了門檻,減少了測試路徑等等。如果你的React項目原來在TDD的邊緣搖擺不定,現在是時候入一發這種唯快不破了。

全家桶讓Model/View/Async這三者之間的邊界變得清晰,任由業務變更,它們之間的職責是不會互相替代的,這樣你測它們的時候才更容易。後端之所以測試穩定是因為有API。所以想讓前端好測也是一樣的思路。

冪等可以讓你減少測試的case,寫代碼更有底氣。拋開測試不談,代碼冪等的地方越多,程序越可控可預期。其實仔細思考一下我們的實際項目,大部分業務都是非常確定的,並沒有什麼隨機因素。為什麼最後還是會出現很多隨機現象呢?

聲明優於命令,描述發生什麼、想要什麼比親自指導具體步驟好。

消息機制優於調用機制。Smalltalk &> Simula。其實RESTful API一定程度上也是消息。簡單的對象直接互相作用是完全沒問題的,人作為複雜對象主要通過語言媒介來交流,聽到內容思考其中的含義,而不是靠肢體接觸,或者像連體嬰兒那樣共享器官。所以才有一句俗語叫「你的對象都想成長為Actor」。

從View的幾種測試里我們也可以看到,測試並不是只有測或者不測這兩種選擇,我們老提測試金字塔,意思是測試可多可少,不同層級的測試保持正金字塔形狀比較健康,像今天我們說的就可以大幅加寬你測試金字塔的底座。所以你的項目有可能測試過少,也可能測試過度,所以時間可以動態調整。

文/王亦凡 閱讀原文(測試工具及代碼詳情)請點擊:React全家桶與前端單元測試藝術 - ThoughtWorks洞見


貼一篇我專欄里的文章(一篇文章帶你了解 Java 服務端單元測試的方方面面 - Not A Geek? - 知乎專欄),主要寫的是 Java 單元測試。不過原理應該是相通的。

我在知乎專欄 Not A Geek? - 知乎專欄 會不定期寫更新一些編程方面的文章,歡迎大家關注。

單元測試最直接的好處有兩點:

1. 讓你寫出更好的代碼:職業高內聚、低耦合而且介面設計合理的代碼才易於測試;

2. 讓你在修改代碼時更有信心。

然後我們來舉個例子,假設我們有個 add(a, b) 函數:

現在想測試它,於是用 JUnit 寫個簡單的測試函數:

你傳入參數,調用 add 函數,然後很快能得到結果,對比期望值就能知道函數功能是否正常。這種驗證方式叫做狀態驗證(state verification)。

但是真實項目中的代碼要複雜的多。

比如,這段代碼從資料庫中查詢數據:

如果要測試以上代碼,你有幾種選擇:

1. 連接一個「真實」的資料庫;

2. 選擇內存資料庫,比如 h2;

3. 創建 Mock 對象,在測試時代替 jdbcTemplate。

第 1 種方式最直接,不過依賴外部資料庫會降低單元測試的效率,如果環境出現問題,比如網路不穩定,單元測試還會出錯。第 2 種方式是我們推薦的做法,我們可以認為 h2 是 MySQL 的 fake 對象(當然你選用其他內存資料庫,或者自己寫一個內存資料庫也可以 : P)。我們在生產環境使用 MySQL,而在單元測試/集成測試的時候,則選用 h2,當然這種方法也有一個問題:h2 不可能和 MySQL 完全兼容,所以我們需要改寫部分不兼容的語句,或者用其他方式比如第 3 種方式測試它們。以上兩種測試方法也是狀態驗證(state verification)。

我們來詳細談談第 3 種方式。讓我們看看 Mockito 版本的單元測試代碼:

單元測試一般分為四個步驟:setup、exercise、verify、teardown。用上面的代碼舉例,setup 階段,我們創建了 mock 對象,並且設置了 queryForInt 的行為;exercise 階段調用了測試函數;verify 階段做了兩件事情:1. 確認 queryForInt 函數被正確調用,2. count() 返回值符合預期;teardown 階段一般用來清理和釋放資源,我們這裡不需要,直接跳過了。

這種利用 Mock 對象的測試叫做行為(behavior verification)。回到我們要測試的 count() 函數:我們調用了 jdbcTemplate 類的 queryForObject 函數從資料庫中查詢數據。我們要測試的是自己的業務邏輯,所以我們認為 jdbcTemplate 和資料庫是可靠的(即使不可靠也不應該由我們的單元測試來驗證),如果我們向 jdbcTemplate 傳了正確的參數,後者就會向資料庫發起正確的請求,然後得到正確的結果,換句話說,我們只要驗證是否正確地調用了 jdbcTemplate 就行了。於是,我們可以創建一個模擬對象,即 Mock 對象,在測試的時候代替 jdbcTemplate。 這個對象可以完全由我們自己編寫:實現特定介面,繼承特定類,或者用動態代理,甚至修改位元組碼,它不會真正的訪問資料庫,但會保存你的調用行為,以便你來驗證是否請發起了正確的請求。 當然,絕大部分情況, Mock 對象的實現細節不用你辛辛苦苦寫出來。Java 社區有一大堆開源項目可以選擇,比如:Mockito、EasyMock、PowerMock、JMock 和 JMockit 等。這麼多工具,該如何選擇呢,可以看看 stackoverflow 上的一個問題: What"s the best mock framework for Java。簡單的建議:使用 Mockito 結合 PowerMock,需要自己寫 fake 對象時選擇 JMockit。

測試中過程中,什麼時候使用 Mock 對象,也形成了 Classical 和 Mockist 兩種不同的測試風格。我個人以前是 Mockist 風格,現在偏向 Classical,不過這裡不展開了,如果想進一步了解,可以看 Martin Fowler 的經典文章 Mocks Aren"t Stubs。

說回到項目,實際的項目往往依賴了各種框架和組件,在動手為它們寫 fake/mock 對象之前,可以看看社區是不是已經有了支持,比如:Spring 有 Spring Test、Spring MVC Test;涉及到 Zookeeper,Netflix 提供了in-process ZooKeeper server 。如果你使用 maven 等構件工具構件你的項目,你還可以利用構件工具以及它們的插件做更多事情,比如:利用多線程提高測試效率,只執行特定的測試代碼,生成測試報告等等。通常,我們也會利用 jenkins 等持續集成工具定時/有代碼變更時運行單元測試,保證修改不會破壞已有的代碼功能。

另外,測試遺留代碼也是一個巨大的挑戰,你需要把代碼重構到「可測試」的狀態,《修改代碼的藝術》在這方面一定可以幫到你。

擴展閱讀:

1.《單元測試之道》

2.《修改代碼的藝術》

3. Mocks Aren"t Stubs


稍微複雜一點的邏輯,沒有測試,怎麼寫?靠盯著它看,相面?

應該靠測試,讓你想到的幾種情況,都測試通過,代碼就寫完了。

就是這樣


高手都不測試,直接寫 Formal proof


可以看看Qt Project中的單元測試代碼。


我對單元測試持懷疑態度。

關於單元測試


沒有系統的Unit Test這種叫法。。

如果是普通的Unit Test,可以和我們一樣用TDD——很多軟體開發的大牛(國內除外)都是用TDD的。

與上面的大部分例子不太一樣的是,TDD都是Test First。而大部分的軟體開發的測試都是在 「補測試」,當然在日常的前端開發工作中,我也是在 補測試。主要是因為:一、我功能還不夠,二、UI的TDD不會Drive出功能代碼。

TDD最早起源於Kent Beck最早寫的《測試驅動開發》,英文《https://www.amazon.com/Test-Driven-Development-Kent-Beck/dp/0321146530》

過程如下:

測試驅動開發的主要過程是:

  1. 先寫功能的測試
  2. 實現功能代碼
  3. 提交代碼(commit -&> 保證功能正常)
  4. 重構功能代碼

Martin Fowler的《重構》則是這其中的第四步。

其優點是:保證你的功能代碼都是滿足你需求而產出的代碼。

正好前幾天我寫了《如何用測試驅動出100%測試覆蓋率的代碼》。下文以DDM為例,簡單地介紹一下如何用測試驅動開發(TDD, Test-Driven Development)的方法來驅動出這個函數庫。

DDM簡介

DDM是一個簡潔的前端領域模型庫,如我在《DDM: 一個簡潔的前端領域模型庫》一文中所說,它是我對於DDD在前端領域中使用的一個探索。

簡單地來說,這個庫就是對一個數據模型的操作——增、刪 、改,然後生成另外一個數據模型。

如以Blog模型,刪除Author,我們就可以得到一個新的模型。而實現上是因為我們需要RSS模型,我們才需要對原有的模型進行修改。

預先式設計

如果你對TDD有點了解的話,那麼你可能會預先式設計有點疑問。

等等,什麼是測試驅動開發?

測試驅動開發,英文全稱Test-Driven Development,簡稱TDD,是一種不同於傳統軟體開發流程的新型的開發方法。它要求在編寫某個功能的代碼之前先編寫測試代碼,然後只編寫使測試通過的功能代碼,通過測試來推動整個開發的進行。這有助於編寫簡潔可用和高質量的代碼,並加速開發過程。

流程大概就是這樣的,先寫個測試 -&> 然後運行測試,測試失敗 -&> 讓測試通過 -&> 重構。

換句簡單的話來說,就是 紅 -&> 綠 -&> 重構

在DDM項目里,就是一個比較適合TDD的場景。我們知道我們所有的功能,我們也知道我們需要對什麼內容進行測試,並且它很簡單。因為這個場景下,我們已經知道了我們所需要的功能,所以我們就可以直接設計主要的函數:

export class DDM {
constructor() {}
from() {};
get(array) {};
to() {};
handle() {};
add() {};
remove(field) {};
}

上面的就是我們的需要函數,不過在後來因為需要就添加了replace和replaceWithHandle方法。

然後,我們就可以編寫我們的第一個測試了。

第一個驅動開發的測試

我們的第一個測試,比較簡單,但是也比較麻煩——我們需要構建出基本的輪廓。我們的第一個測試就是要測試我們可以從原來的對象中取出title的值:

let ddm = new DDM();
var originObject = {
title: "hello",
blog: "fdsf asdf fadsf ",
author: "phodal"
};

var newObject = {};
ddm
.get(["title"])
.from(originObject)
.to(newObject);

expect(newObject.title).toBe("hello");

對應的,為了實現這個需要基本的功能,我們就可以寫一個簡單的return來通過測試。

from(originObject) {
return this;
};

get(array) {
return this;
};

to(newObject) {
newObject.title = "hello";
return this;
};

但是這個功能在我們寫下一個測試的時候,它就會出錯。

ddm
.get(["title", "phodal"])
.from(originObject)
.to(newObject);

expect(newObject.title).toBe("hello");
expect(newObject.author).toBe("phodal");

但是這也是我們實現功能要做的一步,下一步我們就可以實現真正的功能:

  • 在from函數里,複製originObject
  • 在get函數里,獲取新的對象所需要的key
  • 最後,在to函數里,進行複製處理

from(originObject) {
this.originObject = originObject;
return this;
};

get(array) {
this.newObjectKey = array;
return this;
};

to(newObject) {
for (var key of this.newObjectKey) {
newObject[key] = this.originObject[key];
}
return this;
};

現在,我們已經完成了基本的功能。

一個意外的情況

在我實現的過程中,我發現如果我傳給get函數的array如果是空的話,那麼就不work了。於是,就針對這個情況寫了個測試,然後實現了這個功能:

get(keyArray) {
if(keyArray) {
this.newObjectKey = keyArray;
} else {
this.newObjectKey = [];
}
return this;
};

to(newObject) {
if(this.newObjectKey.length gt; 0){
for (var key of this.newObjectKey) {
newObject[key] = this.originObject[key];
}
} else {
// Clone each property.
for (var prop in this.originObject) {
newObject[prop] = clone(this.originObject[prop]);
}
}
return this;
};

在這個過程中,我還找到了一個clone函數,來替換from中的"="。

from(originObject) {
this.originObject = clone(originObject);
return this;
};

第三個驅動開發的測試

因為有了第一個測試的基礎,我們要寫下一測試變得非常簡單:

javascript
dlm.get(["title"])
.from(originObject)
.add("tag", "hello,world,linux")
.to(newObject);

expect(newObject.tag).toBe("hello,world,linux");
expect(newObject.title).toBe("hello");
expect(newObject.author).toBe(undefined);

在實現的過程中,我又投機取巧了,我創建了一個對象來存儲新的對象的key和value:

add(field, value) {
this.objectForAddRemove[field] = value;
return this;
};

同樣的,在to方法里,對其進行處理:

to(newObject) {
function cloneObjectForAddRemove() {
for (var prop in this.objectForAddRemove) {
newObject[prop] = this.objectForAddRemove[prop];
}
}

function cloneToNewObjectByKey() {
for (var key of this.newObjectKey) {
newObject[key] = this.originObject[key];
}
}

function deepCloneObject() {
// Clone each property.
for (var prop in this.originObject) {
newObject[prop] = clone(this.originObject[prop]);
}
}

cloneObjectForAddRemove.call(this);
if (this.newObjectKey.length gt; 0) {
cloneToNewObjectByKey.call(this);
} else {
deepCloneObject.call(this);
}
return this;
};

在這個函數里,我們用cloneObjectForAddRemove函數來複制將要添加的key和value到新的對象里。

remove和handle函數

對於剩下的remove和handle來說,他們實現起來都是類似的:

  • 存儲相應的對象操作
  • 然後在to函數里進行處理

編寫測試:

function handler(blog) {
return blog[0];
}

ddm.get(["title", "blog", "author"])
.from(originObject)
.handle("blog", handler)
.to(newObject);
expect(newObject.blog).toBe("A");

然後實現功能:

remove(field) {
this.objectKeyForRemove.push(field);
return this;
};

handle(field, handle) {
this.handleFunction.push({
field: field,
handle: handle
});
return this;
}

這一切看上去都很自然,然後我們就可以對其進行重構了。

100%的測試覆蓋率

由於,我們先編寫了測試,再實現代碼,所以我們編寫的代碼都有對應的測試。因此,我們可以輕鬆實現相當高的測試覆蓋率。

在這個Case下,由於業務場景比較簡單,要實現100%的測試覆蓋率就是一件很簡單的事。

(PS: 我不是TDD的死忠,只是有時候它真的很美。)

各們看官可以看相關的提交歷史,GitHub: GitHub - phodal/ddm: A simple DDD library by powerful. Design for Front-End to create Bounded Context Model.


《XUnit Test Patterns》只能看英文,中文翻譯成了屎


C++的項目用googletest吧,我用過幾個項目,還可以,雖然沒有Java的JUnit那一套東西那麼方便。


說說我對UnitTest這種技術的看法,主要偏向於Java下的JUnit等測試技術。

單元測試是為了保障系統某些粒度,特別是最細節到方法的粒度上的正確性(狹義的單元測試)。通過詳盡而適度的UnitTest case,可以在重構和修改代碼時,保障不影響我們以前的代碼(回歸測試),也可以在實現具體業務之前寫測試來保障開發時的迭代實現都是正確的(測試驅動開發),有時候也可以對比方法級別更高的層次做一次封裝實現一定程度的(集成測試),有時候也可以實現對某些遠程業務的集成測試(介面自動化測試),或者藉助一些特定的庫或技術實現對UI系統的功能測試(UI自動化測試)。

單元測試本身就是利用機器去執行測試,從而代替人工的測試或調試,節約程序員的時間和生命,並且可以做到常態化(結合項目質量和持續集成工具,Sonar、Hudson/Jenkins等),保障代碼的(邏輯、邊界等)正確性和系統質量。所以單元測試應該越簡單越好。當體系比較龐大時,為了一個大流程中間的某個小零件能run起來,需要準備非常多這個點的測試環境不需要的東西,非腳本語言環境下的單元測試(比如java)可以結合Mock框架(Jmock,mockito,easyMock)使用,簡單方便、不需要拖泥帶水。複雜環境下的UnitTest,需要測試數據的準備、測試完成後的數據清理,特別要注意Test上下文環境的污染,從而影響測試的準確性。Spring下的UnitTest,可以使用SpringTest,對配置環境的管理,測試上下文的管理,事務的管理,都可以很好支持。對於遠程方法或借口的測試可以用HttpUnit。WEB的自動化測試可以走SeleniumTellurium。RCP的桌面程序,可以用SWTBot等等。。。有點跑題了。


一般用googletest和googlemock

不要為了測試而測試,良好的模塊化設計更加重要。沒有良好的模塊化設計,你會發現簡直沒法測,mock代碼量會比實際功能更多。


我個人是不太推薦做沒必要的單元測試,因為在快速迭代開發或者重構過程中其實很難保持介面不變,最後還要花大量時間修改測試代碼和數據,測試代碼本身也會引入大量錯誤。


我傾向於能不寫就不寫。盡量把代碼寫成「顯然正確的形狀」就行了。代碼寫多了,就知道顯然正確的形狀大概是什麼樣了

儘可能在編譯期讓編譯器/靜態檢查器檢查代碼。儘可能在運行期做動態檢查和提前報錯。在性能變化不大的情況下儘可能把資料庫/中間件的約束都用上

另外,複雜的演算法要手工在調試器里step over一次。這是reality check



好多人一上來就講testing framework 了。對於剛要學習的人用處不大。

要我說,應該先學習 dependency injection 的概念。

然後學習stub, fake,mock的概念,並且知道什麼情況下用哪一個。

久而久之你會發現unit test 萬變不離其宗,基礎概念理解透徹了那遇到什麼情況都不怕。

然後慢慢的你會學到其他很多有用的東西。

然後慢慢你會發現僅有unit test ,其實完全不夠,不過那是後話啦。


三點,有好有壞。

第一,不用框架,而是使用 n 年前自己寫的一個超小測試庫 ut.h,就一個頭文件,基本全是宏實現的。缺點是沒有內置非同步測試,因為我到現在也不能保證我實現的非同步測試功能沒有 bug。

第二,單元測試只涉及底層介面和業務邏輯中比較好測的類,GUI 不做單元測試,而是直接 e2e。

第三,模塊的導出介面測試,不使用任何 mock 對象,而是直接搭建運行環境測試(所以到現在也做不到在構建伺服器上測試)。


我也來貢獻一個我平常工作時寫unit test的小竅門,其實沒有那麼複雜。

首先寫unit test之前,要確認自己的測試順從兩個原則:

1. 盡量不要干涉原來的代碼。從閱讀代碼的體驗來說,不要讓你的測試(哪怕是一小段if..else...的代碼)出現在你準備測試的代碼中。

2. 代碼要只是測試某個class裡面的一個特定的function。這個function不能太簡單,太簡單就沒有測試的意義;也不能太複雜,不應該牽涉太多其他的class,如果太複雜就不應該寫unit test,而考慮其他的測試方法。

(以上兩個原則其實大家都知道,但是寫的時候很多人都沒有嚴格遵守,所以這裡強調一下)

好了說一些我的小竅門,其實很簡單,就是合理的使用inheritance。舉個栗子,以下是我的某個特別複雜的class叫Complex,裡面有一個function叫doSomethingUseful(),其中呼叫了很多IPC (Inter-process communication,即進程間通信)。

class Complex {
public:
void doSomethingUseful();
};

很顯然,一個小小的unit test是不應該牽扯IPC進來的,但是我要測試這個doSomethingUseful()的邏輯,怎麼辦呢?

我的做法就是將這些IPC做成一個interface,存在Complex裡頭:

Complex.h

class Complex {
public:
Complex(std::unique_ptr&);
void doSomethingUseful();
private:
std::unique_ptr& m_messageDispatcher;
};

Complex.cpp

Complex::Complex(std::unique_ptr& msgDispatcher) {
m_messageDispatcher = std::move(msgDispatcher);
}
void Complex::doSomethingUseful() {
// Do something...
m_messageDispatcher-&>sendMessage1(object1);
// Do something again...
m_messageDispatcher-&>sendMessage2(myMsg);
// Do something else...
m_messageDispatcher-&>sendMessage3(...);
// and so on so forth...
}

這個MessageDispatcher就只做一個很簡單的虛擬界面:

MessageDispatcher.h

class MessageDispatcher {
public:
virtual void sendMessage1(Object) = 0;
virtual void sendMessage2(String) = 0;
virtual void sendMessage3(...) = 0;
...
};

然後你把所有的IPC的執行細節都專門寫在另外一個MessageDispatcher的子類里,比如:

class MessageDispatcherImpl final : public MessageDispatcher {
public:
void sendMessage1(Object o) override;
void sendMessage2(String s) override;
void sendMessage3(...) override;
};

記得在建立Complex時創建一個 MessageDispatcherImpl 對象:

std::unique_ptr& myMsgDispatcher = new MessageDispatcherImpl();
Complex complex = new Complex(std::move(myMsgDispatcher));

看了這麼多代碼,你肯定要問我,單元測試在哪裡?別急,這就出來了。

class ComplexTest {
public:
ComplexTest();
void testDoSomethingUseful();
private:
std::unique_ptr& m_complex;
}

class FakeMessageDispatcher final : public MessageDispatcher {
public:
void sendMessage1(Object o) override;
void sendMessage2(String s) override;
void sendMessage3(...) override;
};

ComplexTest::ComplexTest() {
std::unique_ptr& md = new FakeMessageDispatcher();
m_complex = new Complex(std::move(md));
}

void ComplexTest::testDoSomethingUseful() {
m_complex-&>doSomethingUseful();
assert_true...assert_false... // verify test results
}

基本上,使用一個MessageDispatcher的虛擬界面,成功得隔離開doSomethingUseful()裡面那部分單元測試不想管也沒辦法去管的內容--在這裡指的是IPC,當然也可以是連接資料庫,等等。測試的部分寫在FakeMessageDispatcher裡頭,原來的代碼寫在MessageDispatcherImpl裡頭。

而一個FakeMessageDispatcher,只要寫的東西大概能虛擬IPC的運作(比如設置某個boolean變數來指示「哦,我這個IPC已經發過了「之類的),整個unit test的邏輯,可以說是嚴密跟原來的代碼是一樣的。這樣就起到了測試的效果。

同時,別人在閱讀你的代碼Complex時,只需要看到MessageDispatcher知道這麼個界面存在就可以了,很乾凈,不會被一堆測試代碼干擾了視線

(倉促寫代碼,恐有一些syntax錯誤,大家知道我想表達的意思就行)


大牛們怎麼測的不太清楚,作為弱雞都是XCode下cmd+U就搞定…


推薦閱讀:

MFC 過時了嗎?
如何用c++寫一個簡單的計算器程序?
C++在acm里的優勢相比其他語言有多大?
如何入門CDQ分治?
利用C++ template,請問我該如何設計這個向量類(Vector)?

TAG:自動化測試 | C | 單元測試 | UnitTest |