终于把这份实现写完了,比想象中要花时间,尤其是为了可测试性而增加的代码结构。我并没有使用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开发这个案例吗?只要开个头就行,让我明白,剩下的我自己来。
如果我有更多问题也会不断列出,欢迎大家一起来讨论。
|