背景
本文接《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的時候,第一個問題出現了,只有球門和足球兩個靜態對象,以此判斷進球是否可行?可行嗎?先挖個坑,看看能不能填!
Goal是球門,Ball是足球,只有靜態實例,怎么設計初始值呢?怎么設計進球的判斷邏輯呢?一起分析下吧~
進球的判斷,有兩個條件,第一,這個球越過了球門線(球場邊界隸屬于球門的一部分);第二,越過球門線時的高度低于球門高度。一旦這兩個條件滿足,OK,進球了,應該返回true。
基于此,可以有三個測試用例:
① 當足球的坐標在球門線上,足球的高度不超過球門高度時,返回true;
② 當足球的坐標不在球門線上,返回false;
③ 當足球的高度超過球門高度時,返回false;
那開始單元測試吧~
測試用例
首先,球門應該包含球門線和高度,球門起點,終點,高度;足球位置即三維位置,編寫單元測試如下:
注:這里的Point不是.net中GDI自帶的Point結構體。考慮到跨平臺和松耦合,建議另行定義Point,雖然可能會增加一些編碼的工作量。
調整測試代碼如下:
很顯然,編譯不能通過,因為沒有對紅線標識的接口進行定義,或者沒有引用相應的程序集,在FBGame.Core.DomainService中添加IGoal,IBall,IPoint接口,并添加對IPoint接口的實現類:
完成后,編譯通過~運行測試,失敗了~失敗提示如下:
因為沒有給接口IGoal和IBall賦實例,所以嘛~添加IGoal和IBall的實現類,并在單元測試中給接口賦實例:
運行測試,OK,通過了~不要擔心什么都還沒寫呢,怎么就通過了,只要通過了,就可以開始下一個測試啦~
Pi君再次省去了一些過程,包括添加新的測試用例和對單元測試的重構,運行測試,失敗了,因為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。
根據BDD方式命名單元測試:
看上去有點別扭,不過慢慢就習慣了~(Pi君英語很一般~~)OK,開始構建測試上下文環境吧~
這里Pi君直接使用了Moq框架對IBall,IGoal進行了模擬,再說一遍,單元測試總是希望獨立的,盡可能的減少對其他資源的依賴。
分別模擬了一次進球和不進球,接下來編寫進球的單元測試:
當然,現在是無法編譯通過的,因為還沒有定義GameInfoService的接口聲明及實現類,添加代碼讓編譯通過:
OK,編譯通過,運行測試,也通過了,運氣真好~沒有邏輯實現就通過測試了~是不是都不放心?是啊,Pi君也不放心,那再加一個測試用例:當沒有進球的時候,返回false。
果然,測試沒通過~添加監聽進球的邏輯,讓測試通過~
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()函數被修改為:
Pi君這里省去了一些過程(和之前都是重復的~),以致于看上去不是小步前進~看官們自行腦補吧~~嘿嘿~編譯不通過,添加GetScoreStr()函數的接口和實現,讓編譯通過~
運行測試,失敗了~添加代碼,讓測試通過~
不要郁悶沒有添加邏輯就通過測試(不是第一次提,不羅嗦啦~)。繼續添加測試前,有一個地方需要重構,goal既可以表示球門,也可以表示進球,這樣很容易混淆視聽,避免二義性,球門重命名為GoalDoor,所以與球門相關的名稱都要重構命名(借助VS提供的工具可以很方便的實現重構):
然后,再繼續添加測試用例吧~當A隊進了一個球,那么比分應該變成“1:0”,在A隊進球前,比分為“0:0”,首先需要模擬這個過程,在A隊沒進球前,初始化球門設置,返回初始比分“0:0”,然后模擬A隊進了一個球,比分變為“1:0”:
運行測試,預料之中失敗了~失敗原因如下:
出現了引用實例為空的錯誤!回到之前在處理GameInfoService時,考慮是否添加對球門引用為空的判斷,當時的處理是不添加判斷,在這里我們找到了不添加非空判斷的理由~幫助我們發現程序設計中的缺陷和錯誤~(所以,千萬不要自以為是的添加代碼,總是要搞懂所以然是個好習慣~)
這里出錯的原因是因為測試之前沒有對InGoal方法進行模擬,添加隊InGoal方法的模擬,代碼如下:
運行測試,依然沒有通過,但是這一次是因為斷言失敗了,這是我們預期的結果~
修改GameInfoService的內部邏輯讓測試剛好通過~
OK,測試通過~檢查是否需要重構~......有一個問題:StartListen()始終都沒有實現?!是不是意味著這個函數有可能不是實現功能所必需的,既然如此,重構的時候就先把它刪掉吧~但是,由于GameInfoService是個獨立線程,應該有一個方法可以控制開始或者結束這個線程,是的,如果GameInfoService是個線程類,最簡單的方式是在主線程中開辟新線程來調用GameInfoService的運行,如此以來,更不需要StartListen()方法,果斷刪掉。
對于“1:0”的比分,暫時還不是很滿意,增加一個B隊的進球,讓比分持平吧,代碼描述測試用例:
首先,模擬A進球和B進球:
運行測試,通過~考慮重構,將模擬進球的代碼進行方法提取(有重復代碼!):
到此,可以開始下一個功能~
功能2.2.3——更新球權狀態
同樣屬于GameInfoService的功能項,添加測試用例~
球權:掌控足球的球隊,比如A隊某球員控球,此時球權為A隊,反之則是B隊。進球發生以后,需要重新設置球權,A隊進球后,B隊獲得球權,反之B隊進球后,A隊將獲得球權。
編寫測試用例:首先,假設開始比賽的時候,球權歸A隊所有,那么此時通過GameInfoService返回球權為A隊:
添加IGameInfoService接口及實現代碼:
編譯運行測試,通過~考慮重構,測試代碼中出現了大量的重復代碼:
這其實是對游戲信息服務類的初始化,應該放在Setup當中,OK,結合模擬參數將該方法提取至SetUp中,并刪除已經無意義的測試參數:
相應,測試用例修改為:
重構結束后,添加新的測試用例,假設A隊控球后,進球了 ,則重設球權為B隊:
運行測試,失敗了~添加邏輯處理,讓測試通過~
測試通過~OK~不放心,再添加一個測試用例:假設A隊控球后,進球了 ,然后B隊又進球了,則重設球權為A隊:
測試通過~OK,這個功能可以過了~
功能2.2.4——更新足球位置
如果GameInfoService能夠更新足球位置,存在兩種解讀:第一,GameInfoService——>足球(IBall),也就是足球是GameInfoService的一個成員或者足球是單例,可以通過類型獲取唯一實例;第二,足球的位置有單獨的模塊進行計算,只是將計算結果傳給GameInfoService。看官們如果是你,你怎么選呢?有木有一些原則性的東東可以作為選擇的依據?嘿嘿,當然有~
兩個理由:
首先,GameInfoService類主要是記錄游戲信息,包括比分,球權,足球位置等等,如果該類還包括控制足球的運動,豈不是發展成為巨型類或者萬能類了(一般起名叫“**Service”的類都容易犯這個毛病),這違反了類的單一職責原則;
其次,在《TDD(測試驅動開發)項目實踐——開發實戰(一)》羅列的核心功能項中有一條:
⑤ 足球根據受控狀態,加速度,速度,位置,方向,時刻更新足球位置;
即,設計中,已經有單獨的類來處理足球的位置更新,所以巴拉巴拉巴拉~~既然如此,GameInfoService的這個功能就非常簡單啦~可以不用測試,直接寫實現吧~
本章小結
本章有關用戶場景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
源碼會隨著文字的更新而更新。