![]() |
VOOZH | about |
測試驅動開發是 Kent 提出的一種新的軟體開發流程,現在已廣為人知,這種開發方法依賴於極短重複的開發周期,面對開發需求,開發人員要先開發代碼測試用例,這些代碼實現的測試用例定義了工程要實現的需求,然後去開發代碼快速測試通過這這些用例,這個時候的代碼是相對比較粗糙的,只是為了通過這個測試,測試通過以後,這些測試所覆蓋的需求就會相對固定下來了,然後隨著實現更多的需求,以前實現的那些粗糙的代碼的問題會逐步的暴露出來,此時就要用重構來消除重複改進代碼設計,因為自動化的測試用例已經框定了相應的需求,這樣在代碼改進和重構的過程中就不會破壞已實現的需求,實現了安全重構。
從測試驅動開發的流程可以看出來,測試驅動開發僅僅要求一個簡單的設計開始實現需求,然後隨著軟體開發的推進實現有保護重構代碼和設計。依賴於 TDD 開發所生成的單元測試用例代碼,實現有保護重構是大型的軟體開發項目不可以缺少的,代碼級別的測試更能有效地提高軟體產品的質量。測試驅動開發中的重構過程也是一個使設計逐步完善的過程。 本文的主要目的是使測試驅動開發落到實地,和具體的語言(C++)和單元測試框架結合起來,並用實例展示測試驅動開發的魅力。
先開發和設計測試代碼,再代碼實現通過測試,以測試驅動設計實現,開發和設計的過程,得到了快速的反饋,用這些反饋驅動,改進和重構代碼設計,是一個有機的開發過程。按照 Kent 的定義,測試驅動開發的原則是:
這兩個簡單的原則,卻產生了一些複雜的個體和組的行為,這些隱含的技術行為包括:
兩個原則還隱含開發任務的順序:
紅色(Red)-綠色(Green)-重構(Refactor),這個就是測試驅動開發的座右銘(Mantra)。這種開發方式可以有效的減少代碼的缺陷密度,減少 bug 的數量,將大部分的缺陷在代碼的開發過程中消除,減少了 QA 測試和質量保證的成本。
按照軟體工程的說法,軟體缺陷和 bug 發現的越早,所需的更正這些缺陷的成本就會越小。所以在軟體的開發階段,採用測試驅動的開發方法,把測試引入到開發階段,使測試和質量意識融入到開發的過程中,這對提高軟體工程質量非常有幫助。 而且在採用測試驅動開發必然要求所開發的組件、接口、類或方法是可測試的(testable),這就要求開發的組件,接口要遵循組件和類高內聚(Highly Cohesive),組件和組件、類和類之間低耦合(loosely Coupled)原則,這種開發方式生成的代碼必然會幫助開發者,在不斷的有保護重構的過程中,提高軟體架構的設計,使日後的軟體維護變得有章可循。
測試驅動開發符合敏捷軟體開發的精神,在不斷疊代過程中,增量地實現軟體需求而這一切開始可以從簡單設計開始。
C++技術是一種高級語言,它出現的時間要比 Java 和 C#早得多,但支持像 xUnit 框架的 C++單元測試框架發展起來的比較晚。 C++ 的單元測試框架選擇比較多,現在比較流行的 C++測試框架有 Boost Test、UnitTest++、CppTest、Google C++ Testing Framework。 Boost Test,擁有良好的斷言功能,對異常控制,崩潰控制方面處理的比較好,也有良好的可以移植性,但結構複雜,不易於掌握。CPPUnit 是開發比較早的單元測試框架,是對 JUnit 的 C++的移植的一種嘗試,擁有豐富的斷言和期望功能。Google Test C++ 簡稱 Gtest,是近期發展起來的單元測試框架,對 xUnit 支持的比較好,支持 TDD 的紅-綠-重構模式,支持死亡和退出測試,較好的異常測試控制能力,良好的測試報告輸出,擁有自動註冊測試用例和用例分組等功能,還有和 Gmock 框架的無縫結合,支持基於接口的(抽象類的)Mock 測試-模擬測試。
下表是一個對三種流行 C++單元測試框架的簡單比較,Gtest 雖然發展起來的較晚,但豐富功能簡單易用,易學,加之移植性較好,是跨平台項目單元測試框架比較好的選擇。
| 可移植性 | 較好 | 好(依賴於 Boost 庫) | 較好 |
| 豐富的斷言 | 優 | 優 | 一般 |
| 豐富的斷言信息 | 優 | 良好 | 較差 |
| 自動檢測和註冊測試用例 | 優 | 良 | 一般 |
| 易於擴展斷言 | 易於擴展 | 一般 | 一般 |
| 支持死亡和退出測試(Death 和 Exit) | 支持 | 支持 | 不支持 |
| 支持參數化測試(Parameterized test) | 支持 | 支持 | 不支持 |
| 支持 Scoped_Trace | 支持 | 不支持 | 不支持 |
| 支持選擇性執行測試用例 | 支持 | 支持 | 支持 |
| 豐富的測試報告形式(xml) | 支持 | 支持 | 支持 |
| 支持測試用例分組 Suites | 支持 | 支持 | 支持 |
| 開源 | 是 | 是 | 是 |
| 執行速度 | 快 | 快 | 快 |
基於接口的Mock測試 |
通過Gmock支持 |
不支持 | 不支持 |
| 易用性 | 優秀 | 較複雜 | 較好 |
| 支持類型化的參數化測試 | 支持 | 不直接支持 | 不直接支持 |
Gtest 是基於 xUnit 的 C++單元測試框架,支持自動化案例自動發掘,豐富的斷言功能,支持用戶自定義斷言,支持死亡測試和退出測試,還有異常測試控制,支持值類型和類型化的參數化測試,接口簡單易用,對每個測試案例有執行時間的輸出,可以幫助分析代碼的執行效率,單一接口文件 gtest.h。
圖 1 是 Console 模式輸出用紅和綠表示失敗和成功的測試用例,看起來比較符合 TDD 的策略和定義
Gtest 的斷言有兩種形式,致命性斷言(Fatal Assertion)和非致命性斷言(Nonfatal Assertion)。
除了基本的斷言形式外,Gtest 還包括一些其他的高級斷言形式,比如死亡斷言,退出斷言測試和異常斷言等。
Gtest 還有其他的一些特性,比如類型參數化測試,值類型參數化的測試,測試用例分組,洗牌式測試等,可以參照附錄中列出的 Gtest 的官網獲取更多的信息。
在測試驅動軟體開發的過程中,我們不可避免的要去依賴第三方系統,比如文件系統、第三方庫、資料庫訪問,其他的在線數據的訪問等,按照測試驅動開發的快速反饋的原則,如果在單元測試用例中去直接訪問這些信息,勢必在測試驅動開發過程中會依賴這些資源從而造成訪問時間無法控制, 所以單元測試一般應該避免直接訪問第三方系統,這就是 Mock 測試的主要目的,用模擬的接口去替換真實的接口,模擬出單元測試需要的第三方數據和接口進而隔離第三方的影響,專注於自己的邏輯實現。Gmock 就是這樣一個 Mock 框架,它是類似於 jMock、EasyMock 和 Hamcres ,但是是 C++版本的 Mock 框架。 Gmock 是基於接口的 Mock 框架,在 C++中接口的定義是通過抽象函數和抽象類來實現的,這種要求勢必會要求我們儘量遵循基於接口的編程原則,把交互界面上的操作抽象成接口,以便是接口可被模擬 Mock。可以在附錄中列出的 Gmock 官網獲取更多信息。
測試驅動開發和敏捷開發是相輔相成的,敏捷開發的需求一般是以故事、產品功能列表,或需求用例的方式給出,拿到這些需求後,開發團隊會根據相應的需求文檔分析需求,做功能分解,根據功能優先級制定疊代開發計劃和測試計劃。測試驅動開發可以從兩個角度來看,廣義的和狹義的。廣義的測試驅動開發是從流程上規定測試驅動開發,這種情況下一般要求 QA 走到前面,先根據需求先開發測試用例,這些測試用例會作為功能驗收的標準,然後開發人員會根據測試用例做詳細的功能設計和編碼實現,最後提交給 QA 做功能驗收測試。 狹義的測試驅動開發是開發人員拿到功能需求後,先自己開發代碼級別的測試用例,然後開發具體的實現通過這些測試用例的一種開發方法。 本文涉及的是第二種,從代碼級別開始的,狹義的測試驅動開發。
相信每個人都玩過棋牌遊戲,簡單起見,為了實踐測試驅動開發方法我想開發一款簡單的三子棋遊戲,如圖 2 所示。三子棋的遊戲規則很簡單,只要是同樣的三個棋子連成一條線那麼持對應棋子的人就勝出,圖中持 O 子棋的人獲勝。總結一下三子棋遊戲的基本需求:
以上是三子棋遊戲的基本需求列表,拿到這些需求後,我會做一些簡單解決方案的設計,解決方案包括 4 個子工程(C++ Project),其中一個測試工程 TicTacToeGamingTest,其餘三個分別是 TicTacToeLib,TicToeGamingLib 和 TicTacToeConsoleGaming,這三個工程的依賴關係是 TicTacToeConsoleGaming 依賴於 TicToeGaminglib 和 TicTacToeLib,TicToeGamingLib 依賴於 TicTacToeLib。 建好這些工程,有了基本的設計思路後,在測試工程里首先開發的測試代碼。
1.我需要一個 3X3 的棋盤,可以來下三子棋。
這個需求很簡單,現在的棋盤不需要包括任何的邏輯,為了便於測試我需要一個接口去訪問它,現在接口是空的,也沒有實現,這樣一個測試用例就可以滿足這個需求:
這是第一個測試用例,稍微解釋一下。TicTacToeTestFixture 是用於測試的分組的,它是一個類,繼承於 Gtest 的 test 類 testing::Test,這個類可以重載 setup 和 teardown 等虛擬函數用於測試準備和清理測試現場。TEST_F是定義測試用例的宏,IWantAGameBoard
是測試的案例的名稱,會顯示在輸出中,測試用例很簡單,只是只是保證能創建和析構 SimpleGameBoard 實例,並無異常拋出。這個測試用例現在是不能編譯通過的,因為 IGameBoard 接口和 SimplegameBoard 都還沒有聲明和定義,接下來為了使這個案例通過,我在 TicTacToeLib 工程里,聲明和定義 IGameBoard 和 SimpleGameBoard
類,IGameBoard 是純抽象類,抽象了所有對棋盤的操作。引入聲明到測試工程中,編譯通過並運行,現在完成了第一測試用例,儘管測試的 IGameBoard 和 SimpleGameBoard 還是空的。可以看一下輸出:
2.我需要在棋盤上下棋和獲取到棋子
這個需求能使棋手在棋盤上把棋子放到想要的位置上並能查看指定棋盤位置上的棋子,棋盤是 3×3。實現這個需求也很簡單,我只要在 IGameBoard 接口上添加兩個函數然後在 SimpleGameBoard 里實現這兩個函數就可以滿足這個需求:
試著編譯這個測試工程,失敗,原因是沒有實現這兩個函數,接下來我回到 TicTacToeLib 工程去聲明和定義這兩個函數。為了實現這兩個功能,在 SimpleGameBoard 定義 private 數據:vectorchar> data_;用於 保存棋子和位置信息,為了簡單,棋子用 Char 類型來表示,位置信息和
data_向量的下標對應,如棋盤位置(2,2)對應的是 data_[2*3+2]這個位置,數據是安行存放的。兩個函數的實現是:
initboard_是個 protected 函數,用於初始化 data_。 現在可以重現編譯和運行測試工程,結果如下:
有了兩個測試用例的實現,並且運行是綠色,繼續下個需求。
3.我要能驗證和判斷是不是三個棋子在同一條線上,以判斷是不是有人勝出
這個需求用於判斷三個棋子是否已經在一條線上,如果是的話,那麼持對應棋子的棋手就會勝出,這個測試用例可以這樣設計:
設計是這樣的,為簡單,我把判斷棋子勝出的函數 CheckWinOut 定義到接口 IGameBoard 中,並在 SimpleGameBoard 中實現它,實現如下:
IsThreeInLine_是受保護的成員函數,它會掃描棋盤的行,列和對角線看是否指定的棋子在一條線上,如果有三個棋子在一條線上,則說明有人勝出。編譯運行測試,綠色通過。 繼續下一個需求。
4.我不能放棋子到已被占用的棋位置上。
這個需求是個驗證性需求,要保證棋子不能重疊和覆蓋已在棋盤上的棋子,實現這個需求我只要重構現有的代碼加上避免棋子重疊的邏輯。只要避免在 PutChess 時候,檢查是否指定的位置是否已有棋子,如果是簡單的拋出異常即可。有了這些基本的思路,我開始設計測試用例。
ChessOverlapException 是我將要實現的一個異常類,這個是在棋手試圖放棋子到已有棋子的棋盤位置上時要拋出的異常。測試用例中,我在(0,0)和(2,2)這兩個位置上放同樣的棋子以觸發這個異常。為了編譯通過,我開始實現 ChessOverlapException。 ChessOverlapException 繼承自 std::exception 我重載了 what 函數返回相應的異常信息。 把這個異常類的定義引入的測試工程中,編譯通過運行測試,但卻得到了紅色 Red,案例失敗:
圖 6.測試用例輸出
原因是我還沒有重構 PutChess 函數以加入避免棋子被被覆蓋的代碼。現在來重構 PutChess 函數:
重新編譯測試工程並運行得到綠色 Green 通過。繼續下一個需求。
5.我要能判斷是不是棋盤已滿並無贏家。
這個需求用於判斷是否是和棋的情況,棋盤滿了但並無贏家,這是可能出現的一種情況,這個實現設計可以有兩種方式. 一是重構 CheckWinOut 函數,使返回值攜帶更多的信息,比如和棋,有人勝出等。二是定義一個獨立的函數去判斷棋盤的當前狀態。第一種方案較合理,開始設計這種方案的測試用例:
以上的測試用例可以看出, 我設計了和棋的棋局,並想重構 CheckWinout 函數,使其返回枚舉類型 GameBoardStatus 以表示棋局的狀態,其中 GAMEDRAW 表示和棋狀態。為了使工程能編譯通過,開始定義這個枚舉類型並重構 CheckWinOut 函數。實現所有設計,經過幾次的 Red 失敗,最終 形成代碼:
其中那個 IsEndedInADraw_是個受保護的成員函數,用於檢測是否和棋。 在調通這個測試用例的過程中,我也更新了測試JugeThreeInLine。因為重構 ChecWinOut 改變了返回類型。
6.我需要能復位棋盤,以便於重新開始下棋。
7.我需要用對記住玩家,以便於我能特例化 Player。
6 和 7 需求的測試案例和實現比較比較簡單,不在贅述,7 的要求是要建立玩家 Player,這個主要是說要能實例化玩家。可以看附帶的工程。
8.我需要能保存和加載棋局能力,以便於我能下次回來繼續之前的遊戲。
這個需求是一個合理的需求,玩家可以保存和繼續回來玩遊戲,他的測試用例可以這樣設計:
這裡用兩個測試用例來覆蓋這個需求,一個是保存棋盤,一個是加載棋盤。由這個測試用例可以看到,要通過這個測試,必須要定義 IGameIO 接口和 SimpeGameIO 類。 保存棋盤的媒介是文件。按照 TDD 的開發要求,測試單元本身最好是脫離對第三方系統的依賴,但測試中必然會用到第三方系統,解決這些問題的方法有幾種。創建第三方系統的 Stub 類或是 FakedObject,第三種選擇是 Mock 框架,如 Gmock。 Gmock 的設計理念是基於接口的,只要是第三方訪問提供的是接口,這些訪問就可以可以被用 Gmock 模擬。可以看參考文獻獲取更多的信息。 限於篇幅不再贅述。一下是完成所有測試用例的測試結果。
或許你會注意到有些測試用例的設計,只是以點蓋面,如果想要更多的驗證點可以藉助於 Gtest 提供的參數化測試設計測試數據,然後去測試實現的類和邏輯。 還有死亡測試的用例,可以在參考資源中的 Gtest 資源中查看。
C++中實現測試驅動開發 TDD 之前是很困難的事。 但有了類似於 xUnit 的 Gtest 和 Gmock 測試框架,在 C++工程中實現 TDD 也變得很享受。測試驅動開發是一個很好的工具,它可以幫助開發者實現有機開發,在需求的實現過程中快速得到反饋,另一個好處是測試驅動開發可以使開發人員更加重視需求和測試,以測試用例為中心,這樣勢必會產生更好代碼。從軟體工程的角度來說,測試驅動開發的實踐應用會大幅度的提高軟體開發的質量,用代碼級別的測試用例來覆蓋和保障程序的健壯性更能保障整個軟體產品的開發質量。
測試驅動開發的座右銘模式:紅色-綠色-重構,然後重複這個直到開發完成為止,是一個自我確認和有保護代碼重構的過程。採用測試驅動開發的模式的軟體產品,產生的單元測試代碼,從代碼級別測試覆蓋了軟體的需求,使以後的代碼重構更安全可靠。