面向 C++ 的測試驅動開發

夏建東、何凱、尹鵬發表於2016-08-11

測試驅動開發(TDD)背景及綜述

測試驅動開發是 Kent 提出的一種新的軟體開發流程,現在已廣為人知,這種開發方法依賴於極短重複的開發週期,面對開發需求,開發人員要先開發程式碼測試用例,這些程式碼實現的測試用例定義了工程要實現的需求,然後去開發程式碼快速測試通過這這些用例,這個時候的程式碼是相對比較粗糙的,只是為了通過這個測試,測試通過以後,這些測試所覆蓋的需求就會相對固定下來了,然後隨著實現更多的需求,以前實現的那些粗糙的程式碼的問題會逐步的暴露出來,此時就要用重構來消除重複改進程式碼設計,因為自動化的測試用例已經框定了相應的需求,這樣在程式碼改進和重構的過程中就不會破壞已實現的需求,實現了安全重構。

從測試驅動開發的流程可以看出來,測試驅動開發僅僅要求一個簡單的設計開始實現需求,然後隨著軟體開發的推進實現有保護重構程式碼和設計。依賴於 TDD 開發所生成的單元測試用例程式碼,實現有保護重構是大型的軟體開發專案不可以缺少的,程式碼級別的測試更能有效地提高軟體產品的質量。測試驅動開發中的重構過程也是一個使設計逐步完善的過程。 本文的主要目的是使測試驅動開發落到實地,和具體的語言(C++)和單元測試框架結合起來,並用例項展示測試驅動開發的魅力。

測試驅動開發的信條

先開發和設計測試程式碼,再程式碼實現通過測試,以測試驅動設計實現,開發和設計的過程,得到了快速的反饋,用這些反饋驅動,改進和重構程式碼設計,是一個有機的開發過程。按照 Kent 的定義,測試驅動開發的原則是:

  1. 不要寫一行程式碼,除非有一個失敗的自動化測試案例要糾正。
  2. 消除重複的程式碼,改進設計。

這兩個簡單的原則,卻產生了一些複雜的個體和組的行為,這些隱含的技術行為包括:

  • 執行程式碼對設計決定快速反饋下,實現有機地設計
  • 必須自己寫自己的測試用例,而不是等待別人幫你寫測試程式碼,那樣會花費很長時間
  • 必須要有對變更程式碼快發反應開發環境
  • 元件必須要高內聚、低耦合,以使測試簡單化。

兩個原則還隱含開發任務的順序:

  1. 紅色(Red):寫些不能夠工作的小測試,這個測試甚至不能編譯通過。
  2. 綠色(Green):快速編寫程式碼使測試通過,不用太在意程式碼質量只是通過測試。
  3. 重構(Refactor):消除開始是隻是要通過測試的重複程式碼,改進設計。

紅色(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 雖然發展起來的較晚,但豐富功能簡單易用,易學,加之移植性較好,是跨平臺專案單元測試框架比較好的選擇。

表 1.單元測試框架比較
測試框架支援特性 Gtest Boost Test CPPUnit
可移植性 較好 好(依賴於 Boost 庫) 較好
豐富的斷言 一般
豐富的斷言資訊 良好 較差
自動檢測和註冊測試用例 一般
易於擴充套件斷言 易於擴充套件 一般 一般
支援死亡和退出測試(Death 和 Exit) 支援 支援 不支援
支援引數化測試(Parameterized test) 支援 支援 不支援
支援 Scoped_Trace 支援 不支援 不支援
支援選擇性執行測試用例 支援 支援 支援
豐富的測試報告形式(xml) 支援 支援 支援
支援測試用例分組 Suites 支援 支援 支援
開源
執行速度
基於介面的Mock測試 通過Gmock支援 不支援 不支援
易用性 優秀 較複雜 較好
支援型別化的引數化測試 支援 不直接支援 不直接支援

測試驅動開發-GTest 簡介

Gtest 是基於 xUnit 的 C++單元測試框架,支援自動化案例自動發掘,豐富的斷言功能,支援使用者自定義斷言,支援死亡測試和退出測試,還有異常測試控制,支援值型別和型別化的引數化測試,介面簡單易用,對每個測試案例有執行時間的輸出,可以幫助分析程式碼的執行效率,單一介面檔案 gtest.h。

圖 1 是 Console 模式輸出用紅和綠表示失敗和成功的測試用例,看起來比較符合 TDD 的策略和定義

圖 1.GTest 的案例測試結果輸出

GTest 的案例測試結果輸出

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 子棋的人獲勝。總結一下三子棋遊戲的基本需求:

  1. 我需要一個 3X3 的棋盤,可以用下三子棋。
  2. 我需要在棋盤上下棋和獲取到棋子。
  3. 我要能驗證和判斷是不是三個棋子在同一條線上,以判斷是不是有人勝出。
  4. 我不能放棋子到已被佔用的棋位置上。
  5. 我要能判斷是不是棋盤已滿並無贏家。
  6. 我需要能復位棋盤,以便於重新開始下棋。
  7. 我需要用對記住玩家,以便於我能特例化 Player
  8. 我需要能儲存和載入棋局能力,以便於我能下次回來繼續之前的遊戲。
圖 2.三子棋遊戲

圖 2.三子棋遊戲

以上是三子棋遊戲的基本需求列表,拿到這些需求後,我會做一些簡單解決方案的設計,解決方案包括 4 個子工程(C++ Project),其中一個測試工程 TicTacToeGamingTest,其餘三個分別是 TicTacToeLib,TicToeGamingLib 和 TicTacToeConsoleGaming,這三個工程的依賴關係是 TicTacToeConsoleGaming 依賴於 TicToeGaminglib 和 TicTacToeLib,TicToeGamingLib 依賴於 TicTacToeLib。 建好這些工程,有了基本的設計思路後,在測試工程裡首先開發的測試程式碼。

圖 3.解決方法設計

圖 3.解決方法設計

先看第一個需求:

1.我需要一個 3X3 的棋盤,可以來下三子棋。

這個需求很簡單,現在的棋盤不需要包括任何的邏輯,為了便於測試我需要一個介面去訪問它,現在介面是空的,也沒有實現,這樣一個測試用例就可以滿足這個需求:

這是第一個測試用例,稍微解釋一下。TicTacToeTestFixture 是用於測試的分組的,它是一個類,繼承於 Gtest 的 test 類 testing::Test,這個類可以過載 setup 和 teardown 等虛擬函式用於測試準備和清理測試現場。TEST_F 是定義測試用例的巨集,IWantAGameBoard 是測試的案例的名稱,會顯示在輸出中,測試用例很簡單,只是只是保證能建立和析構 SimpleGameBoard 例項,並無異常丟擲。這個測試用例現在是不能編譯通過的,因為 IGameBoard 介面和 SimplegameBoard 都還沒有宣告和定義,接下來為了使這個案例通過,我在 TicTacToeLib 工程裡,宣告和定義 IGameBoard 和 SimpleGameBoard 類,IGameBoard 是純抽象類,抽象了所有對棋盤的操作。引入宣告到測試工程中,編譯通過並執行,現在完成了第一測試用例,儘管測試的 IGameBoard 和 SimpleGameBoard 還是空的。可以看一下輸出:

圖 4 .測試用例輸出

圖 4 .測試用例輸出

2.我需要在棋盤上下棋和獲取到棋子

這個需求能使棋手在棋盤上把棋子放到想要的位置上並能檢視指定棋盤位置上的棋子,棋盤是 3×3。實現這個需求也很簡單,我只要在 IGameBoard 介面上新增兩個函式然後在 SimpleGameBoard 裡實現這兩個函式就可以滿足這個需求:

試著編譯這個測試工程,失敗,原因是沒有實現這兩個函式,接下來我回到 TicTacToeLib 工程去宣告和定義這兩個函式。為了實現這兩個功能,在 SimpleGameBoard 定義 private 資料:vectorchar> data_;用於 儲存棋子和位置資訊,為了簡單,棋子用 Char 型別來表示,位置資訊和 data_向量的下標對應,如棋盤位置(2,2)對應的是 data_[2*3+2]這個位置,資料是安行存放的。兩個函式的實現是:

initboard_()是個 protected 函式,用於初始化 data_。 現在可以重現編譯和執行測試工程,結果如下:

圖 5 .測試用例輸出

圖 5 .測試用例輸出

有了兩個測試用例的實現,並且執行是綠色,繼續下個需求。

3.我要能驗證和判斷是不是三個棋子在同一條線上,以判斷是不是有人勝出

這個需求用於判斷三個棋子是否已經在一條線上,如果是的話,那麼持對應棋子的棋手就會勝出,這個測試用例可以這樣設計:

設計是這樣的,為簡單,我把判斷棋子勝出的函式 CheckWinOut 定義到介面 IGameBoard 中,並在 SimpleGameBoard 中實現它,實現如下:

IsThreeInLine_是受保護的成員函式,它會掃描棋盤的行,列和對角線看是否指定的棋子在一條線上,如果有三個棋子在一條線上,則說明有人勝出。編譯執行測試,綠色通過。 繼續下一個需求。

4.我不能放棋子到已被佔用的棋位置上。

這個需求是個驗證性需求,要保證棋子不能重疊和覆蓋已在棋盤上的棋子,實現這個需求我只要重構現有的程式碼加上避免棋子重疊的邏輯。只要避免在 PutChess 時候,檢查是否指定的位置是否已有棋子,如果是簡單的丟擲異常即可。有了這些基本的思路,我開始設計測試用例。

ChessOverlapException 是我將要實現的一個異常類,這個是在棋手試圖放棋子到已有棋子的棋盤位置上時要丟擲的異常。測試用例中,我在(0,0)和(2,2)這兩個位置上放同樣的棋子以觸發這個異常。為了編譯通過,我開始實現 ChessOverlapException。 ChessOverlapException 繼承自 std::exception 我過載了 what 函式返回相應的異常資訊。 把這個異常類的定義引入的測試工程中,編譯通過執行測試,但卻得到了紅色 Red,案例失敗:

圖 6.測試用例輸出

圖 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 模擬。可以看參考文獻獲取更多的資訊。 限於篇幅不再贅述。一下是完成所有測試用例的測試結果。

圖 7.測試用例輸出

圖 7.測試用例輸出

或許你會注意到有些測試用例的設計,只是以點蓋面,如果想要更多的驗證點可以藉助於 Gtest 提供的引數化測試設計測試資料,然後去測試實現的類和邏輯。 還有死亡測試的用例,可以在參考資源中的 Gtest 資源中檢視。

結論

C++中實現測試驅動開發 TDD 之前是很困難的事。 但有了類似於 xUnit 的 Gtest 和 Gmock 測試框架,在 C++工程中實現 TDD 也變得很享受。測試驅動開發是一個很好的工具,它可以幫助開發者實現有機開發,在需求的實現過程中快速得到反饋,另一個好處是測試驅動開發可以使開發人員更加重視需求和測試,以測試用例為中心,這樣勢必會產生更好程式碼。從軟體工程的角度來說,測試驅動開發的實踐應用會大幅度的提高軟體開發的質量,用程式碼級別的測試用例來覆蓋和保障程式的健壯性更能保障整個軟體產品的開發質量。

測試驅動開發的座右銘模式:紅色-綠色-重構,然後重複這個直到開發完成為止,是一個自我確認和有保護程式碼重構的過程。採用測試驅動開發的模式的軟體產品,產生的單元測試程式碼,從程式碼級別測試覆蓋了軟體的需求,使以後的程式碼重構更安全可靠。

相關文章