前言
之前有和同事試過,並且用 Pair Programming 的方式進行,
代碼髒得很快,並且進入了死胡同,即使使用 TDD 有測試保護,
還是難以重構。
原因有以下:
- 不夠了解規則,開發到一半才重新解析
- 未經足夠的設計與討論
- Pairs 沒有相同的想法
- Test Case 粒度太大,不夠 Baby Step , Production 常常會多出多餘的代碼
在讀完 TDD By Example 後,我想再試一次,
用 Todo List 方式列下我想開發的項目再轉換成 Test Case,
不看網路上已有的 Solution 進行獨立開發(至少現在不會有 Pairs 的想法相異問題),
不刻意設計物件,讓測試自然趨動整體開發。
開始之前
先改善前幾次的問題,
由於這次由我一個人進行開發, 所以不會有想法不一致的狀況, 實務上或許需要更多的溝通,
規則的部份我參考,
詳細內容如下 :
1 | - Each game, or “line” of bowling, includes ten turns, or “frames” for the bowler. |
有了基本規則後, 我要參考 TDD By Example 一書的作法,
寫下 Todo List 用來記錄我要作的事情, 當然這也會是一份湧現式的清單.
第一次 Kata 的 Todolist
我想像中的 BowlingGame 會提供一個計算方法,
透過傳入一組整數列,回傳目前的分數,
過程中如果有目前 Todo List 沒考慮到的東西會逐步加上
- 計算總分
- 計算回合分數
- 一回合兩次擊球,沒有全倒
- Spare,加上額外一擊的分數
- Strike,加上額外二擊的分數
- API,給定一個數列,回傳一個分數
- 0 分不等於沒有分
- 初始分數是沒有分
第一次 Kata 中斷時的 Todolist
- 計算總分
- 計算回合分數
- 一回合兩次擊球,沒有全倒
- 前面有 Spare 或 Strike 的計算
- Spare,加上額外一擊的分數
- Strike,加上額外二擊的分數
- API,給定一個數列,回傳一個分數
- 0 分不等於沒有分
- 初始分數是沒有分
- 第一球就洗溝,
0 分沒有分- 第二球就打倒1瓶,0 分
- 第一球就打倒1瓶,
1 分沒有分- 第二球就打倒 0 瓶,1 分
- 最後一回合的計算
- Frame 回合的概念
- 消除重複的 null
- 第一次就全倒就是一個 Frame
- FirstTry
- 打兩次就是一個 Frame
- Strike Frame 的分數是 null
第一次 Kata 的檢討
設計上仍然不足, 單純只想靠測試 → 開發其實是有點鄉愿的,
TDD 的概念應該是以 Client(TestCase)的角度去使用 Production Code,
這個案例中, 我想設計的 API 是一次將目前擊倒的瓶數組合成一個 List 傳給 BowlingLine,
計算後回傳總分.
這樣的設計, 對 Client 來說簡單好用, 但是對 BowlingLine 來說似乎職責太多了,
另外 Frame 的概念就消失在 Client 的視野之中, 但 BowlingLine 應該要能夠區分出 Frame
所以我預計寫下 Frame 的測試案例. 再來, 我們發現分數在某些情況是尚未決定的,
比如說擊出 Strike/Spare 或是只擊出該 Frame 的第一次時, 是無法計分的.
經過第一次 Kata 後重塑對 Bowling 的認知
重塑認知
- 總分是 Frame 的分數的加總
- Frame 的分數由兩次 try 與 bonus 作計算
- 兩次 try 的加總等於 10 才有 bonus
- 有 bonus 的話必須計算完 bonus 才有分數
第二次 Todolist
- Frame 的分數是 2 次 try 的加總加上 bonus
- 一個 Frame 未 try 過 2 次的分數是 null
- Try 的分數計算方式是加法
- Bonus 的計算方式
- 有 Bonus 但是還未計算的分數為 null
- Game 的總分是 Frame 的分數的加總
第一次 Kata 的遺留代碼
1 | public class BowlingLine |
Frame 的實作
可以看到這些遺留代碼, 雖然可以通過目前的所有測試, 但是想更進一步的時候確寸步難行.
主因是我們的設計上缺乏 Frame 的概念, 由此我會先撰寫 Frame 的測試案例
1 | [ ] |
1 | public class Frame |
有了 Frame 之後我要來處理之前第一次 Kata 產生的遺留代碼
首先, Game 的總分是 Frame 的分數的加總 這條規則吸引了我,
理論上所有只有一個 Frame 的測試, 在我用 Frame 的寫法後,
測試應該都會通過. 而且幸運的是, 我之前的測試只有 2 個測試的情境進行到了 2 個 Frame,
所以頂多壞 2 個測試, 我可以嘗試修復它.
修改成使用 Frame 的方式
1 | public int? Calculate(List<int> fellPins) |
有了 Frame 的概念後, 我們可以逐一將每個被擊倒的球瓶組成一個個 Frame
Loop 處理 Frame
先看一下目前的代碼
1 | public int? Calculate(List<int> fellPins) |
我們建一個 For Loop 目標要將這些醜醜的 if 判斷式移到 Loop 之中
結果大致如下, 過程當然也是逐步的抽離
1 | public int? Calculate(List<int> fellPins) |
前面的 Frame 在計算分數的時候, 並未考慮 Strike 或是 Spare 完成 Bonus 的情況 ,
所以接下來我們會用測試案例來趨動, 而最小的案例就是只有兩個 Frame 的計分狀況
比如說, 這樣的測試案例
1 | Assert.Equal(13, _line.Calculate(new List<int> { 3, 7, 2, 1 })); |
另一方面, 即使算分正確,
Frame 的個數也引起我的注意, 因為有時候 Frame 裡面只會有一次 Try Hit
擔心的事就用測試作為保護吧
1 | _line.Calculate(new List<int> { 3, 7, 2, 1 }); |
Bonus
Bonus 也是我寫法改變最多的地方之一
我有用過 Flag, 計數器, 最後我選擇了 Type
1 | _frames = new(); |
1 | _frames = new(); |
1 | _frames = new(); |
不過更重要的是, 為什麼 Bonus 與 BowlingLine 有關?
我們已知這個 Frame 與接下來兩次的擊球數與 Bonus 才有正相關,
所以我應該把這個職責移到 Bonus 身上, 原始判斷 Strike 與 Spare 的邏輯,
SetBonus 的邏輯, 也應該一併移到 Frame 身上, 這也是 OOP 的體現
原始代碼如下, 真是有夠糟糕的
1 | public int? Calculate(List<int> fellPins) |
下面的代碼已經將 Bonus 相關邏輯移到了 Frame 之中
1 | public int? Calculate(List<int> fellPins) |
最後透過幾個重構的技巧可以讓這段代碼更好理解
- 共用 Field(這個作法是否適合還可以討論)
- Extact Method
- Inline Variable
結果如下,更多可以參考分支
1 | public List<Frame> FrameList { get; private set; } = new(); |
結語
這樣的結果其實還沒有完成, 我接下來將會測試一些邊際
或是不合理的輸入與呼叫. 過程中的幾個亮點仍然是讓人非常的開心
- Frame 的概念
- 沒有分的計算
- Bonus 職責的轉移
- 重構
其實還有一個概念沒有被寫出來,
那就是擊出 4 球計算一個 Frame 的分數,
再有一次 Kata 的話我或許會用這個概念下去實作.
參考
- https://www.bowlinggenius.com/
- https://kata-log.rocks/bowling-game-kata
- Adventures in C#: The Bowling Game
- https://codingdojo.org/kata/Bowling/
(fin)