TDD從何開始
萬事開頭難。在TDD中,人們糾結最多的可能是這樣一個問題:如何寫第一個測試呢?實際上要視不同的問題而定。如果問題本身是一個算法求解,或者是一個大系統中的小單元,那么可以從最簡單、最直觀的情況出發,這樣有助于快速建立信心,形成反饋周期。但是在實際的開發中,很多時候我們拿到的就是一個“應用級”的需求:一個網上訂票系統,一個網上書店,憤怒的小鳥,諸如此類。此時,我們如何TDD呢?一種很自然的想法是:
先對系統做簡單的功能分解,形成概念中的相互協作的小模塊。然后再從其中的一個小模塊開始(往往是最核心的業務模塊)TDD。我們把這種方式權且稱為inside-out,也就是從部分到整體。這種方式可能存在的風險是:即使各個部分都通過TDD的方式驅動出來,我們也不能保證它們一起協作就能是我們想要的那個整體。更糟糕的是,直到我們把各個部分完成之前,我們都不知道這種無法形成整體的風險有多大。因此這對我們那個“概念中模塊設計”提出了很高的要求,并且無論我們當前在實現哪個模塊,都必須保證那個模塊是要符合概念中的設計的。
如果換一種思路呢?與其做概念中的設計,不如做真正的設計,通過寫測試的方式驅動出系統的各個主要模塊及其交互關系,當測試完成并通過,整個應用的“骨架”也就形成了。
例如,現在假設我們拿到一個需求,要實現一個猜數字的游戲。游戲的規則很簡單,游戲開始后隨機產生4位不相同的數字(0-9),玩家在6次之內猜出這個4位數就算贏,否則就算輸。每次玩家猜一個4位數,游戲都會告訴玩家這個4位數與正確結果的匹配情況,以xAyB的形式輸出,其中x表示數字在結果中出現,并且出現的位置也正確,y表示數字在結果中出現但位置不正確。如果玩家猜出了正確的結果,游戲結束并輸出“You win”,如果玩家輸,游戲結束并輸出“You lose”。
針對這樣一個小游戲,有人覺得簡單,有人覺得復雜,但無論如何我們都沒有辦法一眼就看到整個問題的解決方案。因此我們需要理解需求,分析系統的功能:這里需要一個輸入模塊,那里需要一個隨機數產生模塊,停!既然已經在做分析了,為什么不用測試來記錄這一過程呢?當測試完成的時候,我們的分析過程也就完成了。
好吧,從何開始呢?TDD有一個很重要的原則-反饋周期,反饋周期不能太長,這樣才能合理的控制整個TDD的節奏。因此我們不妨站在玩家的角度,從最簡單的游戲過程開始吧。
最簡單的游戲過程是什么呢?游戲產生4位數,玩家一把猜中,You win,游戲結束。
現在開始寫這個測試吧。有一個游戲(Game),游戲開始(start):
Game game =newGame(); game.start(); |
等等,似乎少了什么,是的,為了產生隨機數,需要有一個AnswerGenerator;為了拿到用戶輸入,需要有一個InputCollector;為了對玩家的輸入進行判斷,需要有一個Guesser;為了輸出結果,需要有一個OutputPrinter。真的要一口氣創建這么多類,并一一實現它們嗎?還好有mock,它可以幫助我們快速的創建一些假的對象。這里我們使用JMock2:
Mockery context = new JUnit4Mockery() { { setImposteriser(ClassImposteriser.INSTANCE); } }; final AnswerGenerator answerGenerator = context.mock(AnswerGenerator.class); |
然后我們測試里的Game就變成這個樣子了:
Game game =newGame(answerGenerator, inputCollector, guesser, outputPrinter); game.start(); |
注意到這里為了通過編譯,需要定義上面提到的幾個類,我們不妨以最快的方式給出空實現吧:
public class AnswerGenerator { public class InputCollector { public class Guesser { public class OutputPrinter { |
以及為了通過編譯而需要的Game的最簡單版本:
public class Game { public Game(AnswerGenerator generator, InputCollector inputCollector, Guesser guesser, OutputPrinter outputPrinter) { } public void start() { } } |
好了,下面可以走我們的那個最簡單的流程了。首先是由answerGenerator產生一個4位數,不妨假定是1234:
context.checking(new Expectations() { { one(answerGenerator).generate(); will(returnValue("1234")); } }); |
這里需要我們的generator有一個generate方法,我們給一個最簡單的空實現:
public class AnswerGenerator { public String generate() { return null; } } |
然后玩家猜數字,第一次猜了1234:
context.checking(new Expectations() { // ... { one(inputCollector).guess(); will(returnValue("1234")); } } |
為了使編譯通過我們給inputCollector加上一個空的guess方法:
public class InputCollector { public String guess() { return null; } } |
然后guesser判斷結果,由于完全猜對,因此返回4A0B:
context.checking(new Expectations() { // ... |
同理我們可以推出guesser的一個最簡實現:
public class Guesser { public String verify(String input, String answer) { return null; } } |
最后玩家贏,游戲輸出“You win”,game over:
context.checking(new Expectations() { // ... { |
對應的outputPrinter可以做如下的微調:
public class OutputPrinter { public void print(String result) { } } |
最后別忘了啟動Expectation驗證:
context.assertIsSatisfied(); |
整個測試方法現在看起來應該是這樣的:
@Test public void should_play_game_and_win() { Mockery context = new JUnit4Mockery() { { setImposteriser(ClassImposteriser.INSTANCE); } }; final AnswerGenerator answerGenerator = context.mock(AnswerGenerator.class); final InputCollector inputCollector = context.mock(InputCollector.class); final Guesser guesser = context.mock(Guesser.class); final OutputPrinter outputPrinter = context.mock(OutputPrinter.class); context.checking(new Expectations() { { one(answerGenerator).generate(); will(returnValue("1234")); } { one(inputCollector).guess(); will(returnValue("1234")); } { oneOf(guesser).verify(with(equal("1234")), with(equal("1234"))); will(returnValue("4A0B")); } { oneOf(outputPrinter).print(with(equal("You win"))); } }); Game game = new Game(answerGenerator, inputCollector, guesser, outputPrinter); game.start(); context.assertIsSatisfied(); } |
運行測試,會看到下面的錯誤信息:
java.lang.AssertionError: not all expectations were satisfied expectations: |
太好了,正是我們期望的錯誤!別忘了我們只是在測試中定義了期望的游戲流程,真正的game.start()還是空的呢!現在就讓測試指引著我們前行吧。
先改一改我們的Game類,把需要依賴的協作對象作為Game的字段:
private AnswerGenerator answerGenerator; public Game(AnswerGenerator answerGenerator, InputCollector inputCollector, Guesser guesser, OutputPrinter outputPrinter) { |
然后在start方法中通過answerGenerator來產生一個4位數:
public void start() { String answer = answerGenerator.generate(); } |
再跑測試,會發現仍然錯,但結果有變化,第一步已經變綠了!
java.lang.AssertionError: not all expectations were satisfied |
下面應該使用inputCollector來收集玩家的輸入:
public void start() { String answer = answerGenerator.generate(); String guess = inputCollector.guess(); } |
跑測試,錯但是結果進一步好轉,已經有兩步可以通過了:
java.lang.AssertionError: not all expectations were satisfied |
下面加快節奏,按照測試中的需求把剩下的流程走通吧:
public void start() { String answer = answerGenerator.generate(); String guess = inputCollector.guess(); String result = ""; do { result = guesser.verify(guess, answer); } while (result != "4A0B"); outputPrinter.print("You win"); } |
再跑測試,啊哈,終于看到那個久違的小綠條了!
回顧一下這一輪從無到有、測試從紅到綠的小迭代,我們最終的產出是:
1、一個可以用來描述游戲流程的測試(需求,文檔?)。
2、由該需求推出的一個流程骨架(Game.start)。
3、一堆基于該骨架的協作類,雖然是空的,但它們每個的職責是清晰的。
經過這最艱難的第一步(實際上敘述的過程比較冗長,但反饋周期還是很快的),相信每個人都會對完整實現這個游戲建立信心,并且應該知道后面的步驟要怎么走了吧。是的,我們可以通過寫更多的骨架測試來進一步完善它(比如考慮失敗情況下的輸出,增加對用戶輸入的驗證等等),或者深入到每個小協作類中,繼續以TDD的方式實現每一個協作類了。無論如何,骨架已在,我們是不大可能出現大的偏差了。
posted on 2012-06-26 09:58 順其自然EVO 閱讀(411) 評論(0) 編輯 收藏 所屬分類: 測試學習專欄