前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >一个完整的TDD演练案例(二)

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

作者头像
张逸
发布2018-07-27 11:19:00
7830
发布2018-07-27 11:19:00
举报
文章被收录于专栏:斑斓斑斓

标签 | TDD Java

字数 | 3219字

阅读 | 9分钟

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

目标收益

  • 熟悉IDE快捷键;
  • 掌握TDD基本知识;
  • 识别代码坏味道,熟练运用重构手法;
  • 熟悉JUnit与Mockito框架;
  • 了解Google Guice框架;

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

常见问题:

  • 没有将测试代码看做是代码的一部分。当编写多个测试方法时,没有及时重构;例如,应及时将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 { private RandomIntGenerator randomIntGenerator; public AnswerGenerator(RandomIntGenerator randomIntGenerator) { this.randomIntGenerator = randomIntGenerator; }}

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

@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()方法。

阅读系列文章:

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


❈ 题图来自Mono《插画太空馆》,绘画者Charlotte Ager,伦敦插画师。她的作品乍看起来凌乱而不拘,但在线条与色块的交错中却有着独特的能量和动感。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2018-06-28,如有侵权请联系 cloudcommunity@tencent.com 删除

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

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

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

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 目标收益
    • 开始第二个任务
      • 讨论:究竟由谁来承担“随机生成答案”的职责?
      • 知识:JUnit中对异常的验证
      • 重构:组合Game与AnswerGenerator
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档