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

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的时候,第一个问题出现了,只有球门和足球两个静态对象,以此判断进球是否可行?可行吗?先挖个坑,看看能不能填!

which_in_the_goal_then_return_the_true

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

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

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

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

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

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

那开始单元测试吧~

测试用例


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

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

调整测试代码如下:

单元测试

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

添加接口文件
IPoint和Point
IBall
IGoal

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

测试失败信息

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

Ball
Goal
给接口赋实例

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

添加新的测试用例

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

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。

GameInfoServiceTest

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

which_in_the_goal_then_return_true_form_GameInfoService

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

构建测试上下文环境

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

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

which_in_the_goal_then_return_true_form_GameInfoService

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

IGameInfoService

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

which_out_the_goal_then_return_false_from_gameinfoservice

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

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,需要做一下调整:

调整设置球门函数,添加单元测试

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

调整GameInfoService

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

GetScoreStr()

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

GetScoreStr()

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

Goal->GoalDoor重构

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

which_A_have_a_goal_then_return_score_1vs0_from_gameinfoservice

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

测试结果

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

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

添加了球门进球的模拟

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

测试结果

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

添加GameInfoService中计分逻辑

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

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

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

模拟两队进球
which_A_have_a_goal_and_B_have_a_goal_then_return_score_1vs1_from_gameinfoservice

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

提取方法来减少重复

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

功能2.2.3——更新球权状态


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

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

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

which_start_with_A_control_the_ball_then_return_A_form_gameinfoservice

添加IGameInfoService接口及实现代码:

添加设置及获取球权的方法

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

重复代码

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

重构后的测试环境

相应,测试用例修改为:

重构后的测试用例

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

which_A_have_a_goal_then_return_ball_control_return_B_from_gameinfoservice

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

添加球权控制逻辑

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

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添加足球轨迹列表
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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容