天天看點

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):

  等等,似乎少了什麼,是的,為了産生随機數,需要有一個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/