TDD(测试驱动开发)项目实践——开发实战(二)

TDD(测试驱动开发)项目实践——开发实战(二)_第1张图片
TDD-PRACTICE

背景


本文接《TDD(测试驱动开发)项目实践——开发实战(一)》开始,前文记述了第0次迭代第一个用户场景的TDD过程,本文接前文记述第二个场景的TDD过程,Pi君力争再现小项目实现过程中的各个细节,以此深入体会TDD精妙~

《TDD(测试驱动开发)项目实践——开发实战(一)》:http://www.jianshu.com/p/b5aa6709f6d6

《TDD(测试驱动设计)的项目实践——需求分析》:http://www.jianshu.com/p/ae34612e1eeb

用户场景2


前文有关场景2的分析结果如下:

2 当足球进入A队球门时,B队得分,球权交给B队,在球场中心位置发球继续开始比赛;

2.1 一个球门类,用来描述球门,传入足球的三维坐标可以判断是否进球;

2.2 一个游戏信息服务类,这是一个独立线程,监控进球发生事件,更新计算比分,更新球权状态,更新足球位置;

2.3 一个全局变量,球场中心位置。

功能2.1


开始Code的时候,第一个问题出现了,只有球门和足球两个静态对象,以此判断进球是否可行?可行吗?先挖个坑,看看能不能填!

TDD(测试驱动开发)项目实践——开发实战(二)_第2张图片
which_in_the_goal_then_return_the_true

Goal是球门,Ball是足球,只有静态实例,怎么设计初始值呢?怎么设计进球的判断逻辑呢?一起分析下吧~

进球的判断,有两个条件,第一,这个球越过了球门线(球场边界隶属于球门的一部分);第二,越过球门线时的高度低于球门高度。一旦这两个条件满足,OK,进球了,应该返回true。

基于此,可以有三个测试用例:

① 当足球的坐标在球门线上,足球的高度不超过球门高度时,返回true;

② 当足球的坐标不在球门线上,返回false;

③ 当足球的高度超过球门高度时,返回false;

那开始单元测试吧~

测试用例


首先,球门应该包含球门线和高度,球门起点,终点,高度;足球位置即三维位置,编写单元测试如下:

注:这里的Point不是.net中GDI自带的Point结构体。考虑到跨平台和松耦合,建议另行定义Point,虽然可能会增加一些编码的工作量。

调整测试代码如下:

TDD(测试驱动开发)项目实践——开发实战(二)_第3张图片
单元测试

很显然,编译不能通过,因为没有对红线标识的接口进行定义,或者没有引用相应的程序集,在FBGame.Core.DomainService中添加IGoal,IBall,IPoint接口,并添加对IPoint接口的实现类:

TDD(测试驱动开发)项目实践——开发实战(二)_第4张图片
添加接口文件
TDD(测试驱动开发)项目实践——开发实战(二)_第5张图片
IPoint和Point
TDD(测试驱动开发)项目实践——开发实战(二)_第6张图片
IBall
TDD(测试驱动开发)项目实践——开发实战(二)_第7张图片
IGoal

完成后,编译通过~运行测试,失败了~失败提示如下:

测试失败信息

因为没有给接口IGoal和IBall赋实例,所以嘛~添加IGoal和IBall的实现类,并在单元测试中给接口赋实例:

TDD(测试驱动开发)项目实践——开发实战(二)_第8张图片
Ball
TDD(测试驱动开发)项目实践——开发实战(二)_第9张图片
Goal
TDD(测试驱动开发)项目实践——开发实战(二)_第10张图片
给接口赋实例

运行测试,OK,通过了~不要担心什么都还没写呢,怎么就通过了,只要通过了,就可以开始下一个测试啦~

TDD(测试驱动开发)项目实践——开发实战(二)_第11张图片
添加新的测试用例

Pi君再次省去了一些过程,包括添加新的测试用例和对单元测试的重构,运行测试,失败了,因为InGoal()方法的逻辑没有添加~修改代码,让测试通过:

TDD(测试驱动开发)项目实践——开发实战(二)_第12张图片
InGoal

运行测试,OK,全部通过啦~检查业务逻辑代码和测试代码,查看是否需要重构~ 命名/重复/单一职责/......OK,貌似暂时不用修改~

功能2.2


2.2 一个游戏信息服务类,这是一个独立线程,监控进球发生事件,更新计算比分,更新球权状态,更新足球位置;

功能2.2有多个复合功能,拆分来看:

2.2.1 监控进球发生

2.2.2 更新计算比分

2.2.3 更新球权状态

2.2.4 更新足球位置

功能2.2.1——监控进球发生


进球发生是由球门来判断的,游戏信息服务类怎么知道的?因为游戏服务类——>球门,只有存在这种关系,游戏服务类在获取足球位置的时候才能知道进球事件的发生,进而有后续的行为;游戏服务是个独立线程,记得功能1.1中设计的TimeCounter也是一个独立的线程,有多个线程了,他们之间是怎么协调工作的?同步的机制是什么?从核心功能的用户场景到单元测试,再从单元测试返回到核心功能,敏捷过程本身就是一个快速反馈,不断迭代的过程,以此将软件开发的设计,测试,开发,质量等等要素穿在一起~

在发现依赖关系的时候,当然可以选择继续考虑单元测试,但是Pi君更倾向于把单元测试作为一种相对独立的功能单元,TDD过程中如果存在依赖,而且是耦合的依赖,那么很有可能是在设计时划分功能单元出现了问题(把不该分开的模块分开了)

为了测试是否获取进球消息,需要在测试开始构建测试运行的环境,即进球!我们需要模拟一个进球!当然,现在还没有这个“信息服务类”,但是不管怎样,先构建测试吧~

首先,在单元测试的FBGame.Core文件夹下添加信息服务类的测试:GameInfoServiceTest。

TDD(测试驱动开发)项目实践——开发实战(二)_第13张图片
GameInfoServiceTest

根据BDD方式命名单元测试:

TDD(测试驱动开发)项目实践——开发实战(二)_第14张图片
which_in_the_goal_then_return_true_form_GameInfoService

看上去有点别扭,不过慢慢就习惯了~(Pi君英语很一般~~)OK,开始构建测试上下文环境吧~

TDD(测试驱动开发)项目实践——开发实战(二)_第15张图片
构建测试上下文环境

这里Pi君直接使用了Moq框架对IBall,IGoal进行了模拟,再说一遍,单元测试总是希望独立的,尽可能的减少对其他资源的依赖。

分别模拟了一次进球和不进球,接下来编写进球的单元测试:

TDD(测试驱动开发)项目实践——开发实战(二)_第16张图片
which_in_the_goal_then_return_true_form_GameInfoService

当然,现在是无法编译通过的,因为还没有定义GameInfoService的接口声明及实现类,添加代码让编译通过:

TDD(测试驱动开发)项目实践——开发实战(二)_第17张图片
IGameInfoService

OK,编译通过,运行测试,也通过了,运气真好~没有逻辑实现就通过测试了~是不是都不放心?是啊,Pi君也不放心,那再加一个测试用例:当没有进球的时候,返回false。

TDD(测试驱动开发)项目实践——开发实战(二)_第18张图片
which_out_the_goal_then_return_false_from_gameinfoservice

果然,测试没通过~添加监听进球的逻辑,让测试通过~

TDD(测试驱动开发)项目实践——开发实战(二)_第19张图片
GameInfoService

OK,这个功能已经完成,继续下一个吧~停!不应该编写更多的测试来保障代码的质量吗?!这是当然,但是就Pi君自己而言,目前已经满足基本需求,所以过啦~(虽然很显然,代码中没有对_goal是否为空做出判断,可能会是一个坑!但是,没有需求上的测试,不要添加自认为有意义的代码,除非针对这个问题,提出测试案例,让测试不通过~)

功能2.2.2——更新计算比分


游戏信息服务类在获取进球之后,需要更新比赛的分数,并将分数返回~在比赛开始时,比分被初始化为“0:0”。即A队:B队比分为“0:0”,当A队进球时,比分应该被更新为“1:0”,这时,如果B队又进球了,比分应该被更新为“1:1”,以此类推。来编写单元测试描述这个场景:

这时,我们开始考虑球队的区分了,A队,B队,这是之前没有考虑的,对于功能2.2.1而言,只要进球即可,至于谁进球,没有判断,结合功能2.2.2,需要做一下调整:

TDD(测试驱动开发)项目实践——开发实战(二)_第20张图片
调整设置球门函数,添加单元测试

添加第一个测试用例,当比赛开始后,没有进球发生的时候,获取比赛分数,应该返回初始比分“0:0”。然后,SetGoal()函数被修改为:

TDD(测试驱动开发)项目实践——开发实战(二)_第21张图片
调整GameInfoService

Pi君这里省去了一些过程(和之前都是重复的~),以致于看上去不是小步前进~看官们自行脑补吧~~嘿嘿~编译不通过,添加GetScoreStr()函数的接口和实现,让编译通过~

GetScoreStr()

运行测试,失败了~添加代码,让测试通过~

GetScoreStr()

不要郁闷没有添加逻辑就通过测试(不是第一次提,不罗嗦啦~)。继续添加测试前,有一个地方需要重构,goal既可以表示球门,也可以表示进球,这样很容易混淆视听,避免二义性,球门重命名为GoalDoor,所以与球门相关的名称都要重构命名(借助VS提供的工具可以很方便的实现重构):

TDD(测试驱动开发)项目实践——开发实战(二)_第22张图片
Goal->GoalDoor重构

然后,再继续添加测试用例吧~当A队进了一个球,那么比分应该变成“1:0”,在A队进球前,比分为“0:0”,首先需要模拟这个过程,在A队没进球前,初始化球门设置,返回初始比分“0:0”,然后模拟A队进了一个球,比分变为“1:0”:

TDD(测试驱动开发)项目实践——开发实战(二)_第23张图片
which_A_have_a_goal_then_return_score_1vs0_from_gameinfoservice

运行测试,预料之中失败了~失败原因如下:

测试结果

出现了引用实例为空的错误!回到之前在处理GameInfoService时,考虑是否添加对球门引用为空的判断,当时的处理是不添加判断,在这里我们找到了不添加非空判断的理由~帮助我们发现程序设计中的缺陷和错误~(所以,千万不要自以为是的添加代码,总是要搞懂所以然是个好习惯~)

这里出错的原因是因为测试之前没有对InGoal方法进行模拟,添加队InGoal方法的模拟,代码如下:

添加了球门进球的模拟

运行测试,依然没有通过,但是这一次是因为断言失败了,这是我们预期的结果~

测试结果

修改GameInfoService的内部逻辑让测试刚好通过~

TDD(测试驱动开发)项目实践——开发实战(二)_第24张图片
添加GameInfoService中计分逻辑

OK,测试通过~检查是否需要重构~......有一个问题:StartListen()始终都没有实现?!是不是意味着这个函数有可能不是实现功能所必需的,既然如此,重构的时候就先把它删掉吧~但是,由于GameInfoService是个独立线程,应该有一个方法可以控制开始或者结束这个线程,是的,如果GameInfoService是个线程类,最简单的方式是在主线程中开辟新线程来调用GameInfoService的运行,如此以来,更不需要StartListen()方法,果断删掉。

对于“1:0”的比分,暂时还不是很满意,增加一个B队的进球,让比分持平吧,代码描述测试用例:

首先,模拟A进球和B进球:

TDD(测试驱动开发)项目实践——开发实战(二)_第25张图片
模拟两队进球
TDD(测试驱动开发)项目实践——开发实战(二)_第26张图片
which_A_have_a_goal_and_B_have_a_goal_then_return_score_1vs1_from_gameinfoservice

运行测试,通过~考虑重构,将模拟进球的代码进行方法提取(有重复代码!):

TDD(测试驱动开发)项目实践——开发实战(二)_第27张图片
提取方法来减少重复

到此,可以开始下一个功能~

功能2.2.3——更新球权状态


同样属于GameInfoService的功能项,添加测试用例~

球权:掌控足球的球队,比如A队某球员控球,此时球权为A队,反之则是B队。进球发生以后,需要重新设置球权,A队进球后,B队获得球权,反之B队进球后,A队将获得球权。

编写测试用例:首先,假设开始比赛的时候,球权归A队所有,那么此时通过GameInfoService返回球权为A队:

TDD(测试驱动开发)项目实践——开发实战(二)_第28张图片
which_start_with_A_control_the_ball_then_return_A_form_gameinfoservice

添加IGameInfoService接口及实现代码:

TDD(测试驱动开发)项目实践——开发实战(二)_第29张图片
添加设置及获取球权的方法

编译运行测试,通过~考虑重构,测试代码中出现了大量的重复代码:

重复代码

这其实是对游戏信息服务类的初始化,应该放在Setup当中,OK,结合模拟参数将该方法提取至SetUp中,并删除已经无意义的测试参数:

TDD(测试驱动开发)项目实践——开发实战(二)_第30张图片
重构后的测试环境

相应,测试用例修改为:

TDD(测试驱动开发)项目实践——开发实战(二)_第31张图片
重构后的测试用例

重构结束后,添加新的测试用例,假设A队控球后,进球了 ,则重设球权为B队:

TDD(测试驱动开发)项目实践——开发实战(二)_第32张图片
which_A_have_a_goal_then_return_ball_control_return_B_from_gameinfoservice

运行测试,失败了~添加逻辑处理,让测试通过~

TDD(测试驱动开发)项目实践——开发实战(二)_第33张图片
添加球权控制逻辑

测试通过~OK~不放心,再添加一个测试用例:假设A队控球后,进球了 ,然后B队又进球了,则重设球权为A队:

TDD(测试驱动开发)项目实践——开发实战(二)_第34张图片
which_A_and_B_both_have_a_goal_then_ball_control_return_A_from_gameinfoservice

测试通过~OK,这个功能可以过了~

功能2.2.4——更新足球位置


如果GameInfoService能够更新足球位置,存在两种解读:第一,GameInfoService——>足球(IBall),也就是足球是GameInfoService的一个成员或者足球是单例,可以通过类型获取唯一实例;第二,足球的位置有单独的模块进行计算,只是将计算结果传给GameInfoService。看官们如果是你,你怎么选呢?有木有一些原则性的东东可以作为选择的依据?嘿嘿,当然有~

两个理由:

首先,GameInfoService类主要是记录游戏信息,包括比分,球权,足球位置等等,如果该类还包括控制足球的运动,岂不是发展成为巨型类或者万能类了(一般起名叫“**Service”的类都容易犯这个毛病),这违反了类的单一职责原则;

其次,在《TDD(测试驱动开发)项目实践——开发实战(一)》罗列的核心功能项中有一条:

⑤ 足球根据受控状态,加速度,速度,位置,方向,时刻更新足球位置;

即,设计中,已经有单独的类来处理足球的位置更新,所以巴拉巴拉巴拉~~既然如此,GameInfoService的这个功能就非常简单啦~可以不用测试,直接写实现吧~

GameInfoService添加足球轨迹列表
TDD(测试驱动开发)项目实践——开发实战(二)_第35张图片
AddGoal中将位置添加至列表

本章小结


本章有关用户场景2:

2 当足球进入A队球门时,B队得分,球权交给B队,在球场中心位置发球继续开始比赛;

2.1 一个球门类,用来描述球门,传入足球的三维坐标可以判断是否进球;

2.2 一个游戏信息服务类,这是一个独立线程,监控进球发生事件,更新计算比分,更新球权状态,更新足球位置;

2.3 一个全局变量,球场中心位置。

的TDD过程就算结束了~欢迎大家留言讨论,一起学习,一起进步~也请关注Pi君TDD-Practice系列的看官们继续期待Pi君的下一篇TDD系列文章《TDD(测试驱动开发)项目实践——开发实战(三)》(用户场景3的TDD过程),有关TDD系列博文的源代码为FBGame项目源码的github地址:https://github.com/fei090620/FBGame.git

源码会随着文字的更新而更新。

你可能感兴趣的:(TDD(测试驱动开发)项目实践——开发实战(二))