说明:本讲义是我在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
提示:应让学员充分思考承担职责的角色,不能在未经分析之前就开始编写测试,从而忽略测试带来的驱动力,甚至忘记一些基本的命名原则和面向对象设计思想。例如,学员可能会将被测类命名为Guess
、Check
,而被测方法也被命名为guess()
、check()
。
知识:命名规则
类命名规则:测试类与被测类的命名应保持一致,通常情况下,测试类的名称为:被测类名称+Test后缀。例如这里的Game类为被测类,则测试类命名为GameTest。
方法命名规则:测试方法应表述业务含义,这样就能使得测试类可以成为文档。测试方法可以足够长,以便于清晰地表述业务。为了更好地辨别方法名表达的含义,ThoughtWorks提倡用Ruby风格的命名方法,即下划线分隔方法的每个单词,而非Java传统的驼峰风格。建议测试方法名以should开头,此时,默认的主语为被测类。例如:
|
这里的方法可以阅读为: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模式:
|
这个测试已经驱动出了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()
方法进行验证。这种方法带来的问题是验证逻辑太繁琐。 - 第二种方法是利用
@Test
的expected
方法,通过指定异常类型值来验证。它的好处是简单直接,缺点是只能验证抛出异常的类型。 - 第三种方法是利用ExpectedException Rule。Rule可以更灵活地验证异常,包括异常类型和异常消息。我们也可以通过定义派生自
TypeSafeMatcher
的Matcher
类,来验证异常的更多信息。
问题:如何确定测试通过就意味着实现正确?
第二个任务看似简单,实则不然。原因在于这里产生了一个随机数。随机数带来了不确定性,它可能偶然地让测试通过了。也许,运行测试100次,前面的99次都通过了,最后一次失败,仍然视为失败。
生成随机数自然是调用Java的JDK。在单元测试环节中,倘若我们要测试的单元需要调用别的API,则在这个测试中,我们可以假定这个API是正确的。我们对Java JDK的正确性自然信心十足。那么,为何我们还要考虑测试的随机失败?这是因为在这个任务的测试中,我们测试的并非随机数的生成逻辑,而在于随机数的种子是否恰当,实现逻辑中是否判断了可能出现的错误数字?
由于生成随机数的逻辑并非确定无疑的,测试时我们就不能依赖于它。这正是Mock可以派上用场的时候。为此,我们需要将生成随机数的功能提取为类RandomIntGenerator
,再注入到AnswerGenerator
中。
public class AnswerGenerator { |
该类的实现调用了Java提供的Random
类,但在测试时,我们却可以通过Mock它的行为,使得返回的结果变为确定的数字:
|
重构:组合Game与AnswerGenerator
在实现第一个任务时,我们定义的Game接受了Answer对象作为游戏的答案。现在,我们定义了AnswerGenerator用以生成符合条件的随机答案。我们当然可以在调用该对象的generate()方法生成答案后,再将该答案作为构造函数参数传递给Game对象。但更好的做法是直接将AnswerGenerator作为构造函数参数传递给Game,在其内部调用它的generate()方法。
开始第三个任务
之所以将“验证输入是否合法”放在第三个任务,是因为它不属于happy path的范畴。它属于辅助业务,重要性相对次之。
提示:对于第三个任务,可以采用Specification By Example的方式来考虑测试用例。
问题:参数 vs. 字段
学员在定义执行该任务的类时,一种可能性是将输入的答案作为类的构造函数参数。例如:
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
对象,就应该针对它编写测试方法,让我们还是从测试方法的业务逻辑描述开始吧:
public class GameTest { |
在这里,实际上我驱动出了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来模拟,不一定要有实现,只需体现它们的接口即可。
例如,在当前这个测试中,除了之前已经处理过的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 { |
开始第五个任务
在开始编写测试之前,先要深入分析该任务表达的需求信息。“判断游戏结果。判断猜测次数,如果满6次但是未猜对则判负;如果在6次内猜测的4个数字值与位置都正确,则判胜。”实际上这里引入了对游戏猜测的控制逻辑,主要是对猜测次数的控制。这样的控制逻辑应该交给谁呢?
多数时候,程序员容易将这样的控制逻辑放到主程序入口处,即main()函数中。这并非恰当的方式。一方面,这里的控制逻辑仍然属于业务逻辑的范畴,不应该暴露给调用者,同时也加大了调用者的负担;另一方面,倘若程序不再作为控制台程序时,例如编写Web Application,主程序入口的内容就要调整,甚至导致这一逻辑的重复。
有了编写第四个任务作为基础,我们很容易判断出该控制逻辑应该交给GameController
。编写测试也变得简单:
public class GameControllerTest { |
这里的两个测试与第四个任务测试“显示历史猜测数据”任务的测试相似,唯一不同的是我们添加了对InputCommand
协作的验证,并以Mockito提供的times()
方法准确的验证了调用的次数。默认情况下,verify
验证的次数为1,但我在第一个测试中仍然给出了times(1),是希望在测试中明确的表示它被执行了一次。
通过编写测试,我们驱动出了GameController
、InputCommand
与GameView
之间的协作关系,并且还驱动出showMessage()
方法。如果你觉得showMessage()
方法的定义太过宽泛,也可以定义showFailure()
和showSuccess()
方法来体现这里表达的业务逻辑。
GameController
的实现就变简单了:
public class GameController { |
运用依赖注入框架
至此,我们的程序基本完成。我们定义并实现了各个参与协作的类,但是,我们需要管理类之间的依赖,组合这些相关的对象。由于我们采用了测试驱动,因此比较好的保证了各个类的可测试性,而达成可测试性的诀窍就是“依赖注入”。
知识:依赖注入
依赖注入模式体现了“面向接口设计”原则,即分离接口与实现,并通过构造函数注入、设值方法注入或接口注入等手法将外部依赖注入到一个类中,从而解除该类与它协作的外部类之间的依赖。具体类型参考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
,如下所示:
public class AnswerGenerator { |
对于GameView
接口,在默认情况下,Guice框架并不知道该注入它的哪个实现类(即使此时只有一个实现类),因此需要创建一个Module,它派生自Guice提供的AbstractModule
,能够将接口与实现类进行绑定:
public class GuessNumberModule extends AbstractModule { |
现在在main()
函数中就无需进行繁琐的类型间组合,Guice框架会帮我们完成依赖对象之间的注入。唯一需要做的是创建一个Injector
对象,通过它可以获得我们需要的GameController
实例:
public class GuessNumber { |
TDD知识
TDD核心
- 红:测试失败
- 绿:测试通过
- 重构:优化代码和测试
TDD三大定律
该定律由Robert Martin提出:
- 没有测试之前不要写任何功能代码
- 只编写恰好能够体现一个失败情况的测试代码
- 只编写恰好能通过测试的功能代码
FIRST原则
- Fast: 测试要非常快,每秒能执行几百或几千个
- Isolated:测试应能够清楚的隔离一个失败
- Repeatable:测试应可重复运行,且每次都以同样的方式成功或失败
- Self-verifying:测试要无歧义的表达成功或失败
- Timely:频繁、小规模的修改代码