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

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

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

标签 | 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诗+歌。

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

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

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

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

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 目标收益
  • 整体需求
  • 任务分解
    • 练习:分解任务
      • 讨论:选择开始的任务
      • 测试驱动开发
        • 开始第一个任务
          • 任务:判断每次的猜测结果
          • 知识:Specification By Example
          • 思考:测试驱动开发的驱动力
          • 知识:Given-When-Then模式
          • 知识:CQS原则
          • 知识:信息专家模式
          • 思考:寻找承担职责“判断每次的猜测结果”的对象
          • 知识:命名规则
          • 讨论:由4个数字组成的答案是否需要封装?
          • 思考:Answer的定义
          • 知识:单一职责原则
          • 思考:是否需要将猜测结果封装为类?
          • 思考:为何要先运行一个失败的测试?
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档