微信号:YiYan_OneWord

介绍:文学与软件,诗意地想念.

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

2018-06-26 09:34 我是张逸

标签 | TDD Java

字数 | 3663字

阅读 | 10分钟


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

目标收益

  • 熟悉IDE快捷键;

  • 掌握TDD基本知识;

  • 识别代码坏味道,熟练运用重构手法;

  • 熟悉JUnit与Mockito框架;

  • 了解Google Guice框架;

整体需求

实现猜数字的游戏。游戏有四个格子,每个格子有一个0到9的数字,任意两个格子的数字都不一样。你有6次猜测的机会,如果猜对则获胜,否则失败。每次猜测时需依序输入4个数字,程序会根据猜测的情况给出xAxB的反馈,A前面的数字代表位置和数字都对的个数,B前面的数字代表数字对但是位置不对的个数。

例如:答案是1 2 3 4, 那么对于不同的输入,有如下的输出:

答案在游戏开始时随机生成。输入只有6次机会,在每次猜测时,程序应给出当前猜测的结果,以及之前所有猜测的数字和结果以供玩家参考。输入界面为控制台(Console),以避免太多与问题无关的界面代码。输入时,用空格分隔数字。

任务分解

TDD的一个重要步骤是在分析需求之后,对其进行任务分解。每个任务相当于一个功能点,它们都是可以验证的。在进行TDD时,可以根据具体情况,对任务再进行分解,或者增加一些我们之前未曾发现的任务。


练习:分解任务

我们对Guess Number分解的任务为:

  • 随机生成答案

  • 判断每次猜测的结果

  • 检查输入是否合法

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

  • 判断游戏结果。判断猜测次数,如果满6次但是未猜对则判负;如果在6次内猜测的4个数字值与位置都正确,则判胜


讨论:选择开始的任务

在分解好任务开始测试驱动开发时,我们应该优先选择哪一个任务? 选择的标准包括:

  • 任务的依赖性

  • 任务的重要性

从依赖的角度看,并不一定需要优先选择前序任务,因为我们可以使用Mock的方式驱动出当前任务需要依赖的接口,而不用考虑实现。例如,“随机生成答案”任务与“判断每次猜测的结果”任务之间存在前后序的依赖关系,但实现的顺序却并不需要按照此顺序。

对于任务的重要性,主要是判断任务是否整个系统(模块)的核心功能。一个判断标准是确定任务是功能的主要流程还是异常流程。例如任务“检查输入是否合法”即为异常流程,可以考虑后做。


测试驱动开发

开始第一个任务

我们认为,任务“判断每次的猜测结果”可以作为起始的核心任务。

任务:判断每次的猜测结果

在进行测试驱动时,选择好任务后,就需要对测试用例进行分析。可以假设该任务就是你要实现的一个完整功能,然后从外部调用的角度去思考用例。这体现为两个方面:

  • 选择测试样本;

  • 驱动承担该职责的对象,根据意图设计接口;

选择测试样本的方法请参考实例化需求。例如,这里可以选择全中或全错等样本。通常情况下,编写的第一个测试应该选择最简单的样本。


知识:Specification By Example

由Gojko Adzic的著作Specification By Example(实例化需求),介绍了如何通过实例去分析和沟通需求。它是一组过程模式,可以协助软件产品的变更,确保有效地交付正确的产品。实例化需求的过程分为:

  • 从目标中获取范围

  • 用实例进行描述

  • 精炼需求说明

  • 自动化验证,无须改变需求说明

  • 频繁验证

  • 演进出一个文档系统

更多内容,请参考该书。


注意:单元测试不能针对方法编写测试,而应根据业务编写测试用例。一个测试方法只能做一件事情,代表一个测试样本和一个业务规则。


思考:测试驱动开发的驱动力

设计接口是体现测试驱动开发“驱动力”的重要一点。之所以先编写测试,就是希望开发人员站在调用者的角度去思考,即所谓“意图导向编程”。从调用的角度思考,可以驱动我们思考并达到如下目的:

  • 如何命名被测试类以及方法,才能更好地表达设计者的意图,使得测试具有更好的可读性;

  • 被测对象的创建必须简单,这样才符合测试哲学,从而使得设计具有良好的可测试性;

  • 测试使我们只关注接口,而非实现;


知识:Given-When-Then模式

在编写测试方法时,应遵循Given-When-Then模式,这种方式描述了测试的准备,期待的行为,以及相关的验收条件。Given-When-Then模式体现了TDD对设计的驱动力:

  • 编写Given时,“驱动”我们思考被测对象的创建,以及它与其他对象的协作;

  • 编写When时,“驱动”我们思考被测接口的方法命名,以及它需要接收的传入参数;考虑行为方式,究竟是命令式还是查询式方法(CQS原则);

  • 编写Then时,“驱动”我们分析被测接口的返回值;


知识:CQS原则

CQS原则,即命令-查询分离原则(Command-Query Separation),是指一个函数要么是一个命令来执行动作,要么是一个查询来给调用者返回数据。但是不能两者都是。


对于任务“判断每次的猜测结果”,我们首先要考虑由谁来执行此任务。从面向对象设计的角度来讲,这里的任务即“职责”,我们要找到职责的承担者。从拟人化的角度去思考所谓“对象”,就是要找到能够彻底理解(Understand)该职责的对象。遵循信息专家模式,大多数情况下,承担职责的对象常常是拥有与该职责相关信息的信息持有者,即所谓“信息专家”。


知识:信息专家模式

信息专家模式(Information Expert)是GRASP模式中解决类的职责分配问题的最基本的模式。

问题:

当我们为系统发现完对象和职责之后,职责的分配原则(职责将分配给哪个对象执行)是什么?

解决方案:

职责的执行需要某些信息(information),把职责分配给该信息的拥有者。换句话说,某项职责的执行需要某些资源,只有拥有这些资源的对象才有资格执行职责。

优点:

  • 信息的拥有者类同时就是信息的操作者类,可以减少不必要的类之间的关联。

  • 各类的职责单一明确,容易理解。


思考:寻找承担职责“判断每次的猜测结果”的对象

可能的答案:Game,Player,Round

提示:应让学员充分思考承担职责的角色,不能在未经分析之前就开始编写测试,从而忽略测试带来的驱动力,甚至忘记一些基本的命名原则和面向对象设计思想。例如,学员可能会将被测类命名为GuessCheck,而被测方法也被命名为guess()check()


知识:命名规则

类命名规则:测试类与被测类的命名应保持一致,通常情况下,测试类的名称为:被测类名称+Test后缀。例如这里的Game类为被测类,则测试类命名为GameTest。

方法命名规则:测试方法应表述业务含义,这样就能使得测试类可以成为文档。测试方法可以足够长,以便于清晰地表述业务。为了更好地辨别方法名表达的含义,ThoughtWorks提倡用Ruby风格的命名方法,即下划线分隔方法的每个单词,而非Java传统的驼峰风格。建议测试方法名以should开头,此时,默认的主语为被测类。例如:

@Test     
public void should_return_0A0B_when_no_number_guessed_correctly(){
    //...    
}

这里的方法可以阅读为:Game should return 0A0B when no number guessed correctly。显然,这是一条描述了业务规则的自然语言。


现在编写测试。由于事先已经明确被测类为Game,编写测试的Given部分,让我们思考如何创建Game对象?是否可以简单地创建?

Game game = new Game();

分析任务,需要判断猜测结果,则必然要求获知游戏的答案。这个答案与Game的关系是什么呢?这里产生的驱动力是如何创建Game对象?为了创建该对象,需要提供哪些准备?这使得我们驱动出Answer类的定义。

讨论:由4个数字组成的答案是否需要封装?

学员容易写出的代码,以如下方式表现答案(Answer):

  • 整数数组

  • 整数类型的可变参数

  • 字符串

第一种方式除了缺乏对整数值的限制外,一个问题还在于暴露了实现细节。第二种方式甚至无法对答案的个数进行限制。第三种方式则与输入有关,使得Game类还要承担解析输入字符串的职责,违背了单一职责原则(说明:在后面,我们为Answer类提供了工厂方法,可以将传入的字符串解析为Answer对象,也即是由Answer承担解析输入字符串的职责,这同时也遵循“信息专家模式”。)


思考:Answer的定义

我们可以从如何构造一个Answer对象着手,看看该如何定义Answer类。

知识:单一职责原则

由Robert Martin提出,该原则指出:就一个类而言,应该只专注于做一件事和仅有一个引起变化的原因。


编写When可以帮助开发者思考类的行为。一定要从业务而非实现的角度去思考接口。例如:

  • 实现角度的设计:check()

  • 业务角度的设计:guess()

注意两个方法命名表达意图的不同。

编写Then实际上是考虑如何验证。没有任何验证的测试不能称其为测试。由于该任务为判断输入答案是否正确,并获得猜测结果,因而必然需要返回值。从需求来看,只需要返回一个形如xAxB的字符串即可。


思考:是否需要将猜测结果封装为类?

至少就目前而言,并没有必要。因为从需求来看,仅仅需要返回一个形如xAxB的字符串而言。这是需要遵循简单设计的要求,不必过度设计。


如前所述,任务“判断每次的猜测结果”存在多个测试样本,例如一个都不对,或者全部正确,又或者值正确而位置不正确等,因而需要编写多个测试。在编写第一个测试时,可以简单实现使得测试快速通过,然后随着多个测试的编写,再驱动出检查输入数值的算法。

根据以上的分析,我们编写的第一个测试如下所示,它遵循了Given-When-Then模式:

@Test    
public void should_return_0A0B_when_no_number_is_correct() {
   //given
   Answer actualAnswer = Answer.createAnswer("1 2 3 4");        
   Game game = new Game(actualAnswer);        
   Answer inputAnswer = Answer.createAnswer("5 6 7 8");      

    //when        
    String result = game.guess(inputAnswer);        

   //then        
   assertThat(result , is("0A0B"));    
}

这个测试已经驱动出了Answer的创建,Game类的定义,guess()接口的定义。在保证编译通过后,应该首先运行该测试。此时测试必然是失败的。为了使该测试快速通过,我们可以简单实现guess()方法,例如直接返回“0A0B”字符串。接着,就可以编写第二个测试。


思考:为何要先运行一个失败的测试?

首先,它能够保证测试框架是没有问题的;其次,它可以避免偶然的成功,因为测试通过不等于实现一定是正确的。


❈ 题图来自Mono诗+歌。

 
逸言 更多文章 Scala实现DSL的框架案例 揭露国软育诚公司的老赖真相 面向接口设计与角色接口 主题数据区的设计 透过文学经典理解软件设计的抽象思想
猜您喜欢 如何成为一个学习高手 【Go聚会专访】链家网平台服务架构师——丁靖 人工智能这一年:Google、微软等科技巨头纷纷作出贡献 再议携程Android动态加载框架DynamicAPK CSS 伪类 :target 的黑科技