什么是单元测试?为什么要进行单元测试?如需要进一步了解,请移步*。
关于.net程序单元测试的文章,网上已经有很多,但我相信我写的这篇文章的内容是独特的,因为我在网上找了很久,都没找到关于StructureMap(参考前一篇博文《谈谈.net模块依赖关系及程序结构》)与VisualStudio单元测试相结合的好教程。但我必须声明一下,这只是我的方法,并不代表就是最好的方法,也许你能找到更好的。
.net单元测试的简单例子
照旧,从一个最简单的例子开始,这里没有StructureMap,也没有模块与模块之间的依赖关系,只有一个简单的模块——HR,需要进行单元测试,我们来感受下,究竟什么是单元测试。先看下程序结构图:
只有一个Hr模块,暴露了一个IHr接口,我们现在写一个叫“TestDemo”的程序,来对它们进行单元测试。测试代码片段如下:
[TestClass] public class HrTester { [TestMethod] public void TestGetAllEmployees() { //... } //... [TestMethod] public void IntegrationTest() { IHr hr = new HrManager(); const string empNoToTest = "0100"; try { hr.AddEmployee(new Employee_Add() { EmpNo = "0001", Name = "Somebody", Birthday = DateTime.Now }); Assert.Fail("尝试增加已存在工号应当抛出异常"); } catch (Exception ex) { Assert.IsInstanceOfType(ex, typeof(DemoException)); } //... } }
代码太多,我只贴了一小部分出来说明,我是定义了一个叫HrTester的类来对Hr模块进行单元测试的,这个类被加上“TestClassAttribute”修饰,而类中需要被测试的方法则加上“TestMethodAttribute”修饰,这两个修饰类都来自“Microsoft.VisualStudio.QualityTools.UnitTestFramework”,需要给测试工程引用这个类库。
当如上图般启动了单元测试之后,VisualStudio就尝试在项目中寻找有“TestClass”修饰的类,并且从这些类中寻找有“TestMethod”修饰的方法,执行这些方法,当然了,也可以直接指定某个测试类或者某个测试方法来让VisualStudio进行单独的测试,而不是全部。如果一个测试方法跑下来没有发生任何异常,也没有任何断言被触发,那么就被认为测试通过,比如你定义了一个空的测试方法,那么毫无疑问是能通过的。另外需要额外说明的一点是:测试方法的定义有些要求,不能带参数,必须为public,返回类型必须是void,想想确实应该如此啊……测试结果会展示在TestExplorer窗口中:
(可通过 <TEST> - <Windows> - <Test Explorer>打开此窗口)
如果测试不通过,那显示出来的就不是绿色的小勾,而是个红色的小叉,点上去,可以查看详情。
从单元测试设计上来说,可以给IHr接口的每个方法都写一个测试方法,也可以统统合并为一个“综合测试方法”,如我上面的“IntegrationTest”就是一个综合方法,我偏向于使用综合方法,一来可以减少一些编码量,二来是业务逻辑上的需要,比如我希望在新增完一个员工之后,再马上将此员工的信息取出作比较,以此来判断正确与否,实践中究竟怎么写,这个要根据实际情况。
为了能够方便进行全方面的测试,我们在做接口设计的时候也要把测试考虑进去,比如要写完整的“获取”方法,方便执行了各种操作之后,能够重新取回值进行验证检查。
关于Assert的其它使用说明,可以参考MSDN。
当然了,也许你看到这里还是有些不太明白,如果之前没有做过单元测试的话,没关系,我提供了完整代码,操练下就很快明白了。
DEMO1完整代码(VS2012)
面向接口编程的优势
这里的“面向接口编程”并非是区别于“面向对象编程”的另外一种编程思想,它其实只是属于面向对象编程中的一种设计模式,即只要固定好了接口,那么接口实现者的改变就能对接口调用者透明,反之依然。其根本目的是什么?——解耦!
现在,我们对我们的DEMO1程序进行一些调整,修改HR模块的实现方式,这次使用SQL Server 2012 Express,而不是一个内存对象来存储数据。
这里顺便提一下SQL Server 2012 Express的使用方式,一开始我还有些被搞糊涂了,明明安装了,但在系统服务中就是看不到相关的服务的存在,原来跟SQL Server 2008 Express相比,它的机制变了,用微软的话说,是更加节省资源,只有你在使用它的时候,服务才会启动起来,平常是看不到的。我们使用SQL Server Management Studio去连接的时候,要填的东西跟之前的也是有些区别的,如图:
服务器名称这栏变成了“(localdb)\v11.0”,而不是之前的“.\SQLEXPRESS”。你可以通过命令行查看目前有哪些数据库运行实例:
>sqllocaldb info
用这个命令还可以创建和删除实例,以及查看实例的详情,具体使用可以“sqllocaldb -?”。默认情况下,可以在“%userprofile%\AppData\Local\Microsoft\Microsoft SQL Server Local DB\Instances”目录下找到数据库实例的数据文件。OK,我觉得应该就此打住,毕竟本文不是讲数据库的。大家还是直接下载我的DEMO2来看一下吧,这算是一段附加说明,跟单元测试关系不大,我只是想说明这么一个观点:面向接口很有爱。
这里附上数据库创建的相关脚本(先创建一个叫“hr_db”的数据库):
create table employees( emp_no nvarchar(20) primary key, name nvarchar(20) not null, birthday datetime not null, descriptions nvarchar(200), status nvarchar(15) not null, salary decimal(10,2) not null ); insert into employees(emp_no, name, birthday, descriptions, status, salary) values (N‘0001‘, N‘蒋国纲‘, ‘1981-11-12‘, N‘技术宅男‘, N‘Normal‘, 5000.00); insert into employees(emp_no, name, birthday, descriptions, status, salary) values (N‘0002‘, N‘周星驰‘, ‘1962-06-22‘, N‘喜剧之王‘, N‘Probation‘, 2000.00); insert into employees(emp_no, name, birthday, descriptions, status, salary) values (N‘0003‘, N‘李逍遥‘, ‘1958-12-19‘, N‘蜀山派掌门人‘, N‘Leaved‘, 8000.00);
DEMO2完整代码(VS2012)
StructureMap与单元测试
上面的例子是否太简单了?不过话说不管怎么简单的东西,你还是得真正动手捣鼓下才能弄明白。
现在,我们来玩稍微复杂一点的,这次加入了一个Log模块,HR模块依赖于它,用StructureMap(如对StructureMap不了解,请查看本文开头提到的“前一篇博文”)来管理它们的依赖关系,程序结构图变成了这样:
HR模块,需要使用Log,它是通过这种方式来请求到ILog的:
ILog logger = ObjectFactory.GetInstance<ILog>();
而程序启动的时候,我们会配置好StructureMap,来指明“ILog”及其实现类之间的关系:
ObjectFactory.Initialize(x => { x.For<ILog>().Singleton().Use<LogManager>().Ctor<string>("logPath").Is(AppDomain.CurrentDomain.BaseDirectory + "log"); });
OK,聪明的读者,有没有发现什么问题?
注意!我们是在“程序启动的时候”来配置StructureMap的,但运行测试项目的时候并不会启动程序,那我们应该在什么地方来做这个配置?
所幸的是微软也考虑到了这点,所以微软提供了AssemblyInitializeAttribute来供我们使用,告诉测试环境,这个方法是在启动测试前要执行的。这是完整的写法:
[TestClass] public static class ContainerBootstrapper { [AssemblyInitialize] public static void BootstrapStructureMap(TestContext context=null) { ObjectFactory.Initialize(x => { x.For<IHr>().Singleton().Use<HrManager>(); x.For<ILog>().Singleton().Use<LogManager>().Ctor<string>("logPath").Is(AppDomain.CurrentDomain.BaseDirectory + "log"); }); } }
class在这里也必须加上“TestClass”修饰,而修饰为“AssemblyInitialize”的方法必须为public、static、void、以及需要带上一个TestContext类型的参数。关于AssemblyInitialize的完整帮助,可以查阅MSDN。
其余的都跟DEMO2没什么差别,测试程序我这里是一点都没变。
好,自行演练时间到:DEMO3完整代码(VS2012)
Mock——真正的单元测试
大家停下来想想DEMO3有什么问题?
问题就是HR模块依赖于Log模块,要对HR模块进行单元测试,就必须先实现好Log模块,而且要求Log模块没有问题,否则单元测试就可能通不过,从这个角度看,这不是真正的对HR模块的单元测试,而是对多个模块的综合测试了……假如HR模块以后还依赖于Office模块、Flow模块、Administration模块……更糟糕的是Office模块又依赖于Vehicle模块、Finance模块、PubMaterial模块……那就意味着这个“单元测试”几乎没法做了,因为这些模块可能是分配给不通的人去实现的,有些人写完了,有些人没写完,只要有一个模块没写完,就没办法对HR模块进行单元测试,只要一个依赖模块有bug,就可能导致HR模块的单元测试通不过。如何解决?
最直截了当的方法当然是:我们来造一个假的Log模块,完全实现ILog接口,但实际上可能并不会真正地记录日志。这样就可以让单元测试进行下去。
这种造假的方法就叫“Mock”,Mock这个单词和Imitate是同义词,仿造,模仿的意思。现在我们来Mock一个Log模块:
class Mock_ILog : ILog { public void Log(LogType logType, string moduleName, string content, params object[] values) { Debug.Write("ILog::Log is called."); } }
太简单了,这个类实现了ILog接口,但它显然没记录日志,只是打印一些debug信息。即便实现Log模块的那位兄弟啥都没写,我们的单元测试也可以开始了。
不过别高兴得太早,到了这步,我们还是碰到了一些问题,必须得先解决:StructureMap的配置是否得改?按理说是不能改的,因为那是正常程序运行所需要的,你做单元测试的时候希望使用“Mock_ILog”,而真实的运行还是得用真家伙啊,如果每次做单元测试的时候都要先改一通,测完之后再改回来,那是不是很繁琐?而且一不小心就改错?
所幸的是:微软和StructureMap的作者都想到了这点。我在HrTester类中加入以下的代码:
[ClassInitialize] static public void Init(TestContext context) { ObjectFactory.Configure(x => { x.For<ILog>().Singleton().Use<Mock_ILog>(); }); } [ClassCleanup] static public void EndTest() { ObjectFactory.ResetDefaults(); }
ClassInitialzie修饰表示在测试此类前,此方法会被调用一次;而ClassCleanup修饰则表示测试完此类后,此方法会被调用一次。ClassInitialzie的具体说明:MSDN;ClassCleanup的具体说明:MSDN。
ObjectFactory.Configure方法可以动态修改StructureMap的配置,而ObjectFactory.ResetDefaults方法则可将StructureMap的配置还原为初始状态。这样一来,测试的归测试,正式的归正式,互不干扰。
使用Moq简化Mock
如果依赖关系复杂起来了,那给对每个模块都Mock一下就让人感觉有些繁琐,(其实通过上面的那个ILog的例子看也繁琐不去哪里)有没有自动帮创建Mock的工具?——当然有,其中一个是Moq,Moq的最简单用法如此般:
Mock<ILog> mockILog = new Mock<ILog>(); ILog logger = mockILog.Object;
这样一个Log模块就mock出来了,当然了,你尝试调用它的Log方法的话,你很快就发觉这是一个空方法。
那我想跟上面那个手工创建的Mock_ILog那样,能打印点Debug信息出来怎么办?写法稍微有点复杂:
Mock<ILog> mockILog = new Mock<ILog>(); mockILog.Setup(c => c.Log(It.IsAny<LogType>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Object[]>())) .Callback<LogType, string, string, Object[]>((a, b, c, d) => Debug.WriteLine("ILog::Log is called.")); ILog logger = mockILog.Object;
从这段代码看,Moq有时候恐怕也方便不去哪里,记住!它只是提供了一种可选的方法,它本身并不是解决单元测试问题的银弹。限于篇幅,我不可能在这里完整讲解Moq究竟怎么用,大家可以自行google,或者到*.com去找,可能更快能找到答案。
当我们用Moq来帮我们生成了这么一个“假对象”了之后,要把这个假对象告知StructureMap,让StructureMap在测试的工程中给ILog的请求者返回这个假对象。
ObjectFactory.Inject(typeof(ILog), mockILog.Object);
最后特别说明一下:用这种方法“注入”到ObjectFactory中去的对象都是单实例的,对于一个Mock对象来说,单实例没什么问题,我们只是为了单元测试。
在最后提供的这个DEMO4的代码中,我还额外增加了一个IOffice接口,但没有具体的实现,而HR模块依赖之,所以在对HR进行单元测试过程中,就是用Moq来做了一个假对象来“混过去”的。
DEMO4完整代码(VS2012)
快过年了,祝大家新年快乐!