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

TDD-PRACTICE

背景  

本文接《TDD(测试驱动开发)项目实践》开始,前文偏重设计,对测试,尤其是单元测试

涉及不多,本文是对前文的补充,相同的项目案例,侧重的是单元测试

有关需求及项目描述,可以直接参看前文:

http://www.jianshu.com/p/ae34612e1eeb

环境(操作系统&&工具&&框架&&第三方库)


① 操作系统:Win8.1

② IDE:VS2015 Pro

③ 单元测试框架:Nunit,版本号:3.0.5813.39033

④ 单元测试辅助插件:NUnit3 Test Adapter(这个适用于VS2013/VS2015,Nunit Test Adpater 不支持VS2013/VS2015,这个Pi君亲测)用这个插件可以将Nunit单元测试的结果放到VS自带的测试资源管理器中显示,比较方便。

⑤ 还有第二个更好的选择,VS2015的CodeRush插件,这简直就是个测试神器,强烈推荐,没有之一,后续出文单说此神器。

CodeRush-TestRunner

⑥ Moq框架,在需要Mock时使用,VS2015的配置可以通过NuGet程序包管理器配置,细节不赘述,可留言分享;

⑦ Ninject框架,依赖注入框架;

⑧ Fluent NHibernate ORM框架;

⑨ .net4.0

项目组织结构


个人习惯,在开始一个新的项目前,先规划好项目代码及相关文件的路径,因为这个项目是Pi君github上的项目(非公司代码),所以在github文件夹下,新建一个子文件夹,并以该项目命名FBGame,继续在该文件夹下新建两个子文件夹libs和src,其中libs存放第三方库或框架,src存放项目代码。

把需要依赖的框架或类库复制到libs中,并在src文件夹下新建一个空的解决方案,命名为FBGame,然后新建一些项目:

→ 建一个类库项目,命名为FBGame.Core;

→ 建一个类库项目,作为该解决方案的单元测试项目,命名为FBGame.UnitTests;

→ 建一个类库项目,作为该解决方案的集成测试项目,命名为FBGame.IntegrationTests

→ 建一个WinForm项目,作为该解决方案的客户端,命名为FBGame.WinClient。

OK,这个项目的基本组织结构基本成型。

FBGame项目结构

在后续的开发过程中,这个结构可能会修改,但是从一开始就保持结构的简单和清晰是个好习惯,倘若在开发和迭代过程中,想添加一个新的项目,那首先要反问自己“即将要添加的部分必须属于一个独立的项目吗?”,如果不一定,那就暂时不要新建,倘若必须新建,也要考虑和现有项目之间的关系,最好在新项目命名的时候就充分体现这种关系。

需求回顾


首先,回顾一下前文中这个项目的需求描述:

① 一个足球比赛类小游戏,用户可以通过键盘操控控球球员前进/后退/左转/右转/加速/传球/射门;如果用户控制的球队没有球权,则用户可以切换控制球员进行铲球/防守;用户可以控制游戏开始,设置游戏时间,一般有两支球队进行比赛;

② 每个球队有一个教练,有十一个球员,有自己的球队队形,用户可以自己调整针对特定队形的球员站位,有自己的队服/队徽;

③ 有一个管理员账号,管理员可以管理球队相关数据,包括球员数据/教练数据/队形数据/队服数据/队徽数据。

OK,一般到此可以根据需求进行需求分析,确定需求优先级......这是一般的开发过程,如果考虑TDD,也就是先添加针对功能的测试用例而已~但是,TDD真的就是这样的?!PI君不晓得,一起探索下吧~

0次迭代


本次迭代的用户场景


既然,我们是要做一个游戏,游戏前端界面在前文中已有描述,可以简单的理解为C/S结构,真正需要考虑的是给前端提供功能,这样,核心的功能(阴影部分描述)可以通过一些场景来描述:

① 当比赛时间完成时,比赛结束;

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

③ 当足球被A队队员踢出界时,球权交给B队,B队队员在出界位置发球继续比赛;

④ 球员根据唯一动作标识进行位置更新(前进/后退/左转/右转/加速),球员有指定的速度(前进速度/后退速度/转向速度)和当前方向,前进/后退/加速时,更新球员的位置,左转/右转更新球员的前进方向;

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

⑥ 球权更新,根据足球的空间位置和所有球员的运动状态<空间位置>,计算所有球员(设置占有球权的逻辑)占有足球的概率,并返回概率最大者为控球球员。

当然,这些并不包含全部的需求(甚至不到完整需求的20%),但是,这是一个Core,在实现这些功能以后,足球游戏是可玩的,好不好玩先不用管~核心需求关系这个项目的核心价值,只有保证Core实现了,这个项目才有延续推进的可能,对于用户而言,也存在一个迭代需求的依据,否则,用户总是看不到项目进展,总是不能上手使用,开发和憋大招一样,大家都互相憋着,憋着憋着就没了耐性,项目也多半会流产~

网上一些大牛们在描述用户场景上有一些很好的建议,Pi君引用过来,一起学习:

将较大的用户情景分解为较小的功能,这些功能应当短小,简单,独立,可测。”——James Bender&&Jeff McWherter

用户场景的功能列表


1 当比赛时间完成时,比赛结束;

1.1 一个独立线程计时器类,可以设置时间,开始计时,当计时结束时返回结束状态;

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

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

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

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

3 当足球被A队队员踢出界时,球权交给B队,B队队员在出界位置发球继续比赛;

3.1 一个球场边界类,根据足球的位置判断界内界外,当足球出界时,返回当前足球位置;

3.2 参看2.2

4 球员根据唯一动作标识进行位置更新(前进/后退/左转/右转/加速),球员有指定的速度(前进速度/后退速度/转向速度)和当前方向,前进/后退/加速时,更新球员的位置,左转/右转更新球员的前进方向;

4.1 一个球员类,根据动作指示符,进行位置及方向的更新;

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

5.1 一个足球类,包含足球的运动属性,方向和三维位置信息,这是一个单例类;

5.2 一个足球服务类,这是一个独立线程,在比赛时间内,每间隔单位时间根据当前时刻下的足球对象信息更新足球的三维位置信息;

6 球权更新,根据足球的空间位置和所有球员的运动状态<空间位置>,计算所有球员(设置占有球权的逻辑)占有足球的概率,并返回概率最大者为控球球员。

6.1 游戏信息服务类,每间隔单位时间,计算所有球员(设置占有球权的逻辑)占有足球的概率,并更新概率最大者为控球球员;

实现场景1——>当比赛时间完成时,比赛结束


1.1 一个独立线程计时器类,可以设置时间,开始计时后每隔单位时间返回计时结果(字符串),当计时结束时返回结束状态;

定义一个计时器类,给计时器设置时间t,当计时器开始计时时,根据不同的时刻(不到t,超过t,等于t),获取计时字符串和状态,即:

TestCase-1:不到t,返回时刻字符串和Running状态;

TestCase-2:超过或到达t,返回时刻字符串和Over状态状态。

其中Running用整数1标识,Over用0标识。

在单元测试的构建中,采用BDD(行为驱动开发)风格的命名规则是个好的建议,在单元测试的类名及方法名的定义中将用户场景进行描述,很直观,易于和业务人员沟通。简单说来就是三个步骤:

1. when->一般是某类场景单元测试的基类,保存但元测试的运行环境,用在此处可以这写:

public class when_working_with_the_TimeCounter_start{};

2. and->一般是具体某种情形下的场景的单元测试类,继承when定义的基类,共享测试环境,例如:

public class and_by_the_time_point : when_working_with_the_TimeCounter_start{};

3. which...then->一般是测试用例,即测试方法的定义,例如:

public void which_in_the_time_then_TimeCounter_status_should_be_running();

在使用BDD的命名规则时,可以直接引用BDD的命名库,但是Pi君暂时还没有感受到使用nBehave类似的BDD语法糖带来的优势,所以先借用一下命名规则而已,(对于BDD而言,这连皮毛都不算~)测试框架,直接使用NUnit。

TestCase-1:


OK,构建单元测试如下:

when
and_by_time_point

Pi君省去了一步一步的添加过程,当前这个状态可以编译通过,但是运行时会报错~

因为_timeCounter没有实例,只有一个接口的定义而已:

ITimeCounter

接下来需要添加一个实现ITimeCounter的实体类:

TimeCounter

然后在when_working_with_the_TimeCounter_start添加对_timeCounter的定义:

_timeCounter

运行测试,失败了,原因是:

测试结果

计算当前时刻字符串的方法不能通过测试,调整该方法的代码:

在TDD过程中,小步前进总是好的,和获取计时器的状态一样,也可以直接在获取当前时刻字符串的方法上返回测试用例中需要的时刻,但是timePoint=_radom.Next(0,10),这是个固定范围内的随机数,次奥,写测试案例的小朋友真是机灵......这意味着如果想通过测试,不得不“前进”一大步了。

为了让计时器可以返回计时时间段内的不同时刻,给计时器添加几个成员:

currentPoint&&currentPointStr

分别记录时刻及其对应的字符串,二者有个一一对应的转换关系,用一个功能函数来实现这种转换:

ToFormateString

哎呀妈呀,步子太大,容易扯着蛋......

谨慎起见,再跑一次原来的测试,次奥,又失败了,看来步子还是太小啊~~继续扯:

因为测试用例已经说明计时器是个独立线程,所以给start函数添加这段代码:

start
Run

再跑一次测试,次奥,又是红叉叉~这次问题出在哪里?多线程~

_currentPoint和_currentPointStr其实是共享资源,使用时,要加锁的~这个不能忘~添加几行代码:

LCOK
Run

再跑一次测试,还是失败,依然是多线程的问题,这一次是多线程同步的问题,分析如下:

线程同步问题描述

主线程的计时时刻和计时器线程的计时时刻不同步,导致测试失败,注意,这不是共享资源的同步问题,是时刻同步问题,So,使用同步事件和等待句柄(AutoResetEvent或ManualResetEvent),不熟悉的看官直接MSDN搜查下:计时器是一个独立线程,所以使用AutoResetEvent比较简单,修改代码如下:

在TimeCounter中添加成员,并初始化:

AutoResetEvent

然后在Run()方法中的while循环下,添加WaitOne句柄:

WaitOne

然后再ITimeCounter接口中增加对AutoResetEvent事件的控制方法及实现:

SetAutoEvent
方法的实现

OK,这样,客户端线程(其实就是这里的测试线程)就可以控制计时器的计时过程啦,让你三更挂掉,绝对不会等到四更~接下来修改测试代码:

which_in_the_time_then_TimeCounter_status_should_be_running

在主线程“计时过程”中控制计时器的计时过程,以此来保证同步(其实只是相对同步,基于线程的概念,如果没有硬件的外在支持,在时间上的绝对同步是不现实的),运行测试,OK,通过~

:https://msdn.microsoft.com/zh-cn/library/ms173179(VS.80).aspx——线程同步MSDN链接

总是编写最小的代码量来让测试通过和小步前进不冲突,后者更偏向于说明运行单元测试的时机。

TestCase-2:


构建单元测试用例:超过或到达t,返回时刻字符串和Over状态;

which_after_the_time_then_TimeCounter_status_should_be_over

阿西吧,这个测试没通过~Why?问题出在最后一个断言,计时器的最大时刻就是t,对于超出t的时刻,计时器是无法获取的,修改下,断言计时器当前时刻是其最大时刻:

修改最后一个断言

运行测试,OK,通过~  

针对第一个场景是不是就结束了,是吗?对于TDD而言,这只是一个开始啊亲~~

重构——每次单元测试运行通过以后,都应该考虑重构


重构,是一个过程工具,每次测试通过之后,都要重构,不仅是业务逻辑代码,也包括测试代码。

先检查业务逻辑代码:TimeCounter,还好,没有重复,命名基本达意,先过,再看看单元测试代码:

测试代码中的重复

蓝色框选中的代码模拟的是主线程的计时,同时关联控制计时器的计时,两个测试用例都需要这个功能,所以可以提取方法:

MockTimeCount

两个测试案例代码修改为:

重构后的测试案例代码

因为MockTimeCount方法,只和when_working_with_the_TimeCounter_start类有关系,所以把该方法上提至基类,更改访问权限为protected。

切记切记,你的每一次修改,都要运行所有的但元测试,至少对于目前的状态是这样的(不然,为啥要写测试嘞~~~)。

OK,重构暂时先到这里。

对于用户场景1:

1 当比赛时间完成时,比赛结束;

1.1 一个独立线程计时器类,可以设置时间,开始计时,当计时结束时返回结束状态;

TDD开发过程告一段落,稍后,Pi君以另一篇文字记述用户场景2的TDD过程。

有关FBGame项目源码的github地址:

https://github.com/fei090620/FBGame.git

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

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

推荐阅读更多精彩内容