先對系統做簡單的功能分解,形成概念中的互相協作的小子產品。然後再從其中的一個小子產品開始(往往是最核心的業務子產品)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):
等等,似乎少了什麼,是的,為了産生随機數,需要有一個answergenerator;為了拿到使用者輸入,需要有一個inputcollector;為了對玩家的輸入進行判斷,需要有一個guesser;為了輸出結果,需要有一個outputprinter。真的要一口氣建立這麼多類,并一一實作它們嗎?還好有mock,它可以幫助我們快速的建立一些假的對象。這裡我們使用jmock2:
mockery context = new junit4mockery() {
{
setimposteriser(classimposteriser.instance);
}
};
final answergenerator answergenerator = context.mock(answergenerator.class);
然後我們測試裡的game就變成這個樣子了:
注意到這裡為了通過編譯,需要定義上面提到的幾個類,我們不妨以最快的方式給出空實作吧:
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方法,我們給一個最簡單的空實作:
然後玩家猜數字,第一次猜了1234:
context.checking(new expectations() {
// ...
{
one(inputcollector).guess();
will(returnvalue("1234"));
}
}
為了使編譯通過我們給inputcollector加上一個空的guess方法:
然後guesser判斷結果,由于完全猜對,是以傳回4a0b:
context.checking(new expectations() {
// ...
oneof(guesser).verify(with(equal("1234")), with(equal("1234")));
will(returnvalue("4a0b"));
}
同理我們可以推出guesser的一個最簡實作:
最後玩家赢,遊戲輸出“you win”,game over:
context.checking(new expectations() {
oneof(outputprinter).print(with(equal("you win")));
}
對應的outputprinter可以做如下的微調:
最後别忘了啟動expectation驗證:
整個測試方法現在看起來應該是這樣的:
@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();
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:
expected once, never invoked: answergenerator.generate(); returns "1234"
expected once, never invoked: inputcollector.guess(); returns "1234"
expected once, never invoked: guesser.verify("1234"); returns "4a0b"
expected once, never invoked: outputprinter.print("you win"); returns a default value
at org.jmock.lib.assertionerrortranslator.translate(assertionerrortranslator.java:20)
at org.jmock.mockery.assertissatisfied(mockery.java:196)
at com.swzhou.tdd.guess.number.gamefacts.should_play_game_and_win(gamefacts.java:54)
太好了,正是我們期望的錯誤!别忘了我們隻是在測試中定義了期望的遊戲流程,真正的game.start()還是空的呢!現在就讓測試指引着我們前行吧。
先改一改我們的game類,把需要依賴的協作對象作為game的字段:
private answergenerator answergenerator;
private inputcollector inputcollector;
private guesser guesser;
private outputprinter outputprinter;
public game(answergenerator answergenerator, inputcollector inputcollector, guesser guesser, outputprinter outputprinter) {
this.answergenerator = answergenerator;
this.inputcollector = inputcollector;
this.guesser = guesser;
this.outputprinter = outputprinter;
然後在start方法中通過answergenerator來産生一個4位數:
public void start() {
string answer = answergenerator.generate();
再跑測試,會發現仍然錯,但結果有變化,第一步已經變綠了!
expected once, already invoked 1 time: answergenerator.generate(); returns "1234"
下面應該使用inputcollector來收集玩家的輸入:
string guess = inputcollector.guess();
跑測試,錯但是結果進一步好轉,已經有兩步可以通過了:
java.lang.assertionerror: not all expectations were satisfied
expected once, already invoked 1 time: inputcollector.guess(); returns "1234"
下面加快節奏,按照測試中的需求把剩下的流程走通吧:
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的方式實作每一個協作類了。無論如何,骨架已在,我們是不大可能出現大的偏差了。
====================================分割線================================
最新内容請見作者的github頁:http://qaseven.github.io/