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