首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >一个完整的TDD演练案例(四)

一个完整的TDD演练案例(四)

作者头像
张逸
发布2019-03-07 14:42:31
7880
发布2019-03-07 14:42:31
举报
文章被收录于专栏:斑斓斑斓

逸言

张逸的胡言乱语

说明:本讲义是我在ThoughtWorks作为咨询师时,为客户开展TDD Code Kata而编写。案例为Guess Number,案例需求来自当时的同事王瑜珩。当时,我们共同在ThoughtWorks的Zynx交付团队,为培养团队TDD能力进行训练时,引入了本案例。讲义中给出的代码问题则来自客户方的受训学员,可谓“真实的代码坏味道”。个人认为TDD不只是开发方法,还应该是设计方法,因此讲义中包含了诸多设计原理、思想和原则。

第四个任务

还剩下两个任务:

  • 记录并显示历史猜测数据
  • 判断游戏结果

究竟应该选择哪一个任务作为第四个任务,并没有定论。从业务逻辑看,“判断游戏结果”任务更重要,它才是整个游戏的核心逻辑。可从技术实现看,“判断游戏结果”可以依赖“记录并显示历史猜测数据”。因为分析“判断游戏结果”任务,实际上做了两件事:其一是判断猜测次数是否超过指定的6次;其二是判断每次猜测的结果。第二件事已经被我们开发的第二个任务覆盖。而对于测试次数而言,如果我们记录了历史猜测数据,那么这个次数也可以唾手可得。

讨论:测试驱动开发需要事先设计吗?

Martin Fowler的文章Is Design Dead?其实就是对此问题的正本清源。由于测试驱动开发提倡“测试先行,简单设计”,许多人就误认为TDD不需要设计,以讹传讹之下,甚至导致许多优秀的设计者抛弃了设计去实践TDD,最后得出TDD不可行的结论。

我个人认为,视场景而定,测试驱动开发仍可进行事先设计。设计并不仅包含技术层面的设计如对OO思想乃至设计模式的运用,它本身还包括对需求的分析与建模。若不分析需求就开始编写测试,就好像没有搞清楚要去的地方,就开始快步前行,最后发现南辕北辙。测试驱动开发提倡的任务分解,实际上就是一种需求的分析。而如何寻找职责,以及识别职责的承担者则可以视为建模设计。测试驱动更像是一种培养设计专注力的手段,就像冥想者通过盘腿静坐的手段来体悟天地一样,测试驱动可以强迫你站在测试的角度(就是使用者的角度)去思考接口,如此才能设计出表现意图的接口。但编写测试自身并不能取代设计,正如盘腿静坐并不等于就是冥想。

在开始测试驱动开发之前,做适度的事先设计,还有利于我们仔细思考技术实现的解决方案。它与测试驱动接口的设计并不相悖。解决方案或许属于实现层面,若过早思考实现,会干扰我们对接口的判断;但完全不理会实现,又可能导致设计方向的走偏。举例来说,如果我们要实现XML消息到Java对象的转换。一种解决方案是通过jaxb将消息转换为Java对象,然后再定义转换映射的Transformer,通过硬编码或者反射的方式将其转换为相关的领域对象。然后在执行了业务操作后,再将返回的结果转换为另一个Jaxb对象。而另一种解决方案则是通过引入模板,例如StringTemplate或者Velocity,定义转换的模板,然后进行替换实现。这两种解决方案的区别,直接影响了我们划分任务的方式。

我们选择“记录并显示历史猜测数据”作为第四个任务。同样,对于此任务,我们要事先考虑清楚,究竟应该由谁来承担这个职责?恩,注意,这里其实包含了两项任务:记录与显示。当我们看到类似“和”、“或者”等并列连接词时,都应该思考它是否表达了多个职责?因此,对于第四个任务,我们应该稍稍拆分一下,分解成两个任务:

  • 记录历史猜测数据;
  • 显示历史猜测数据;

那么应该谁来“记录历史猜测数据”?我们应该寻找承担该职责的对象。

知识:寻找职责的承担者

寻找职责的承担者,其实就是寻找某个可以承担该职责的角色。角色又是什么?想象我们现实世界中的角色。看看我们身边,是否角色遍地可寻?BA角色负责分析需求,DEV角色负责实现功能,QA角色负责测试功能是否正确,PM角色负责管理整个项目的进度与项目成员。我们是依据什么来划分角色的?——能力。能力的体现是什么?除了诸多素质要求,最直接的体现就是“知识”。因此,所谓“角色”,就是拥有了相关“知识”从而具有相关“能力”的人。

什么角色应该记录历史猜测数据呢?那就是要寻找谁具有记录历史猜测数据的能力。于是推之于知识,就是谁拥有每一次猜测的数据。显然,Game拥有当前猜测的数据,因此承担责任的应该为Game。

现在,开始编写测试。既然已经辨别出Game对象,就应该针对它编写测试方法,让我们还是从测试方法的业务逻辑描述开始吧:

public class GameTest {     
    private final Answer actualAnswer = Answer.createAnswer("1 2 3 4");     
    private Game game;     


    @Before     
    public void setUp() throws Exception {         
        AnswerGenerator answerGenerator = mock(AnswerGenerator.class);      
        when(answerGenerator.generate()).thenReturn(actualAnswer);         
        game = new Game(answerGenerator);     
    }     


    @Test     
    public void should_record_every_guess_result() {       
        game.guess(Answer.createAnswer("2 1 6 7"));       
        game.guess(Answer.createAnswer("1 2 3 4"));       

        List<GuessResult> guessHistory = game.guessHistory();         

        assertThat(guessResults.size(), is(2));         
        assertThat(guessResults.get(0).result(), is("0A2B"));         
        assertThat(guessResults.get(0).inputAnswer().toString(), is("2 1 6 7"));         
        assertThat(guessResults.get(1).result(), is("4A0B"));         
        assertThat(guessResults.get(1).inputAnswer().toString(), is("1 2 3 4"));     
    }
}

在这里,实际上我驱动出了Game的guessHistory()方法,同时还得到了一个封装了猜测结果的GuessResult对象。与第一个任务不同的是,我没有使用字符串来表示猜测结果,这是因为这里的历史猜测数据不仅包含了猜测结果,还包含了当前的测测数据。

现在,应该考虑“显示历史猜测记录”的任务了。这个功能就是要在猜测了数字之后,在控制台显示历史猜测记录。虽然是控制台,我们仍然认为这属于界面的工作。TDD根本就不应该用来驱动界面设计,还是将注意力放到业务逻辑上来吧。抛开界面,这里的逻辑就转换为:

当用户猜测了数字后,应该显示历史猜测记录。

将界面与业务逻辑分开体现了“关注点分离”原则,也是表现层设计的常用做法。最常见的处理界面设计的模式就是MVC模式。因此在这里可以引入GameController类,就目前而言,它可以负责Game与GameView的协作,所以相应的还可以为界面显示定义一个专属的View对象。

虽然在这里是用控制台显示历史猜测数据信息,实现非常简单,直接调用System.out.println()方法即可,然而我们却很难测试控制台是否显示了该信息。虽然有一些框架也提供了Mock控制台的功能,但就TDD而言,这样的测试并无实际意义。我们需要合理地辨别在功能实现中,哪些内容适合编写自动化测试,哪些内容适合人工测试。因此,这里可以引入Mock框架来模拟GameView,我们只需验证Controller与View之间的协作即可。这时,测试还有助于我们设计出可测试性好的类。

因为是Controller,需要接受用户输入,而非直接传入答案的字符串值。同理,我们在TDD中也不可能测试业务逻辑与控制台的交互。因此,同样需要引入InputCommand类型来封装输入逻辑,然后以Mock框架来模拟InputCommand。 故而,我们为该功能编写的测试为:

public class GameControllerTest {     
    @Mock     
    private GameView mockGameView;     
    @Mock     
    private InputCommand mockCommand;     
    @Mock     
    private AnswerGenerator mockGenerator;     

    private Game game;     
    private Answer correctAnswer;     
    private Answer errorAnswer;     
    private GameController gameController;     

    @Before     
    public void setUp() throws Exception {         
        MockitoAnnotations.initMocks(this);      
   
        correctAnswer = Answer.createAnswer("1 2 3 4");         
        errorAnswer = Answer.createAnswer("1 2 5 6");   
      
        when(mockGenerator.generate()).thenReturn(correctAnswer);         
        game = new Game(mockGenerator);         
        gameController = new GameController(game, mockGameView);     
    }         

    @Test     
    public void should_display_guess_history_message_when_guess_number_twice() {         
        //given         
        when(mockCommand.input()).thenReturn(errorAnswer);         
        GameController gameController = new GameController(game, mockGameView);  

        //when         
        gameController.play(mockCommand);         

        //then       
        verify(mockGameView).showGuessHistory(anyList());     
    }
}

在编写该测试之前,我们实则做了一部分设计与分析工作,辨别各种职责以及承担这些职责的对象,尤其重要的是,要分辨出它们之间的协作方式。对协作的分析应以被测对象为主。一旦分析清楚,就应该编写测试,通过测试来驱动对象之间的协作方式。在编写的测试中,参与协作的其他对象都可以通过Mock来模拟,不一定要有实现,只需体现它们的接口即可。

例如,在当前这个测试中,除了之前已经处理过的Game与AnswerGenerator之间的协作外,我主要考虑了InputCommand与GameView之间的协作方式,其中包括:三者之间的依赖注入,例如GameView作为构造函数的参数,因为一个GameController对象应对应一个GameView对象;而InputCommand则作为play()方法的输入参数。这里的GameController的接口就是通过测试驱动获得的。由于我们测试的是历史猜测结果是否显示,因此使用了Mockito框架的verify方法对这种对象之间的协作进行了验证。之所以在验证逻辑中没有验证具体的猜测结果是否正确,是因为这个逻辑已经在Game的测试中覆盖;而对于GameController,我们需要验证的逻辑只限于“是否显示历史猜测数据”,而非“显示了什么样的历史猜测数据”。

注意:这里创建了多个Mock对象,因此使用了Mockito提供的@Mock便捷方式来创建这些Mock对象。

InputCommand可以定义为接口,真正的控制台实现交给了ConsoleInputCommand类。实现如下:

public class ConsoleInputCommand implements InputCommand {     
    private BufferedReader bufferedReader;     

    {         
        bufferedReader = new BufferedReader(new InputStreamReader(System.in));     
    }     

    @Override     
    public Answer input() {         
        try {             
            String inputAnswer = bufferedReader.readLine();             
            return Answer.createAnswer(inputAnswer);         
        } catch (IOException e) {             
            throw new RuntimeException(e.getMessage());         
        }     
    } 
}
本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-02-11,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 逸言 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档