求知 文章 文库 Lib 视频 iPerson 课程 认证 咨询 工具 讲座 Modeler   Code  
会员   
 
  
 
 
     
   
分享到
一份用于学习单元测试的案例需求
 
作者:老赵,发布于2012-3-7
 

终于把这份实现写完了,比想象中要花时间,尤其是为了可测试性而增加的代码结构。我并没有使用TDD来开发这个类库,依然是先写代码,再写单元测试,测试代码也只关注了代码主体,没有刻意去测试边界情况。一部分原因是其中都是内部实现,可以把握住输入,令一部分原因是这段实现主要是各种交互,而没有复杂的业务逻辑。我个人满足于单元测试而不是测试驱动开发,但如果您是使用测试驱动开发(TDD)甚至传说中的BDD来实现这个方案,那就更好不过了。

引入接口与工厂

我经常嚷嚷说,为了增加代码的可测试性,我必须在项目里不断引入各种抽象。例如写一个用于连接的MyConnector类,它包含一个构造函数和三个成员,原本我只需要这么写:

internal class MyConnector

{

    public MyConnector(string[] uris, IConnectionEventFirer eventFirer) { ... }

    public IMyDriverClient Client { get; private set; }

    public void Connect() { ... }

    public void CloseClient() { ...}

}

但是,假如有其他类使用了MyConnector,会发现MyConnector是没法Mock的(除非使用一些基于二进制修改的Mock类库)。例如,它的所有成员都是非虚(non-virtual)的。有人说,把它全部标为virtual不就行了嘛,像Java里面默认就是virtual的。但我不喜欢这样,因为这个类本来就没打算有扩展的场景,也不想为了单元测试而去改变代码实现。因此,为了可测试性,代码便会变成这个样子:

internal interface IMyConnectorFactory

{

    IMyConnector Create(string[] uris, IConnectionEventFirer eventFirer);

}

internal interface IMyConnector

{

    IMyDriverClient Client { get; }

    void Connect();

    void CloseClient();

}

internal class MyConnector : IMyConnector

{

    private class Factory : IMyConnectorFactory

    {

         public IMyConnector Create(string[] uris, IConnectionEventFirer eventFirer)

        {

            return new MyConnector(uris, eventFirer);

        }

    }

    public static readonly IMyConnectorFactory DefaultFactory = new Factory();

    public MyConnector(string[] uris, IConnectionEventFirer eventFirer) { ... }

    public IMyDriverClient Client { get; private set; }

    public void Connect() { ... }

    public void CloseClient() { ...}

}

为了可测试性,我们在具体的实现类以外还增加了:

接口:使用者通过接口访问该类,便于Mock类的成员。

工厂接口:注入使用者,便于创建实例,相当于Mock构造函数。

具体类的默认工厂:定义在具体类内部,用于创建一个具体类的实例。

当然,使用我之前提过的方法便可以省去两个接口,只留两个具体类就行了。

模拟MyDriver代码

单元测试离不开Mock,但是我发布示例代码之后,有几个人(都是Java同学)向我反映说MyDriver项目里的MyDriverClient类为什么是final的,能否将其去除以便Mock?我说不行,这里我是故意设置的障碍,因为实际情况下第三方的类库可能也是这种情况,因此需要自己在MyClient项目里解决这个问题。

当然解决方法并不难,只需要为MyDriverClient写一个接口及封装类即可,自然还有对应的工厂类型:

internal interface IMyDriverClient : IDisposable

{

    void Connect();

    void AddQuery(int queryId);

    void RemoveQuery(int queryId);

    MyData Receive();

}

internal interface IMyDriverClientFactory

{

    IMyDriverClient Create(string uri);

}

internal class MyDriverClientWrapper : IMyDriverClient

{

    private class Factory : IMyDriverClientFactory

    {

        public IMyDriverClient Create(string uri)

        {

            return new MyDriverClientWrapper(new MyDriverClient(uri));

        }

     }

     public static readonly IMyDriverClientFactory DefaultFactory = new Factory();

     private readonly MyDriverClient _client;

     public MyDriverClientWrapper(MyDriverClient client)

    {

        this._client = client;

     }

    public void Connect()

    {

        this._client.Connect();

    }

     ...

}

这么做除了便于单元测试以外,还可以形成一个窄接口,避免在使用的时候迷失在繁复的成员里。

抽象多线程操作

多线程操作会直接用到Thread类以及相关静态方法,这些也是不利于单元测试的地方,为此我抽象出了一个IThreadUtils接口,以及一个默认实现:

internal interface IThreadUtils

{

    void Sleep(int millisecondsTimeout);

    void StartNew(string name, ThreadStart start);

}

internal class ThreadUtils : IThreadUtils

{

    public static readonly ThreadUtils Instance = new ThreadUtils();

    public void Sleep(int millisecondsTimeout)

    {

        Thread.Sleep(millisecondsTimeout);

    }

    public void StartNew(string name, ThreadStart start)

    {

         var thread = new Thread(start);

        thread.Name = name;

        thread.Start();

    }

}

在代码里所有的线程操作都会使用IThreadUtils完成,便于模拟。不过,在单元测试的时候,我们还必须真正去检查“新线程”有没有执行正确的代码。为此,我还实现了一个DelayThreadUtils类,专供单元测试使用:

public class DelayThreadUtils : IThreadUtils

{

    private List<Action> _actionsToExecute = new List<Action>();

    public virtual void StartNew(string name, ThreadStart start)

    {

        this._actionsToExecute.Add(() => start());

     }

    public void Sleep(int millisecondsTimeout) { }

     public void Execute()

    {

        foreach (var action in this._actionsToExecute) action();

    }

}

在DelayThreadUtils中,所有的StartNew调用都只是“收集”操作,并不执行,一切都延迟到Execute方法调用时才真正执行。在单元测试里使用DelayThreadUtils的模式大约为(基于Moq类库):

// 1. 准备Mock对象

var threadUtilsMock = new Mock<DelayThreadUtils> { CallBase = true };

// 2. 使用threadUtilsMock.Object

// 3. 确认ThreadUtils上的相关方法已经正确调用

threadUtilsMock.Verify(tu => tu.StartNew("Some Name", It.IsAny<ThreadStart>()));

// 4. 确认线程里的操作没有执行

// 5. 执行线程里的操作

threadUtilsMock.Object.Execute();

// 6. 确认线程里的操作已经正确执行总而言之,我们只是想要确认目标代码的确是在新线程里执行。

构造函数

还是拿MyConnector为例,它在实际使用时其实只需要这样一个构造函数:

public MyConnector(string[] uris, IConnectionEventFirer eventFirer) { ... }

但在内部实现的时候,我们还需要线程操作,也需要创建MyDriverClient对象,都是涉及单元测试的依赖。因此,我们也会准备另一个“完整”的构造函数,用于注入所有需要的依赖,而真正使用的构造函数则委托至“完整”的构造函数上:

internal MyConnector(

    string[] uris,

    IConnectionEventFirer eventFirer,

    IThreadUtils threadUtils,

    IMyDriverClientFactory clientFactory) { ... }

public MyConnector(string[] uris, IConnectionEventFirer eventFirer)

    : this(uris, eventFirer, ThreadUtils.Instance, MyDriverClientWrapper.DefaultFactory) { }

为了区分“实际使用”的构造函数和“用于测试”的构造函数,我的规则是使用public和internal进行区分。由于它们大都是定义在内部类里,因此两者效果其实没有什么不同,只是为了“看上去”能分清而已。

MyConnection实现简要描述

MyClient项目唯一暴露的类型便是MyConnection。MyConnection的绝大部分操作都会委托给MySubscriptionManager,其完整功能被拆分成了数个小部分,每个小部分都能独立的实现和测试,代码不多,属于可测试的范围内。

MyConnector类封装了与服务器连接相关的逻辑,包括失败后的重试:

  • Connect方法:尝试连接服务器,直至成功。连接失败时会发起ConnectFailed事件,成功后发起Connected事件,并在Client属性里暴露出可用的MyDriverClient对象。
  • CloseClient方法:关闭已经打开的MyDriverClient对象,并发起Disconnect事件。如正在尝试连接但还未完成,则停止尝试。该方法调用后可以重新调用Connect方法。
  • Client属性:可用的MyDriverClient对象,如果尚未成功连接,则该属性为null。

MyConnector的Connect方法总是在一个新线程里执行,连接成功后会触发Connected事件,由MySubscriptionManager的OnConnected方法响应,并开启三个工作线程,它们分别是:

  • MyRequestSender:从一个BlockingCollection<MyRequest>集合中不断获取MyRequest请求(Subscribe或Unsubscribe),并使用MyConnector.Client上的AddQuery或RemoveQuery方法。假如从集合中获取请求时操作被取消,则退出该任务。假如AddQuery或RemoveQuery时抛出异常,则关闭MyConnector对象。
  • MyDataReceiver:使用MyConnector.Client上的Receive方法不断获取MyData数据,并放入DataProduced集合。假如Receive方法抛出异常,则关闭MyConnector对象。MyDataReceiver同时还暴露出一个CancellationToken表明操作是否已经取消。假如Receive方法返回null(意味着了MyConnector已在其他地方关闭)或抛出异常,则都会将这个CancellationToken取消,并退出任务。简单地说,MyDataReceiver就是“生产者”。
  • MyDataDispatcher:从一个BlockingCollection<MyData>集合中不断获取MyData对象,并分派至对应的IMySubscriber对象中。假如从集合中获取对象时操作被取消,则退出该任务。简单地说,MyDataDispatcher是一个“消费者”,消费MyDataReceiver生产出来的MyData对象。

剩下的便是MySubscriptionManager内部的协调工作了,它也会监听Disconnected事件,并重新调用MyConnector.Connect方法,后者会触发Connected事件,并重新开启三个新的MyRequestSender、MyDataReceiver,MyDataDispatcher任务。简单的说:旧的任务会在任意环节出错时停止,而每次重新连接之后,都会开启新的任务。

MyClient的Program.cs项目中包含了简单的使用案例。

总结

所有的代码都可以在GitHub项目里的csharp/Practice01-End目录里找到,包括实现以及单元测试。Java项目我就没有精力再做一份了,但是我想这不会影响交流,使用Java的同学肯定也可以理解C#代码,我也会继续关注一些Java同学所实现的代码,需要的时候也会将其移植为C#代码。

单元测试的组织方式我尝试了使用Phil Haacked之前提到的做法,感觉不错。我使用VS 2010编写代码,但没有使用VS集成的单元测试框架,这方面我使用的是xUnit,一方面原因是由于xUnit更符合我的审美(这点以后再说),另一方面原因是我不想让示例与开发环境产生依赖,现在VS 2010 Express甚至Mono下都能运行这些代码。Mock类库使用的是Moq,这应该也是目前最流行的C#标准Mock类库了吧。所有依赖的类库我都用NuGet进行管理,您也可以通过NuGet来安装这些类库(执行Practice01-End目录下的install-packages.cmd文件即可)。

这只是个练习,因此我也有一些问题还没有完全想明白:

  • 设计上有没有什么问题?以上为了可测试性引入的代码结构是否必要?如果不必要,可以怎么做?
  • 所有代码依旧是设计先行,然后实现,最后再单元测试,而不是使用TDD进行开发。从最后的结果来看,似乎我更多是在测试“交互行为”而不是输入输出,是否合适?这是否是因为没有TDD的缘故?
  • 但是,对于此类非“逻辑验证”类型的代码,直接让我用TDD来开发,我真心感到无从下手。因为如果不是事件划分好职责,我很难获得很好的可测试性,不知如何单元测试,更不论测试驱动开发了。
  • 并发方面的代码能否进行单元测试?例如MyConnector里就要考虑在Connect方法还没有返回的时候,使用CloseClient进行中断。这部分能用TDD实现吗?
  • 能否用BDD开发这个案例吗?只要开个头就行,让我明白,剩下的我自己来。

如果我有更多问题也会不断列出,欢迎大家一起来讨论。


相关文章

微服务测试之单元测试
一篇图文带你了解白盒测试用例设计方法
全面的质量保障体系之回归测试策略
人工智能自动化测试探索
相关文档

自动化接口测试实践之路
jenkins持续集成测试
性能测试诊断分析与优化
性能测试实例
相关课程

持续集成测试最佳实践
自动化测试体系建设与最佳实践
测试架构的构建与应用实践
DevOps时代的测试技术与最佳实践


LoadRunner性能测试基础
软件测试结果分析和质量报告
面向对象软件测试技术研究
设计测试用例的四条原则
功能测试中故障模型的建立
性能测试综述
更多...   


性能测试方法与技术
测试过程与团队管理
LoadRunner进行性能测试
WEB应用的软件测试
手机软件测试
白盒测试方法与技术


某博彩行业 数据库自动化测试
IT服务商 Web安全测试
IT服务商 自动化测试框架
海航股份 单元测试、重构
测试需求分析与测试用例分析
互联网web测试方法与实践
基于Selenium的Web自动化测试
更多...