Hello World
Spiga

求助:一份用于学习单元测试的案例需求

2012-01-07 15:23 by 老赵, 4015 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类库,而不要使用基于二进制改写或其他方式实现的高级类库,如TypeMockMoles或是PowerMock。我知道它们可以Mock静态成员或是final成员,的确会有很大帮助,但是我还是希望可以了解如何通过优秀的代码设计来辅助单元测试。像Moq或是EasyMock都是不错的选择。

如果您有任何反馈,例如上面的需求有任何不清楚的地方,也请立即提出,我会做出改进。这次我打算给三位积极参与并提供较好实现的同学分别送上一本书(或是其他您想要的礼物)作为感谢。

Creative Commons License

本文基于署名 2.5 中国大陆许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名赵劼(包含链接),具体操作方式可参考此处。如您有任何疑问或者授权方面的协商,请给我留言

Add your comment

27 条回复

  1. cnhzlt
    122.233.43.*
    链接

    cnhzlt 2012-01-07 17:37:01

    ConnectionFailed事件:在每次连接失败是触发 错字,是->时

  2. Cat Chen
    106.187.35.*
    链接

    Cat Chen 2012-01-09 14:01:45

    这么复杂的需求,从社区的角度来说我觉得难以利用群体智慧,还是找少数专家讨论比较好。(你面试过的 C# 程序员有多少会主动写 unit test 的?)

    场景设计复杂度如此之高,让人读完本身就是很难的事情,你能否把它 break down 一下?这样比较 reader friendly。

  3. 星晴
    218.205.172.*
    链接

    星晴 2012-01-09 16:38:01

    我想试一下,我读了c#版本的,但会尝试java的代码。我想到的需要测试的地方:

    1. client 和driver能正常通信。
    2. client 能正常解析通信内容:哪个queryId、是否是“begin”信息。
    3. 解析通信内容后,能通知给订阅者,能先后调用订阅者的OnBegin()、OnMessage。

    不知我理解的大致方向对不?还有,我不太清楚这个地方需要怎么测试:“根据QueryID分派到对应的subscriber中(在Receive线程中分派即可)”。

  4. 老赵
    admin
    链接

    老赵 2012-01-09 21:47:02

    @Cat Chen

    这个场景复杂度如果也算高的话,那么单元测试真又要变成纸上谈兵了。我就是不想找那些最简单的情况,结果导致我也会做,但还是解决不了实际情况……当然可读性上面我再看看能不能改进……

  5. 老赵
    admin
    链接

    老赵 2012-01-09 21:48:29

    @星晴

    这个我不想限制实现方式和测试,所以就不给我的看法了啊……其实单元测试的确很难测试并发情况,所以我们就测试非并发的逻辑吧……

  6. 听雨
    202.167.248.*
    链接

    听雨 2012-01-10 09:54:53

    我认为单元测试就是单个函数或者单个功能块的测试。整个项目做单元测试,不适用吧!

  7. 老赵
    admin
    链接

    老赵 2012-01-10 10:19:17

    @听雨

    谁让你对整个项目做单元测试?

  8. 听雨
    202.167.248.*
    链接

    听雨 2012-01-10 10:46:58

    我做单元测试一般只对单个函数做测试,不考虑多线程,并行什么的,多线程需要单独做!如果是对单个函数功能或者小模块做测试,应该就没有问题的吧!

  9. 老赵
    admin
    链接

    老赵 2012-01-10 15:33:48

    @听雨

    行啊,你可以只做和多线程无关的逻辑部分的单元测试。

  10. 程序猿某人
    114.112.45.*
    链接

    程序猿某人 2012-01-11 01:32:52

    老赵你好,关于这个单元测试的研讨,我写在自己的博客里,目前刚开个一个头。地址:http://blog.sina.com.cn/s/blog_879b4e7b0100w2ml.html

    下面是节选:

    首先创建一个新的项目MyClient.UnitTest,类型为Windows Console(使用Console类型项目做单元测试的好处是既可以运行测试类,也可以很方便地写调试方法,并在console上查看调试信息)。

    然后加入单元测试框架的引用——这里我使用的是NUnit 2.5.3,.net平台上最古老的xUnit框架——我引入了两个assembly引用:NUnit.Framework和NUnit.Mock。

    接下来创建第一个测试类:MyConnectionTestCases。第一个要测试的东西是MyConnection的Connect方法,所以在MyConnectionTestCases类里添加一个方法

    [TestFixture]
    public class MyConnectionTestCases
    {
        [Test]
        public void OpenConnection()
        {
        }
    }
    

    接下来要想想怎么写。这是命题作文,类的接口设计已经完成,没什么自由发挥的空间,并且指明要使用MyDriver作为实现,显然这是一个白盒的测试,需要在测试方法中描述MyConnection和MyDriver的交互序列。说实话这是我最不愿意面对的一种情况,在初期过多对于被测试方法内部实现的描述会导致mock的泛滥,对可读性和可维护性的影响非常大。

    好吧,硬着头皮做下去。MyConnection.Connect方法要调用MyDriver.Connect。在连接成功后,会触发Connected事件,否则会在ReconnectInterval之后重新连接。对这个逻辑进行条件覆盖,我们需要能够控制MyDriver的行为,所以要用到Mock或Fake——这里我选择使用Mock。MyDriver是不能修改的,为了测试方便,还是要提取一个接口IMyDriver,

    public interface IMyDriver
    {
        void Connect();
        void AddQuery(int queryId);
        void RemoveQuery(int queryId);
        void Close();
        MyData Receive();
    }
    

    (注:在现实中,如果完全接触不到源代码,为了测试我们也可以提取一个adapter接口来帮助插入一个mock/stub对象,当然更好的手段是采用无侵入的mock框架,这样不会产生专为测试而生的接口或工厂类这样的坏味道。)

    然后,对MyConnection的constructor进行修改:由于传入的uri的个数不定,为了IMyDriver的实现换成mock,要么引入工厂对象,要么直接传入IMyDriver数组替代uri。后者显然更简单,于是,

    public class MyConnection
    {
        public readonly static int ReconnectInterval = 3000;
    
        public MyConnection(IMyDriver[] myDrivers) // uris替换成myDrivers
        {
            throw new NotImplementedException();
        }
    
        // ...
    }
    

    回头开始实现MyConnectionTestCases.OpenConnection,

    [Test]
    public void OpenConnection()
    {
        var myDriverMocks = new DynamicMock[]
                                {
                                    new DynamicMock((typeof(IMyDriver)))
                                };
        var drivers = new IMyDriver[]
                          {
                              (IMyDriver)myDriverMocks[0].MockInstance
                          };
    
        var connection =
            new MyConnection(drivers);
    
        myDriverMocks[0].Expect("Connect");
    
        connection.Open();
    
        myDriverMocks[0].Verify();
    }
    
  11. 老赵
    admin
    链接

    老赵 2012-01-11 12:05:40

    @程序猿某人

    写好了发个链接吧,我周末也搞一下,呵呵。

  12. uubox
    112.91.181.*
    链接

    uubox 2012-01-12 17:38:28

    其实测试并发逻辑也好做!

  13. 老赵
    admin
    链接

    老赵 2012-01-12 19:42:58

    @uubox

    说说看可以怎么做?比如就测试一个方法里面的实现是不是线程安全的吧,比如就List.Add。

  14. qiaojie
    101.80.35.*
    链接

    qiaojie 2012-01-14 00:49:28

    你这个需求还真不太容易做,至少要开2个后台线程,还要涉及同步和错误处理,实现起来总感觉很不顺手。 实现我写好了,单元测试用例写了三个,感觉不太好做,目前还不是很满意。先把代码放在git上给大家提提意见吧,等我有空了再改进。 https://github.com/qiaojie/unit-test-practices

  15. 链接

    张志敏 2012-01-14 10:48:20

    只熟悉 Silverlight, Nunit用的不多, 关于多线程以及事件的测试,可以看一下这里

    另外, Silverlight也可以比较好的处理这类的测试

    希望对老赵有一些帮助。

  16. WuYi
    219.136.248.*
    链接

    WuYi 2012-01-14 17:26:09

    @星晴 @老赵

    个人愚见,且从“通信”二字展开来“开始上手的时候总会遇到各种各样的坎”:

    1. UT的粒度问题:关乎“通信”,亦即关乎“网络”,关乎“网络”,那还是UT么?然而不测试“网络”,绝大多数类似应用,余下的实在木有神马逻辑,需之一测。
    2. 产品代码接口的粒度与依赖管理问题:且忽略“很是学术的1”。倘若把通信的相关抽取出来,mock之,那么如此大粒度的测试,编写起来必然会很笨重,很花费时间,渐渐的又会陷入一个小小的测试却不得不mock一堆接口的尴尬境地。该如何把握接口的粒度与依赖呢?再而,即便可以“不厌其烦”地mock,mock,mock,久而久之,多个版本的mock也相当的难以管理,倘若mock无法模拟得足够贴近真实的使用场景,那么这样测试便是“不可信”的。一份不可信的UT,边界条件考虑得再怎么周全也是枉然。
    3. UT的管理问题:随着时间的推移,bug的显现,用例会越来越多,有些UT会失效,有些UT会重复,UT的维护也将是个难题。
    4. case的设计问题:怎样的测试才是“足量”的呢?《Pragmatic Unit Testing》里所描述的CORRECT?然而我们似乎没有那么多的时间?
    5. 并发问题:我们要考虑并发吗?考虑吧,还是那句话:UT会很笨重,难以编写,更不要说旁人理解了。不考虑吧,UT就会悖于真实的环境,不真实的测试,值得我们信赖么?

    如此种种……

  17. 程序猿某人
    123.150.196.*
    链接

    程序猿某人 2012-01-15 20:16:41

    一直没抽出时间来,下午更新了一篇

    http://blog.sina.com.cn/s/blog_879b4e7b0100w565.html

    晚上接着写,争取写完。

  18. 老赵
    admin
    链接

    老赵 2012-01-15 20:28:28

    @程序猿某人

    我比较懒,估计今天写不完了……

  19. 程序猿某人
    123.150.196.*
    链接

    程序猿某人 2012-01-15 21:50:01

    第三部分,MyConnection.Close的方法的测试与实现:http://blog.sina.com.cn/s/blog_879b4e7b0100w59w.html

  20. 程序猿某人
    123.150.196.*
    链接

    程序猿某人 2012-01-15 22:14:59

    @老赵

    请问MyDriver.AddQuery方法是否可重入?

  21. 程序猿某人
    123.150.196.*
    链接

    程序猿某人 2012-01-15 23:02:36

    @老赵

    估计我也写不完了,嘿嘿

  22. 老赵
    admin
    链接

    老赵 2012-01-16 14:38:25

    @程序猿某人

    忘说了,所有方法必须顺序执行,不能并行,唯一的例外算是Receive吧,它可以在另外一个线程里单独使用,但其本身的调用也必须顺序执行。

  23. vczh
    207.46.92.*
    链接

    vczh 2012-01-17 17:38:33

    不用纠结了,你看这个书吧:http://www.amazon.com/xUnit-Test-Patterns-Refactoring-Code/dp/0131495054

  24. 程序猿某人
    123.150.196.*
    链接

    程序猿某人 2012-01-17 22:54:57

    @老赵

    明白了,我继续。

  25. 程序猿某人
    123.150.196.*
    链接

    程序猿某人 2012-01-17 23:36:16

    @老赵

    还有一个问题。怎样能够终止MyDriver.ReceiveData的阻塞状态?是不是可以加个timeout参数?如果MyDrive没有Add任何一个Query就ReceiveData,是不是就一直阻塞着?

  26. 程序猿某人
    123.150.196.*
    链接

    程序猿某人 2012-01-18 00:37:28

    @老赵

    又想了一下似乎不必,ReceiveData是可以和AddQuery/RemoveQuery并发执行的,所以query变化后何时生效是MyDrive内部实现决定的,在MyConnection里不需要操心,是这样么?

  27. 老赵
    admin
    链接

    老赵 2012-01-18 10:19:18

    @程序猿某人

    ReceiveData一直阻塞,没法超时或取消,直到出错或关闭连接。这个其实够用了。

发表回复

登录 / 登录并记住我 ,登陆后便可删除或修改已发表的评论 (请注意保留评论内容)

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

评论内容(大于5个字符):

  1. Your Name yyyy-MM-dd HH:mm:ss

使用Live Messenger联系我