Hello World
Spiga

一份用于学习单元测试的案例需求(实现)

2012-02-03 18:44 by 老赵, 6716 visits

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

引入接口与工厂

我经常嚷嚷说,为了增加代码的可测试性,我必须在项目里不断引入各种抽象。例如写一个用于连接的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的。例如,它的所有成员都是非虚(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#代码。

我使用VS 2010编写代码,但没有使用VS集成的单元测试框架。我使用的是xUnit,一方面原因是由于xUnit更符合我的审美(这点以后再说),另一方面原因是我不想让示例与开发环境产生依赖,现在VS 2010 Express甚至Mono下都能运行这些代码。Mock类库使用的是Moq,这应该也是目前最流行的C#标准Mock类库了吧。所有依赖的类库我都用NuGet进行管理,您也可以通过NuGet来安装这些类库。

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

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

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

Creative Commons License

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

Add your comment

5 条回复

  1. FF
    114.249.162.*
    链接

    FF 2012-02-03 20:16:15

    我不赞成对这个class做这个粒度的单元测试,除非它是一个提供给外部使用的接口class。正如你所说的,对“非逻辑验证”类型的代码,是不适合用TDD的。我以为,单元测试,针对的应该是业务逻辑。因此,其测试的最小单元,也应当是能够从业务逻辑划分出来的最小单元。这个class,很明显,它体现不出一个业务逻辑单元,它应当是参与到业务逻辑单元中去的。因此,实际上,如果能够对此class参与的业务逻辑单元进行足够的测试,那么,这个class的质量自然而然的也就得到了保证。

  2. 老赵
    admin
    链接

    老赵 2012-02-03 22:04:00

    @FF

    不适合TDD是一说,不应该对它进行单元测试,这话有问题吧?要知道这个类虽然没有什么业务逻辑,但是也足够复杂,它怎么参与到别的业务逻辑单元里面去?要我看来依赖它的逻辑单元在测试时,也是Mock这个MyConnection的,不是么?否则的话,每次运行单元测试的话,都要连接服务器吗?更何况这还是涉及多线程操作的。

  3. Jianyi
    113.29.65.*
    链接

    Jianyi 2012-04-12 15:27:30

    为了搞个测试加这个多累u啊。直接上dynamic吧:

    dynamic obj= ReflectionDynamicObject.WrapObjectIfInternal(new AnyObject());
    obj.CallAnyMethod();
    
  4. 老赵
    admin
    链接

    老赵 2012-04-12 17:55:29

    @Jianyi

    dynamic在重构时就起不到检查的作用了。

  5. kinogam
    61.140.22.*
    链接

    kinogam 2012-05-09 22:26:29

    可惜现在没有怎么研究c#了,要是老赵有空,希望能看到关于javascript的单元测试经验交流。

发表回复

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

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

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

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

使用Live Messenger联系我