一份用于学习单元测试的案例需求(实现)
2012-02-03 18:44 by 老赵, 6841 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实现吗?
如果我有更多问题也会不断列出,欢迎大家一起来讨论。
我不赞成对这个class做这个粒度的单元测试,除非它是一个提供给外部使用的接口class。正如你所说的,对“非逻辑验证”类型的代码,是不适合用TDD的。我以为,单元测试,针对的应该是业务逻辑。因此,其测试的最小单元,也应当是能够从业务逻辑划分出来的最小单元。这个class,很明显,它体现不出一个业务逻辑单元,它应当是参与到业务逻辑单元中去的。因此,实际上,如果能够对此class参与的业务逻辑单元进行足够的测试,那么,这个class的质量自然而然的也就得到了保证。