.net程序单元测试介绍

什么是单元测试?为什么要进行单元测试?如需要进一步了解,请移步维基百科

关于.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
    (N0001, N蒋国纲, 1981-11-12, N技术宅男, NNormal, 5000.00);

insert into employees(emp_no, name, birthday, descriptions, status, salary) values
    (N0002, N周星驰, 1962-06-22, N喜剧之王, NProbation, 2000.00);

insert into employees(emp_no, name, birthday, descriptions, status, salary) values
    (N0003, N李逍遥, 1958-12-19, N蜀山派掌门人, NLeaved, 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,或者到StackOverflow.com去找,可能更快能找到答案。

当我们用Moq来帮我们生成了这么一个“假对象”了之后,要把这个假对象告知StructureMap,让StructureMap在测试的工程中给ILog的请求者返回这个假对象。

ObjectFactory.Inject(typeof(ILog), mockILog.Object);

最后特别说明一下:用这种方法“注入”到ObjectFactory中去的对象都是单实例的,对于一个Mock对象来说,单实例没什么问题,我们只是为了单元测试。

在最后提供的这个DEMO4的代码中,我还额外增加了一个IOffice接口,但没有具体的实现,而HR模块依赖之,所以在对HR进行单元测试过程中,就是用Moq来做了一个假对象来“混过去”的。

DEMO4完整代码(VS2012)

快过年了,祝大家新年快乐!

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。