TDD编码实战讲义

说明:本讲义是我在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, 那么对于不同的输入,有如下的输出

输入 输出 说明
1 5 6 7 1A0B 1位置正确
2 4 7 8 0A2B 2和4位置都不正确
0 3 2 4 1A2B 4位置正确,2和3位置不正确
5 6 7 8 0A0B 没有任何一个数字正确
4 3 2 1 0A4B 4个数字位置都不对
1 2 3 4 4A0B 胜出 全中
1 1 2 3 输入不正确,重新输入
1 2 输入不正确,重新输入

答案在游戏开始时随机生成。输入只有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开头,此时,默认的主语为被测类。例如:

1
2
3
4
@Test     
public void should_return_0A0B_when_no_number_guessed_correctly(){
//...
}

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


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

1
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模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
@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”字符串。接着,就可以编写第二个测试。


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

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


在编写第二个测试时,由于测试样本与之前的测试完全不一样,之前的简单实现就不能满足新增的测试了。事实上,测试就是要去验证实现逻辑,这其中最重要的测试目标就是分支。不同的分支可能会返回不同的结果,如果我们根据分支来设计测试,就能有效保障实现的正确性。这称为“三角测试法”。

常见问题:

  • 没有将测试代码看做是代码的一部分。当编写多个测试方法时,没有及时重构;例如,应及时将game对象与actualAnswer对象提取为字段,以避免不必要的声明。
  • 直接暴露表达式,而未对表达式进行方法提取,以表达业务意义;
  • guess()方法过长;应该通过提取方法来改进代码的可读性;
  • Game类与Answer类的职责分配不合理,将Answer类设计为仅具有get()set()的数据对象,而将判断数值是否正确、位置是否正确的逻辑分配给了Game。没有考虑get()set()是否真正有必要;如果我们对guess()方法进行了方法提取,可以识别出代码的坏味道“Feature Envy”,即Game的方法用到的都是Answer的属性。这时,应该采用移动方法的重构手法对其进行重构。

开始第二个任务

我们选择的第二个任务为“随机生成答案”,这是一个独立的职责。编写测试类时,很容易驱动出AnswerGenerator类。关键在于,我们该如何编写单元测试来验证生成的结果。我们对结果的要求是:

  • 数字必须是0…9之间;
  • 产生的四个数字不能相同;

讨论:究竟由谁来承担“随机生成答案”的职责?

学员容易将此职责直接分配给Answer。然而,随机生成答案与创建一个答案适用于不同的场景,这对于Answer的调用者而言,并不友好。尤其对于只需要答案的场景,还需要无端地引入对随机数的依赖,显然是不合理的。


编写测试方法的过程与前相似,仍然按照Given-When-Then模式来编写(若测试方法比较简单,可以不遵循这一模式,但思考的过程却应该按照该模式)。

在编写then部分的测试时,可能出现疑问。

问题:如何验证生成的答案是否正确?

我们已经将答案建模为Answer,因此AnswerGenerator的generate()方法要返回的对象类型为Answer。那么,我们怎么知道返回的Answer对象是合法的呢?一种做法是获取Answer的属性,然后再进行验证。那么,为了测试的验证而暴露这些属性,是否适合

要完成对答案正确性的验证,直接暴露答案的属性是不妥当的,至少目前没有获取答案属性的需求。我们的做法是定义一个验证方法。这是否仍然属于为测试而定义行为的做法呢?这个问题有点像鸡与鸡蛋的哲学问题。我们应该还原到设计,看看这种手法是否改善了设计,如此即可。毕竟,这种对答案正确性的校验,也可以说是业务逻辑的一种。

说明:在开始编写“检查输入是否合法”任务时,你会发现,这里所谓多余的验证,就会派上用场。

这个验证方法可以是单纯的返回true或者false,但从需求来看,这个返回结果并没有很好地展现验证要求:究竟是因为数字超出了范围,还是出现了相同的数字?我个人更倾向于用自定义异常来表示生成的答案违背了这两条规则。因此,我们可以为Answer定义一个validate()方法,以验证生成的Answer是否满足规则要求;如果不符合,就抛出对应的异常。


知识:JUnit中对异常的验证

随着JUnit版本的演化,先后提供了三种验证异常的机制。

  • 一种是传统的在测试代码中通过编写try... catch结合fail()方法进行验证。这种方法带来的问题是验证逻辑太繁琐。
  • 第二种方法是利用@Testexpected方法,通过指定异常类型值来验证。它的好处是简单直接,缺点是只能验证抛出异常的类型。
  • 第三种方法是利用ExpectedException Rule。Rule可以更灵活地验证异常,包括异常类型和异常消息。我们也可以通过定义派生自TypeSafeMatcherMatcher类,来验证异常的更多信息。

问题:如何确定测试通过就意味着实现正确?

第二个任务看似简单,实则不然。原因在于这里产生了一个随机数。随机数带来了不确定性,它可能偶然地让测试通过了。也许,运行测试100次,前面的99次都通过了,最后一次失败,仍然视为失败。

生成随机数自然是调用Java的JDK。在单元测试环节中,倘若我们要测试的单元需要调用别的API,则在这个测试中,我们可以假定这个API是正确的。我们对Java JDK的正确性自然信心十足。那么,为何我们还要考虑测试的随机失败?这是因为在这个任务的测试中,我们测试的并非随机数的生成逻辑,而在于随机数的种子是否恰当,实现逻辑中是否判断了可能出现的错误数字?

由于生成随机数的逻辑并非确定无疑的,测试时我们就不能依赖于它。这正是Mock可以派上用场的时候。为此,我们需要将生成随机数的功能提取为类RandomIntGenerator,再注入到AnswerGenerator中。

1
2
3
4
5
6
public class AnswerGenerator {     
private RandomIntGenerator randomIntGenerator;
public AnswerGenerator(RandomIntGenerator randomIntGenerator) {
this.randomIntGenerator = randomIntGenerator;
}
}

该类的实现调用了Java提供的Random类,但在测试时,我们却可以通过Mock它的行为,使得返回的结果变为确定的数字:

1
2
3
4
5
6
7
8
@Test(expected = OutOfRangeAnswerException.class)     
public void should_throw_OutOfRangeAnswerException_which_is_not_between_0_and_9() {
RandomIntGenerator randomIntGenerator = mock(RandomIntGenerator.class);
when(randomIntGenerator.nextInt()).thenReturn(1, 2, 3, 10);
AnswerGenerator answerGenerator = new AnswerGenerator(randomIntGenerator);

answerGenerator.generate();
}

重构:组合Game与AnswerGenerator

在实现第一个任务时,我们定义的Game接受了Answer对象作为游戏的答案。现在,我们定义了AnswerGenerator用以生成符合条件的随机答案。我们当然可以在调用该对象的generate()方法生成答案后,再将该答案作为构造函数参数传递给Game对象。但更好的做法是直接将AnswerGenerator作为构造函数参数传递给Game,在其内部调用它的generate()方法。

开始第三个任务

之所以将“验证输入是否合法”放在第三个任务,是因为它不属于happy path的范畴。它属于辅助业务,重要性相对次之。

提示:对于第三个任务,可以采用Specification By Example的方式来考虑测试用例。

问题:参数 vs. 字段

学员在定义执行该任务的类时,一种可能性是将输入的答案作为类的构造函数参数。例如:

1
new InputValidator("1 2 3 5").validate();

存在两个错误:

  • 错误地判断了输入值的生命周期。什么内容应该放在构造函数中作为参数?换言之,构造函数参数与对象之间的关系是什么?之所以要作为构造函数参数,就是意味着在某种场景下这些参数值应该在创建该对象时就存在。这些参数值与对象“生死与共”,它们的生命周期是保持一致的。如果不是,就不应该作为构造函数的参数。你觉得输入应该作为构造函数吗?如果我要验证另一条输入应该怎么办?再创建一个InputValidator对象吗?
  • 违反了阅读直觉。validate()方法验证谁?验证空吗?显然这样的接口违反了主-谓-宾的语法。

问题:封装的Answer与输入

既然已经封装了Answer对象,为何validate()方法还是要接收字符串类型的输入?阅读需求,已可寻求到答案。

问题:引入InputValidator类型是否有必要?

多数人会认为这里的验证逻辑与Answer相关,根据前面提到的“信息专家模式”,似乎应该将验证逻辑放到Answer中。然而,这里的需求明确地表示了,如果输入不符合要求,就不允许创建该Answer,而是抛出异常。所以,这里的部分验证逻辑是在创建Answer之前就应该存在,当然就不应该由Answer承担了。

针对第三个任务,验证结果的逻辑不应该由boolean型或错误码来表现。对于表达一种错误规则来说,如果你将其看做是一种业务规则,最好的表达方式是采用自定义异常,除非这门语言允许返回两个值(例如Go语言支持返回多个字,但并不支持异常)。对此,在第二个任务中已有描述,这里不再赘述。


重构:Answer的验证逻辑

在开发第二个任务时,我们已经在Answer类中定义了validate()方法。现在,InputValidator类又提供了validate()方法,且其中部分逻辑是相同的。在实现时,应该如何重构现有代码?


开始第四个任务

还剩下两个任务:

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

究竟应该选择哪一个任务作为第四个任务,并没有定论。从业务逻辑看,“判断游戏结果”任务更重要,它才是整个游戏的核心逻辑。可从技术实现看,“判断游戏结果”可以依赖“记录并显示历史猜测数据”。因为分析“判断游戏结果”任务,实际上做了两件事:其一是判断猜测次数是否超过指定的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对象,就应该针对它编写测试方法,让我们还是从测试方法的业务逻辑描述开始吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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"));
}
}

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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来模拟,不一定要有实现,只需体现它们的接口即可。

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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());
}
}
}

开始第五个任务

在开始编写测试之前,先要深入分析该任务表达的需求信息。“判断游戏结果。判断猜测次数,如果满6次但是未猜对则判负;如果在6次内猜测的4个数字值与位置都正确,则判胜。”实际上这里引入了对游戏猜测的控制逻辑,主要是对猜测次数的控制。这样的控制逻辑应该交给谁呢?

多数时候,程序员容易将这样的控制逻辑放到主程序入口处,即main()函数中。这并非恰当的方式。一方面,这里的控制逻辑仍然属于业务逻辑的范畴,不应该暴露给调用者,同时也加大了调用者的负担;另一方面,倘若程序不再作为控制台程序时,例如编写Web Application,主程序入口的内容就要调整,甚至导致这一逻辑的重复。

有了编写第四个任务作为基础,我们很容易判断出该控制逻辑应该交给GameController。编写测试也变得简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class GameControllerTest {     
@Test
public void should_end_game_and_display_sucessful_message_when_number_is_correct_in_first_round() {
//given
when(mockCommand.input()).thenReturn(correctAnswer);

//when
gameController.play(mockCommand);

//then
verify(mockCommand, times(1)).input();
verify(mockGameView).showMessage("successful");
}

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

//when
gameController.play(mockCommand);

//then
verify(mockCommand, times(6)).input();
verify(mockGameView).showMessage("failed");
}
}

这里的两个测试与第四个任务测试“显示历史猜测数据”任务的测试相似,唯一不同的是我们添加了对InputCommand协作的验证,并以Mockito提供的times()方法准确的验证了调用的次数。默认情况下,verify验证的次数为1,但我在第一个测试中仍然给出了times(1),是希望在测试中明确的表示它被执行了一次。

通过编写测试,我们驱动出了GameControllerInputCommandGameView之间的协作关系,并且还驱动出showMessage()方法。如果你觉得showMessage()方法的定义太过宽泛,也可以定义showFailure()showSuccess()方法来体现这里表达的业务逻辑。

GameController的实现就变简单了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class GameController {     
private static final int MAX_TIMES = 6;
private Game game;
private GameView gameView;

public GameController(Game game, GameView gameView) {
this.game = game;
this.gameView = gameView;
}
public void play(InputCommand inputCommand) {
GuessResult guessResult;
do {
Answer inputAnswer = inputCommand.input();
guessResult = game.guess(inputAnswer);
gameView.showCurrentResult(guessResult);
gameView.showGuessHistory(game.guessHistory());
} while (!guessResult.correct() && game.guessHistory().size() < MAX_TIMES);

gameView.showMessage(guessResult.correct() ? "successful" : "failed");
gameView.showMessage("The correct number is " + game.actualAnswer());
}
}

运用依赖注入框架

至此,我们的程序基本完成。我们定义并实现了各个参与协作的类,但是,我们需要管理类之间的依赖,组合这些相关的对象。由于我们采用了测试驱动,因此比较好的保证了各个类的可测试性,而达成可测试性的诀窍就是“依赖注入”。


知识:依赖注入

依赖注入模式体现了“面向接口设计”原则,即分离接口与实现,并通过构造函数注入、设值方法注入或接口注入等手法将外部依赖注入到一个类中,从而解除该类与它协作的外部类之间的依赖。具体类型参考Martin Fowler的文章Inversion of Control Containers and the Dependency Injection pattern(http://martinfowler.com/articles/injection.html)。


在我们的例子中,主要通过构造函数注入的方式实现依赖注入。我们当然可以自己来组合这些类,但也可以运用现有的框架,例如Java平台下的Spring以及更轻量级的Guice(https://code.google.com/p/google-guice/)。

在目前的设计中,我们仅仅针对GameView以及InputCommand进行了接口与实现分离。由于InputCommand是作为play()方法的传入参数,不在依赖管理范围之内。至于RandomIntGenerator以及AnswerGenerator则是通过类直接注入的,因此,我们仅需做如下调整。

首先为那些运用了构造函数注入的类配置Guice提供的@Inject,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class AnswerGenerator {     
private RandomIntGenerator randomIntGenerator;
@Inject
public AnswerGenerator(RandomIntGenerator randomIntGenerator) {
this.randomIntGenerator = randomIntGenerator;
}
}

public class Game {
private Answer actualAnswer;
private final ArrayList<GuessResult> guessHistory;
@Inject
public Game(AnswerGenerator answerGenerator) {
this.actualAnswer = answerGenerator.generate();
guessHistory = new ArrayList<GuessResult>();
}
}

public class GameController {
private static final int MAX_TIMES = 6;
private Game game;
private GameView gameView;
@Inject
public GameController(Game game, GameView gameView) {
this.game = game;
this.gameView = gameView;
}
}

对于GameView接口,在默认情况下,Guice框架并不知道该注入它的哪个实现类(即使此时只有一个实现类),因此需要创建一个Module,它派生自Guice提供的AbstractModule,能够将接口与实现类进行绑定:

1
2
3
4
5
6
public class GuessNumberModule extends AbstractModule {     
@Override
protected void configure() {
bind(GameView.class).to(ConsoleGameView.class);
}
}

现在在main()函数中就无需进行繁琐的类型间组合,Guice框架会帮我们完成依赖对象之间的注入。唯一需要做的是创建一个Injector对象,通过它可以获得我们需要的GameController实例:

1
2
3
4
5
6
7
8
9
10
public class GuessNumber {     
public static void main(String[] args) {
Injector injector = createInjector(new GuessNumberModule());
GameController gameController = injector.getInstance(GameController.class);
InputCommand command = new ConsoleInputCommand();

System.out.println("Please input four numbers following by X X X X(0--9)");
gameController.play(command);
}
}

TDD知识

TDD核心

  • 红:测试失败
  • 绿:测试通过
  • 重构:优化代码和测试

TDD三大定律

该定律由Robert Martin提出:

  • 没有测试之前不要写任何功能代码
  • 只编写恰好能够体现一个失败情况的测试代码
  • 只编写恰好能通过测试的功能代码

FIRST原则

  • Fast: 测试要非常快,每秒能执行几百或几千个
  • Isolated:测试应能够清楚的隔离一个失败
  • Repeatable:测试应可重复运行,且每次都以同样的方式成功或失败
  • Self-verifying:测试要无歧义的表达成功或失败
  • Timely:频繁、小规模的修改代码