TDD从何开始

    xiaoxiao2022-07-14  144

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

        // ...                                                                                                             {                                                            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();

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

    @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: 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 satisfiedexpectations: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 valueat 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页:http://qaseven.github.io/

    相关资源:敏捷开发V1.0.pptx
    最新回复(0)