TDD(測試驅動開發)項目實踐——開發實戰(二)

TDD-PRACTICE

背景


本文接《TDD(測試驅動開發)項目實踐——開發實戰(一)》開始,前文記述了第0次迭代第一個用戶場景的TDD過程,本文接前文記述第二個場景的TDD過程,Pi君力爭再現小項目實現過程中的各個細節,以此深入體會TDD精妙~

《TDD(測試驅動開發)項目實踐——開發實戰(一)》:http://www.lxweimin.com/p/b5aa6709f6d6

《TDD(測試驅動設計)的項目實踐——需求分析》:http://www.lxweimin.com/p/ae34612e1eeb

用戶場景2


前文有關場景2的分析結果如下:

2 當足球進入A隊球門時,B隊得分,球權交給B隊,在球場中心位置發球繼續開始比賽;

2.1 一個球門類,用來描述球門,傳入足球的三維坐標可以判斷是否進球;

2.2 一個游戲信息服務類,這是一個獨立線程,監控進球發生事件,更新計算比分,更新球權狀態,更新足球位置;

2.3 一個全局變量,球場中心位置。

功能2.1


開始Code的時候,第一個問題出現了,只有球門和足球兩個靜態對象,以此判斷進球是否可行?可行嗎?先挖個坑,看看能不能填!

which_in_the_goal_then_return_the_true

Goal是球門,Ball是足球,只有靜態實例,怎么設計初始值呢?怎么設計進球的判斷邏輯呢?一起分析下吧~

進球的判斷,有兩個條件,第一,這個球越過了球門線(球場邊界隸屬于球門的一部分);第二,越過球門線時的高度低于球門高度。一旦這兩個條件滿足,OK,進球了,應該返回true。

基于此,可以有三個測試用例:

① 當足球的坐標在球門線上,足球的高度不超過球門高度時,返回true;

② 當足球的坐標不在球門線上,返回false;

③ 當足球的高度超過球門高度時,返回false;

那開始單元測試吧~

測試用例


首先,球門應該包含球門線和高度,球門起點,終點,高度;足球位置即三維位置,編寫單元測試如下:

注:這里的Point不是.net中GDI自帶的Point結構體。考慮到跨平臺和松耦合,建議另行定義Point,雖然可能會增加一些編碼的工作量。

調整測試代碼如下:

單元測試

很顯然,編譯不能通過,因為沒有對紅線標識的接口進行定義,或者沒有引用相應的程序集,在FBGame.Core.DomainService中添加IGoal,IBall,IPoint接口,并添加對IPoint接口的實現類:

添加接口文件
IPoint和Point
IBall
IGoal

完成后,編譯通過~運行測試,失敗了~失敗提示如下:

測試失敗信息

因為沒有給接口IGoal和IBall賦實例,所以嘛~添加IGoal和IBall的實現類,并在單元測試中給接口賦實例:

Ball
Goal
給接口賦實例

運行測試,OK,通過了~不要擔心什么都還沒寫呢,怎么就通過了,只要通過了,就可以開始下一個測試啦~

添加新的測試用例

Pi君再次省去了一些過程,包括添加新的測試用例和對單元測試的重構,運行測試,失敗了,因為InGoal()方法的邏輯沒有添加~修改代碼,讓測試通過:

InGoal

運行測試,OK,全部通過啦~檢查業務邏輯代碼和測試代碼,查看是否需要重構~ 命名/重復/單一職責/......OK,貌似暫時不用修改~

功能2.2


2.2 一個游戲信息服務類,這是一個獨立線程,監控進球發生事件,更新計算比分,更新球權狀態,更新足球位置;

功能2.2有多個復合功能,拆分來看:

2.2.1 監控進球發生

2.2.2 更新計算比分

2.2.3 更新球權狀態

2.2.4 更新足球位置

功能2.2.1——監控進球發生


進球發生是由球門來判斷的,游戲信息服務類怎么知道的?因為游戲服務類——>球門,只有存在這種關系,游戲服務類在獲取足球位置的時候才能知道進球事件的發生,進而有后續的行為;游戲服務是個獨立線程,記得功能1.1中設計的TimeCounter也是一個獨立的線程,有多個線程了,他們之間是怎么協調工作的?同步的機制是什么?從核心功能的用戶場景到單元測試,再從單元測試返回到核心功能,敏捷過程本身就是一個快速反饋,不斷迭代的過程,以此將軟件開發的設計,測試,開發,質量等等要素穿在一起~

在發現依賴關系的時候,當然可以選擇繼續考慮單元測試,但是Pi君更傾向于把單元測試作為一種相對獨立的功能單元,TDD過程中如果存在依賴,而且是耦合的依賴,那么很有可能是在設計時劃分功能單元出現了問題(把不該分開的模塊分開了)

為了測試是否獲取進球消息,需要在測試開始構建測試運行的環境,即進球!我們需要模擬一個進球!當然,現在還沒有這個“信息服務類”,但是不管怎樣,先構建測試吧~

首先,在單元測試的FBGame.Core文件夾下添加信息服務類的測試:GameInfoServiceTest。

GameInfoServiceTest

根據BDD方式命名單元測試:

which_in_the_goal_then_return_true_form_GameInfoService

看上去有點別扭,不過慢慢就習慣了~(Pi君英語很一般~~)OK,開始構建測試上下文環境吧~

構建測試上下文環境

這里Pi君直接使用了Moq框架對IBall,IGoal進行了模擬,再說一遍,單元測試總是希望獨立的,盡可能的減少對其他資源的依賴。

分別模擬了一次進球和不進球,接下來編寫進球的單元測試:

which_in_the_goal_then_return_true_form_GameInfoService

當然,現在是無法編譯通過的,因為還沒有定義GameInfoService的接口聲明及實現類,添加代碼讓編譯通過:

IGameInfoService

OK,編譯通過,運行測試,也通過了,運氣真好~沒有邏輯實現就通過測試了~是不是都不放心?是啊,Pi君也不放心,那再加一個測試用例:當沒有進球的時候,返回false。

which_out_the_goal_then_return_false_from_gameinfoservice

果然,測試沒通過~添加監聽進球的邏輯,讓測試通過~

GameInfoService

OK,這個功能已經完成,繼續下一個吧~停!不應該編寫更多的測試來保障代碼的質量嗎?!這是當然,但是就Pi君自己而言,目前已經滿足基本需求,所以過啦~(雖然很顯然,代碼中沒有對_goal是否為空做出判斷,可能會是一個坑!但是,沒有需求上的測試,不要添加自認為有意義的代碼,除非針對這個問題,提出測試案例,讓測試不通過~)

功能2.2.2——更新計算比分


游戲信息服務類在獲取進球之后,需要更新比賽的分數,并將分數返回~在比賽開始時,比分被初始化為“0:0”。即A隊:B隊比分為“0:0”,當A隊進球時,比分應該被更新為“1:0”,這時,如果B隊又進球了,比分應該被更新為“1:1”,以此類推。來編寫單元測試描述這個場景:

這時,我們開始考慮球隊的區分了,A隊,B隊,這是之前沒有考慮的,對于功能2.2.1而言,只要進球即可,至于誰進球,沒有判斷,結合功能2.2.2,需要做一下調整:

調整設置球門函數,添加單元測試

添加第一個測試用例,當比賽開始后,沒有進球發生的時候,獲取比賽分數,應該返回初始比分“0:0”。然后,SetGoal()函數被修改為:

調整GameInfoService

Pi君這里省去了一些過程(和之前都是重復的~),以致于看上去不是小步前進~看官們自行腦補吧~~嘿嘿~編譯不通過,添加GetScoreStr()函數的接口和實現,讓編譯通過~

GetScoreStr()

運行測試,失敗了~添加代碼,讓測試通過~

GetScoreStr()

不要郁悶沒有添加邏輯就通過測試(不是第一次提,不羅嗦啦~)。繼續添加測試前,有一個地方需要重構,goal既可以表示球門,也可以表示進球,這樣很容易混淆視聽,避免二義性,球門重命名為GoalDoor,所以與球門相關的名稱都要重構命名(借助VS提供的工具可以很方便的實現重構):

Goal->GoalDoor重構

然后,再繼續添加測試用例吧~當A隊進了一個球,那么比分應該變成“1:0”,在A隊進球前,比分為“0:0”,首先需要模擬這個過程,在A隊沒進球前,初始化球門設置,返回初始比分“0:0”,然后模擬A隊進了一個球,比分變為“1:0”:

which_A_have_a_goal_then_return_score_1vs0_from_gameinfoservice

運行測試,預料之中失敗了~失敗原因如下:

測試結果

出現了引用實例為空的錯誤!回到之前在處理GameInfoService時,考慮是否添加對球門引用為空的判斷,當時的處理是不添加判斷,在這里我們找到了不添加非空判斷的理由~幫助我們發現程序設計中的缺陷和錯誤~(所以,千萬不要自以為是的添加代碼,總是要搞懂所以然是個好習慣~)

這里出錯的原因是因為測試之前沒有對InGoal方法進行模擬,添加隊InGoal方法的模擬,代碼如下:

添加了球門進球的模擬

運行測試,依然沒有通過,但是這一次是因為斷言失敗了,這是我們預期的結果~

測試結果

修改GameInfoService的內部邏輯讓測試剛好通過~

添加GameInfoService中計分邏輯

OK,測試通過~檢查是否需要重構~......有一個問題:StartListen()始終都沒有實現?!是不是意味著這個函數有可能不是實現功能所必需的,既然如此,重構的時候就先把它刪掉吧~但是,由于GameInfoService是個獨立線程,應該有一個方法可以控制開始或者結束這個線程,是的,如果GameInfoService是個線程類,最簡單的方式是在主線程中開辟新線程來調用GameInfoService的運行,如此以來,更不需要StartListen()方法,果斷刪掉。

對于“1:0”的比分,暫時還不是很滿意,增加一個B隊的進球,讓比分持平吧,代碼描述測試用例:

首先,模擬A進球和B進球:

模擬兩隊進球
which_A_have_a_goal_and_B_have_a_goal_then_return_score_1vs1_from_gameinfoservice

運行測試,通過~考慮重構,將模擬進球的代碼進行方法提取(有重復代碼!):

提取方法來減少重復

到此,可以開始下一個功能~

功能2.2.3——更新球權狀態


同樣屬于GameInfoService的功能項,添加測試用例~

球權:掌控足球的球隊,比如A隊某球員控球,此時球權為A隊,反之則是B隊。進球發生以后,需要重新設置球權,A隊進球后,B隊獲得球權,反之B隊進球后,A隊將獲得球權。

編寫測試用例:首先,假設開始比賽的時候,球權歸A隊所有,那么此時通過GameInfoService返回球權為A隊:

which_start_with_A_control_the_ball_then_return_A_form_gameinfoservice

添加IGameInfoService接口及實現代碼:

添加設置及獲取球權的方法

編譯運行測試,通過~考慮重構,測試代碼中出現了大量的重復代碼:

重復代碼

這其實是對游戲信息服務類的初始化,應該放在Setup當中,OK,結合模擬參數將該方法提取至SetUp中,并刪除已經無意義的測試參數:

重構后的測試環境

相應,測試用例修改為:

重構后的測試用例

重構結束后,添加新的測試用例,假設A隊控球后,進球了 ,則重設球權為B隊:

which_A_have_a_goal_then_return_ball_control_return_B_from_gameinfoservice

運行測試,失敗了~添加邏輯處理,讓測試通過~

添加球權控制邏輯

測試通過~OK~不放心,再添加一個測試用例:假設A隊控球后,進球了 ,然后B隊又進球了,則重設球權為A隊:

which_A_and_B_both_have_a_goal_then_ball_control_return_A_from_gameinfoservice

測試通過~OK,這個功能可以過了~

功能2.2.4——更新足球位置


如果GameInfoService能夠更新足球位置,存在兩種解讀:第一,GameInfoService——>足球(IBall),也就是足球是GameInfoService的一個成員或者足球是單例,可以通過類型獲取唯一實例;第二,足球的位置有單獨的模塊進行計算,只是將計算結果傳給GameInfoService。看官們如果是你,你怎么選呢?有木有一些原則性的東東可以作為選擇的依據?嘿嘿,當然有~

兩個理由:

首先,GameInfoService類主要是記錄游戲信息,包括比分,球權,足球位置等等,如果該類還包括控制足球的運動,豈不是發展成為巨型類或者萬能類了(一般起名叫“**Service”的類都容易犯這個毛病),這違反了類的單一職責原則;

其次,在《TDD(測試驅動開發)項目實踐——開發實戰(一)》羅列的核心功能項中有一條:

⑤ 足球根據受控狀態,加速度,速度,位置,方向,時刻更新足球位置;

即,設計中,已經有單獨的類來處理足球的位置更新,所以巴拉巴拉巴拉~~既然如此,GameInfoService的這個功能就非常簡單啦~可以不用測試,直接寫實現吧~

GameInfoService添加足球軌跡列表
AddGoal中將位置添加至列表

本章小結


本章有關用戶場景2:

2 當足球進入A隊球門時,B隊得分,球權交給B隊,在球場中心位置發球繼續開始比賽;

2.1 一個球門類,用來描述球門,傳入足球的三維坐標可以判斷是否進球;

2.2 一個游戲信息服務類,這是一個獨立線程,監控進球發生事件,更新計算比分,更新球權狀態,更新足球位置;

2.3 一個全局變量,球場中心位置。

的TDD過程就算結束了~歡迎大家留言討論,一起學習,一起進步~也請關注Pi君TDD-Practice系列的看官們繼續期待Pi君的下一篇TDD系列文章《TDD(測試驅動開發)項目實踐——開發實戰(三)》(用戶場景3的TDD過程),有關TDD系列博文的源代碼為FBGame項目源碼的github地址:https://github.com/fei090620/FBGame.git

源碼會隨著文字的更新而更新。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,333評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,491評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,263評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,946評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,708評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,409評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,939評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,774評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,209評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,641評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,872評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,650評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373

推薦閱讀更多精彩內容