TDD从何开始

news/2025/2/26 22:14:01

万事开头难。在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 = new Game();
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 = new Game(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() {                   

    // ...                                               
                                                        
    {                                                   
        oneOf(guesser).verify(with(equal("1234")), with(equal("1234")));                    
        will(returnValue("4A0B"));                      
    }                                                  
} 

同理我们可以推出guesser的一个最简实现:

public class Guesser {
    public String verify(String input, String answer) {
        return null;
    }
}

最后玩家赢,游戏输出“You win”,game over:

context.checking(new Expectations() {   

    // ...

    {                                   
        oneOf(outputPrinter).print(with(equal("You win")));     
    }                                   
}

对应的outputPrinter可以做如下的微调:

public class OutputPrinter {
    public void print(String result) {

    }
}

最后别忘了启动Expectation验证:

context.assertIsSatisfied();

整个测试方法现在看起来应该是这样的:

完整的测试方法
 1 @Test                                                                             
 2 public void should_play_game_and_win() {                                          
 3     Mockery context = new JUnit4Mockery() {                                       
 4         {                                                                         
 5             setImposteriser(ClassImposteriser.INSTANCE);                          
 6         }                                                                         
 7     };                                                                            
 8     final AnswerGenerator answerGenerator = context.mock(AnswerGenerator.class);  
 9     final InputCollector inputCollector = context.mock(InputCollector.class);     
10     final Guesser guesser = context.mock(Guesser.class);                          
11     final OutputPrinter outputPrinter = context.mock(OutputPrinter.class);        
12                                                                                   
13     context.checking(new Expectations() {                                         
14         {                                                                         
15             one(answerGenerator).generate();                                      
16             will(returnValue("1234"));                                            
17         }                                                                         
18                                                                                   
19         {                                                                         
20             one(inputCollector).guess();                                          
21             will(returnValue("1234"));                                            
22         }                                                                         
23                                                                                   
24         {                                                                         
25             oneOf(guesser).verify(with(equal("1234")), with(equal("1234")));      
26             will(returnValue("4A0B"));                                            
27         }                                                                         
28                                                                                   
29         {                                                                         
30             oneOf(outputPrinter).print(with(equal("You win")));                   
31         }                                                                         
32     });                                                                           
33                                                                                   
34     Game game = new Game(answerGenerator, inputCollector, guesser, outputPrinter);
35     game.start();                                                                 
36                                                                                   
37     context.assertIsSatisfied();                                                  
38 }                                                                                 

运行测试,会看到下面的错误信息:

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();
}                                              

再跑测试,会发现仍然错,但结果有变化,第一步已经变绿了!

java.lang.AssertionError: not all expectations were satisfied
expectations:
expected once, already invoked 1 time: 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)

下面应该使用inputCollector来收集玩家的输入:

public void start() {                          
    String answer = answerGenerator.generate();
    String guess = inputCollector.guess();     
}                                              

测试,错但是结果进一步好转,已经有两步可以通过了:

 java.lang.AssertionError: not all expectations were satisfied
expectations:
expected once, already invoked 1 time: answerGenerator.generate(); returns "1234"
expected once, already invoked 1 time: 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)

下面加快节奏,按照测试中的需求把剩下的流程走通吧:

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 repository里了,欢迎大家参考。

转载于:https://www.cnblogs.com/swing-zhou/archive/2012/05/29/2522517.html


http://www.niftyadmin.cn/n/673084.html

相关文章

触发器不能读它的问题

http://space.itpub.net/7728585/viewspace-718992 报错如下: SQL> update GPPAYMENTFUND set attribute51 where fundapplyno 20120314500102010001; update GPPAYMENTFUND set attribute51 where fundapplyno 20120314500102010001 ORA-04091: 表 ACDEP.GPPAYM…

北京银行(601169))今日申购全攻略

2007-9-11 8:28:00 代码:601169 作者:来源: 出处: 顶点财经加入收藏复制链接给好友跳到低部北京银行(601169) 新股发行网上申购日:2007-09-11,发行方式:网下向询价对象配售和网上定价发行相结合,发行数量:12亿股,申购上限:8.4亿股…

UVa 409 - Excuses, Excuses!

1A,呵呵。 注意不区分大小写,如果单词重复也要算的,还有单词要合法,就是哲句话“A keyword occurs" in an excuse if and only if it exists in the string in contiguous form and is delimited by the beginning or end of the line o…

vim按键功能总览表格

转载于:https://www.cnblogs.com/CodeWorkerLiMing/archive/2012/06/09/2543328.html

黑石6亿美元战略入股蓝星20%

http://www.sina.com.cn 2007年09月11日 02:20 中国证券网-上海证券报黑石6亿美元战略入股蓝星20%点击此处查看全部财经新闻图片记者 陈其珏 蓝星与美国私人股本集团黑石一段传得沸沸扬扬的“姻缘”终于浮出水面。昨天,中国化工集团(下称“中国化工”&a…

理解REST软件架构

一种思维方式影响了软件行业的发展。REST软件架构是当今世界上最成功的互联网的超媒体分布式系统。它让人们真正理解我们的网络协议HTTP本来面貌。它正在成为网络服务的主流技术,同时也正在改变互联网的网络软件开发的全新思维方式。AJAX技术和Rails框架把REST软件架…

葛 洲 坝(600068):整体上市 巨额订单

2007-9-11 16:01:00 代码:600068 作者:来源: 东方证券 出处: 加入收藏复制链接给好友跳到低部大盘高位震荡,投资者畏高心理有所抬头,但在目前流动性过剩的现状下,新资金的涌入依然是十分踊跃的,并且市场仍然存在着可令投资者疯狂…

[原]关于线性递归与尾递归

作为读书笔记使用: 线性递归: 1 fac(0) -> 1; 2 fac(N) -> N*fac(N-1). 尾递归: 1 fac(0,Sum) -> 2 Sum; 3 fac(N,Sum) -> 4 fac(N-1,Sum*N). 尾递归定义: 函数最后一步调用自身,即最后一行代…