求助:一份用于学习单元测试的案例需求
2012-01-07 15:23 by 老赵, 5451 visits一直熟知单元测试的重要性,也算是了看了几本这方面的经典书籍,但是真开始上手的时候总会遇到各种各样的坎。例如,为什么总感觉自己的单元测试之间有较多的重合,为什么每个单元测试都要准备那么多依赖?有的说法是,这意味着代码设计不够好,单元测试也有问题,或者说没有使用TDD的缘故,等等。但是,现实开发过程中在这方面也颇感无力。前段时间在微博上咨询了几个问题,感觉收获不大,这次干脆整理一份需求,仔细认真向高手学习一下代码设计,单元测试,甚至测试驱动开发的方式吧。我也会准备一些礼物来感谢一部分同学的帮助。
MyDriver项目描述
我准备了一个简单而完整的需求,使用C#和Java各实现了一份,以便更多同学可以加入到这次活动中来。不过下面的说明暂时围绕C#代码开展,后文也会提到Java实现与C#的区别,不过两者基本完全一致,只有些微不同,例如命名方式。
首先,我们有一个MyDriver项目,您可以将其认为是一个网络服务的驱动程序。在实际情况下,这个驱动程序不会公开源代码,因此您不能修改这个项目里的任何代码,而只能在MyClient项目里使用它们。
MyDriver项目里唯一的核心类型便是MyDriverClient类,它有几个主要成员:
- MyDriverClient(string uri):构造函数,创建一个MyDriverClient类型的实例。uri为服务器地址,在这里只是个摆设。
- void Connect():连接服务器,连接后则可以进行后续操作。
- void AddQuery(int queryId):向服务器端发起一个查询,标识为queryId。使用相同queryId多次调用这个方法与调用一次无异。
- void RemoveQuery(int queryId):向服务器端取消一个查询,标识为queryId。如果这个查询本身并不存在,则不会发生任何事情。
- void Dispose():关闭与服务器端的连接。
- MyData Receive():接受一条服务器端返回的查询数据,如果没有数据,则会一直阻塞。如果连接关闭,则会返回null。
其中Connect,AddQuery、RemoveQuery和Receive由于都要和服务器端进行通信,因此都有可能会抛出MyDriverException。这些方法一旦抛出异常之后,该MyDriverClient对象则需要被视为不可用,但我们依然需要调用Dispose方法将其关闭。
MyData有两个字段,int类型的QueryID和字符串类型的Value。前者标识这条数据对应于哪条查询,而Value则是查询得到的数据,服务器端会将查询得到的数据不断推送到客户端。值得注意的是,即使使用了RemoveQuery取消了某个id的查询,但服务器端也可能已经向客户端推送了与这个id有关的数据,因此在一定时间内,Receive方法还是会得到这个id有关的MyData对象。这个时间虽然会很短,但并非不可能发生。
在调用AddQuery方法添加某个id的查询之后,服务器端首先会推送一条{ QueryID: id, Value: "begin" }这样的MyData对象,表示查询已经生效,此后再会源源不断地推送与该查询相关的数据。
在Program类型中有可执行的Main方法,可能会帮助您理解以上这些描述:
static void Main(string[] args) { var driver = new MyDriverClient("jeffz://server:12345"); try { driver.Connect(); driver.AddQuery(1); driver.AddQuery(2); driver.AddQuery(3); } catch { driver.Dispose(); Console.WriteLine("Error occurred when connect or add query."); Environment.Exit(1); } new Thread(() => ReceiveData(driver)).Start(); } private static void ReceiveData(MyDriverClient driver) { try { while (true) { var data = driver.Receive(); if (data == null) { Console.WriteLine("Closed"); break; } else { Console.WriteLine(data); } } } catch (MyDriverException) { driver.Close(); Console.WriteLine("Error occurred when receive data."); } }
在Main方法中,我们先创建一个MyDriverClient对象,并添加三个查询。如果添加查询时抛出了异常,则关闭MyDriverClient并退出,否则便启动一个额外的线程轮询Receive方法,并将数据打印在屏幕上。Receive方法如果返回null,则表明连接已经断开(其他某处调用了Dispose方法)。如果Receive方法抛出异常,则将MyDriverClient对象关闭。这段程序将会输出与下面类似的内容:
1, begin 2, begin 3, begin 1, 572224 2, 64186468 3, 9448434 1, 568828 2, 94581343 3, 7291394 ... 1, 84165615 2, 26815943 3, 237878844 1, 44716345 Error occurred when receive data.
Java程序的结构与C#完全相同,区别只是命名方式有所不同。此外C#里的MyDriver项目在Java项目里则变成了myDriver包,自然您也不能修改包里的代码。
MyClient项目描述
与MyDriver对应,MyClient则是您要实现的代码(及其单元测试)。其中我已经定义了一些需要对外暴露的类型和成员,您自然也可以添加更多私有或是内部的类型和成员,但一定要保持这些公开的接口不变。
MyConnection类型是MyClient的核心部分,其中封装了MyDriverClient类的使用。MyConnection的成员如下:
- ReconnectInterval:一个静态常量,两次尝试之间的间隔。
- MyConnection(string[] uris):构造函数,一个MyConnection保存多个服务器的uri。
- void Open():开始连接服务器。Open方法则会在额外的线程连接服务器,其调用本身会立即返回。
- int Subscribe(IMySubscriber subscriber):使用一个新的QueryID订阅数据,subscriber会开始收到与此次订阅相关的所有数据。该方法返回一个int值,作为订阅的ID,可以使用Unsubscribe方法取消订阅。取消订阅后subscriber不会继续收到数据。
- bool Unsubscribe(int subscriptionId):取消数据订阅,如果传入的subscriptionId有效,则返回true,否则返回false。
- void Dispose():断开与服务器的连接(即调用MyDriverClient的Dispose方法)。该方法“不需要”清除所有订阅,换言之可以再次Open,每个已注册的subscriber会重新开始接受数据。
- Connected事件:在每次连接成功时触发。
- ConnectFailed事件:在每次连接失败时触发。
- Disconnected事件:连接断开时触发。
与MyDriverClient的“抛出异常”的错误反馈方式不同,MyConnection一旦遇到各种服务器通信的失败时则会选择“重试”。例如:
- 调用Open方法之后,会在另一个的线程里从第一个uri开始连接。如果连接失败,则尝试连接下一个uri,如果最后一个uri也失败则重新尝试第一个uri,直至成功。两次尝试之间都需要等待一段时间(ReconnectInterval)。
- MyConnection会在另一个线程里轮询MyDriverClient的Receive方法,如果抛出了MyDriverException异常,则关闭当前的MyDriverClient对象,并“立即”重新开始连接下一个uri,如果失败,则再尝试下一个(此时需要有一定间隔),连接成功后重新向各subscriber发送数据。
IMySubscriber是订阅器的接口,它有两个方法:
- void OnBegin():当MyDriverClient的Receive方法收到begin数据时,则调用这个方法。
- void OnMessage(string message):当MyDriverClient的Receive方法收到其他数据时,调用这个方法。
简单地说,MyConnection便是在一个额外的线程中轮询MyDriverClient的Receive方法,并将接收到的MyData对象,根据QueryID分派到对应的subscriber中。接收和分派需要使用一个缓冲区,不能阻塞。值得注意的是,如果调用Subscribe方法的时候尚未连接成功,则相当于只是注册了一个subscriber。假如已经连接了服务器,则在额外的线程里调用AddQuery方法,而Subscribe方法使用要立即返回。同理,取消订阅时使用的Unsubscribe方法也要立即返回,不能被MyDriverClient的RemoveQuery方法阻塞。
Java项目与C#类似,一些区别则是为了遵循Java的习惯,例如命名以及事件的定义方式,以及使用Closeable接口代替了IDisposable接口。
实现要求
代码已经存放在GitHub中,项目Practices01,C#和Java都有。您可以Fork项目,也可以下载到本地。最好您也可以把代码存放到某个在线的Repository中,这样我可以随时查看到您的进展,我也可以立即给您反馈。
MyClient实现中最重要的部分其实是单元测试,如果您使用测试驱动开发(TDD)亦可,但不做强求,唯一的要求便是有“足够”的单元测试。为了简化问题,您也不需要过于纠缠于一些没有描述到的边界情况,例如还没有调用MyDriverClient的Connect方法便去AddQuery会怎样,或是反复调用Open等等。我们假设所有的使用方式都是“正确”的。但是,对于描述过的情况,例如必须使用“额外的线程”,或是出错之后的重新连接,则必须正确地实现,并包含足够的单元测试。
在编写单元测试的时候,您可以使用您最喜欢的Mock类库,但请尽量使用普通的Mock类库,而不要使用基于二进制改写或其他方式实现的高级类库,如TypeMock,Moles或是PowerMock。我知道它们可以Mock静态成员或是final成员,的确会有很大帮助,但是我还是希望可以了解如何通过优秀的代码设计来辅助单元测试。像Moq或是EasyMock都是不错的选择。
如果您有任何反馈,例如上面的需求有任何不清楚的地方,也请立即提出,我会做出改进。这次我打算给三位积极参与并提供较好实现的同学分别送上一本书(或是其他您想要的礼物)作为感谢。
ConnectionFailed事件:在每次连接失败是触发 错字,是->时