1、你正在开发一个系统,你不断地编码-编译-调试-编码-编译-调试……终于,你负责的功能模块从上到下全部完成且编译通过!你长出一口气,怀着激动而又忐忑的心情点击界面上的按钮,顿时你刚刚的轻松感烟消云散:系统无法正常工作,你想读的数据显示不出来,你想存的东西也送不到数据库……于是,你再次回到IDE里,设断点、调试、一层一层跟踪,当你精疲力尽终于将数据送到数据库里,你又发现了其它问题,于是你继续设断点、调试、编译、调试……
2、你狂躁地敲击着键盘和鼠标,咒骂着不断出现的bug:啊?这里怎么没返回值啊!哎?这里不该是0啊!不对啊,这里怎么没数据……你永远不知道还有多少bug,你也永远不知道你的改动会不会引入其它bug——这里有几十个甚至上百个类,几百几千个方法!我不能都照顾到啊!你感觉bugs像敲击鼹鼠游戏中的鼹鼠:打下了这个,另一个又从其它洞口露出头来……
3、也许是毕业答辩的演示,也许是客户的审查,你小心地打开自己要演示的系统,进行着预定的操作,忽然,有个功能不能正常运行,你大汗淋漓,在答辩老师或者客户质疑且不满的目光下你试了又试,但还是于事无补……于是,答辩老师可能扭头便走,客户可能愤然离去,然后离去的还有你的学位证和项目奖金。当后来你检查代码时,发现这一切竟然只是因为一个底层工具类中一个方法输出结果为空。
如果你觉得上面的场景令你似曾相识甚至痛心疾首,那么你应该看完这篇文章。
单元测试(Unit Test)的一个测试用例(Test Case)是一小段代码,这段代码用于测试一个小的程序功能(一般是一个方法或相关的几个方法)行为是否正常。下面给出一个实际项目中单元测试用例的代码,大家可以不用深究这段代码中的细节,这里贴这段代码只是给大家一个直观的感觉。
1 /// <summary> 2 /// 测试基本的添加及删除角色是否正确 3 /// </summary> 4 [Test] 5 public void TestAddAndRemoveRole() 6 { 7 IRoleServices roleServ = UnityHelper.CreateContainer().Resolve<IRoleServices>(); 8 IRoleRepository roleRep = UnityHelper.CreateContainer().Resolve<IRoleRepository>(); 9 Assert.IsNotNull(roleServ); 10 Assert.IsNotNull(roleRep); 11 12 String timeStamp = DateTime.Now.ToString(); 13 RoleDto newRole = new RoleDto() 14 { 15 Name = "测试角色" + timeStamp, 16 Desciption = "此角色仅供测试使用", 17 }; 18 roleServ.AddRole(newRole); 19 20 RoleDto addedRole = roleRep.GetRoleByName("测试角色" + timeStamp); 21 Assert.AreNotEqual(-1, addedRole.ID);//确认新角色添加成功 22 23 roleServ.RemoveRole(addedRole.ID); 24 Assert.AreEqual(-1, roleRep.GetRoleByName("测试角色" + timeStamp).ID);//确认刚才添加的角色删除成功 25 }
上面的Unit Test Case来自我目前负责的一个项目,这段代码的作用是测试Services层对角色的添加和删除是否正常工作。这里大家可以先不必太细究代码,后面会有详解。
按照惯例,说完什么是单元测试,就该说为什么要使用单元测试了。但是,我在这里想先和大家讨论,为什么很多开发人员知道单元测试,也“认为”单元测试有必要,但绝大多数开发人员都不写单元测试,能认真对待单元测试的开发人员更是寥寥无几了。
我私下调查了一些开发人员,发现大家不写单元测试主要有两点原因:一是对单元测试存在很多误解,二是没有真正意识到单元测试的收益。下面我就这两点做一些讨论。
首先,我们来看看大家对单元测试普遍存在哪些误解。
误解1:单元测试属于测试工作,应该由测试人员来完成,所以单元测试不属于开发人员的职责范围。
正解1:单元测试虽然叫做“测试”,但实际属于开发范畴,应该由开发人员来做。
在大多数开发人员眼里,“开发”和“测试”是两个泾渭分明的范畴,他们认为:开发人员的工作就是写新代码,实现新功能,至于代码的测试,那是测试人员的职责,我只要让代码编译通过就行了。
我们都知道,软件是很复杂很抽象的东西,软件开发人员压力都很大,况且人非圣贤,强求开发人员开发出没有缺陷的程序是不现实的,所以才有了“测试工程师”这一职位。但是,开发人员至少应该保证一点:你写的每一个函数或方法(Function)应该能够正常完成功能,即行为正常。软件最终可能会有缺陷,这不是开发人员完全可以控制的,但你写了一个类,类里有4个方法,作为开发人员应该保证这四个方法实现了“眼下”的功能。例如,你写了一个获取IoC容器的工具类,你总要保证其中的GetContainer方法能正确返回一个Container吧。
所以,单元测试虽然叫“测试”,但实际其属于开发范畴,其目的是保证开发的功能子项能完成正确实现其基本功能。甚至我个人认为,当开发人员开发每一个功能子项(通常是方法)时,如果不能附带一配套的单元测试代码,都不能算开发完成。换言之,单元测试代码应该是开发人员必须提供的要素。
误解2:单元测试是一种测试,其功能是对代码进行检测。
正解2:单元测试是一种工具,其功能除了是对代码进行检测,更重要的是对软件的质量起到一种保证,并且是为他人和后续编码、重构工作提供的一种十分美妙的工具!
单元测试不是一种测试。没错,我不是在说疯话,单元测试其实是一种工具。特别是当自动化测试软件(如NUnit、JUnit)出现后,单元测试更像是一种工具了。
当你处在一个多人开发团队中,你需要和其他队友配合开发,而这在程序层面则表现为你开发的Class会被别人用,而你也会用别人开发的Class。我们每个人都希望别人交给我的Class是行为正确的,如果我拿到一个同事写的数据库操作类DBHelper,但发现其中的Connect方法根本无法连接上数据库(虽然没有编译错误),那我将非常郁闷。所以,在交给别人一个Class之前,你应该使用Unit Test保证这个Class是正常实现功能的,在交付的时候,你应该一手递上刚出炉的Class,然后另一只手递上配套的Unit Test,然后说:嘿!哥们,这是你要的类,而这个是配套的单元测试,你可以随时使用自动化测试工具运行它以便迅速知道这个类是否工作正常。
这将会是个很棒的工具,你的队友以后可能会想知道它的改动是否影响了你提供的类的功能,也可能会对你的类进行重构,但无论何时,它只要拿出你的配套单元测试,让自动化测试工具跑一下,不出几秒,就知道你提供的类是否还正常完成功能。即使是对于你自己,以后也会有很多机会用到它。而当你写的代码出现bug,你可以拿出你这段代码调用的所有类的单元测试跑一遍,很快就能知道到底是你依赖的类出了问题还是你自己代码有问题,而不必抓狂似地到处设断点。
误解3:项目经理或技术主管没有要求写单元测试,所以不用写。
正解3:写单元测试应该成为开发人员的一种本能,开发本身就应该包含些单元测试。
就像项目经理不用告诉你要使用计算机写程序一样,写单元测试应该成为开发人员的必须动作。因为你是开发人员,因为你在做开发,所以你必须写单元测试,就这么简单。
误解4:写单元测试获益者是测试人员,而开发人员无法从中获益,还要搭上宝贵的时间。
正解4:写单元测试谁都获得不了像开发人员获得的那么大的益处。
有了单元测试,你可以随时从同事手中接过值得信赖的代码;有了单元测试,你可以随时保证你写的代码行为正确;有了单元测试,你可以随时通过自动化操作得知某个Class行为是否正确;有了单元测试,你以后的Debug和重构工作将变得轻松异常;有了单元测试,……没有人比开发人员从中获得的利益更大了。
关于这点,我认为有两个重要原因。
第一、绝大多数开发人员没有尝试过贯彻单元测试。
这个很好理解,如果你不亲口尝尝一道菜,即使是海参鲍鱼,你也不知道它有多美味。我曾经也是其中的一员,但当我第一次将单元测试贯彻于项目中并尝到甜头后,我就爱上“她”了,所以,迈出一地步,很关键。
第二、人有一种天性:相比长远的更大利益,人们更倾向于眼前的小的多的利益,正所谓“贪小便宜吃大亏”是也。
想起了美国人类行为专家的一个实验:他到了美国一个小学,里面一个一年级班级有48个孩子,他给每个孩子5颗做了特殊标记的糖,并告诉他们如果到一周后谁能一颗都不吃,我就给他100颗糖。一周后,48个孩子中只有4个孩子做到了。他跟踪了这48个孩子30年的成长,最后发现那4个孩子都成为了十分成功的人物,他们4个人30年后拥有的财富是剩下44个孩子财富总和的3倍。
同样道理,即使很多开发人员也知道好的单元测试能让以后省不少心,但他们也宁可省掉写单元测试时间去堆砌代码。因为我们总觉得今天省掉1个小时多写一个类更有的赚,虽然我们以后要为省掉的1小时多付出3个小时去抓狂。
上面写了很多,所以我认为这里有必要小结一下,整理一下思绪。
单元测试的概念——一小段代码,用于检查一个或几个相关的方法行为是否正确。
单元测试的本质——随功能代码一起提供的一个配套工具。
单元测试的用途——保证交付Class行为正确,随时可用于自动化检测其对应的Class行为是否正确,对整个软件的质量是一种保证,对缺陷是一种控制。
我忽然发现,写了上面的文字后,再来讨论这个问题有些多余了,那么我尽量写简短一点。
1、开发人员有义务提供行为正确的Class,也有权利得到行为正确的Class。
很明显,如果你和你的同事,都能重视单元测试的话,你将同时履行这份义务和享受这份权利。
2、尽早消灭缺陷。
缺陷越早消灭所付出的代价越小,而越往后其代价呈指数增长,这是有充分的实验数据证明的,并已经被写到每一本软件工程教科书中。毫无疑问,当你交付一个Class前,就将其行为上的缺陷全部扼杀,那将取得巨大的收益。
3、使合作变得愉快顺畅。
想想看,每个你调用的Class,都是经过你的同事测试,确保行为是正确的,这是多么美妙的事情!我们写程序经常没有安全感,我们战战兢兢,很大程度上是因为我们没信心认为调用的每个Class行为是正确的。
4、得到一个有力的工具,会在后续工作中大显身手的工具。
如果每个Class都有配套的单元测试,好的,如果你想确认你的改动有没有影响到其它几个Class,run it!如果你想看看你调用的类是否行为正确,run it!如果你在重构,想看看重构有没有改变或损害其行为,run it!你正在调试一个bug但很难定位问题出在哪个地方,run it!你想看看目前项目中所有集成进来的代码是否行为都正确 run it! ……
如果你愿意,你可以手工设计和运行单元测试,但这是低效和让人恐惧的。目前,各个平台上都有较为流行的自动化单元测试工具,像Visual Studio 2008本身就集成了单元测试功能。但是,我更愿和大家分享的是一个叫NUnit的工具。其官方网站为:http://www.nunit.org/。这是一个开源且免费的.NET平台下自动化单元测试工具,可以在其官网下载。NUnit是XUnit家族的一员,其体积小巧,使用简单,但功能强大,一直是我做单元测试的首选。
另外,这个例子我选取目前我正负责的一个实际项目,这是一个国家863项目。这个项目使用了敏捷开发方法,贯彻了以保证为目的的测试驱动、持续集成等实践。我将截取其中几个片段和大家分享一下单元测试的一些实践。
值得一提的是,我对这个项目的单元测试要求是相对严格的,我们使用的配置管理工具是SVN,作为项目负责人,我对所有开发人员有一个要求:每一个新开发的Class,必须有配套的单元测试,并且在每次Commit到SVN前,不仅仅要保证Commit的代码编译没问题,还要跑通所有单元测试,否则不准Commit到SVN。这就保证了每个人Update到的Class都是行为正确的。再配合面向接口编程方法和Mock技术,大大提高了代码的可测试性,使得开发过程一直比较让人满意。刚开始大家觉得我的要求有些过分,但是当每次结合时几乎都没有出现问题,每次刚刚集成的新功能都能顺利在UI上跑通,大家也就慢慢接受了,并且渐渐都对待单元测试非常认真。
这个项目的整体结构大家可以先看一下,其中XUnit项目就是单元测试项目。
图1、解决方案结构图
其中的单元测试Case进行了一定的组织,相应工程的Case放在了不同目录下。当然,这个大家可以根据具体情况自行确定组织方式。
这个项目选用的依赖注入工具为Unity(http://www.codeplex.com/unity)。因为获取UnityContainer是一个常用操作,所以我将其封装成一个辅助类,代码如下:
1 using System.Configuration; 2 using Microsoft.Practices.Unity; 3 using Microsoft.Practices.Unity.Configuration; 4 namespace SPMS.Common.Utils 5 { 6 /// <summary> 7 /// 工具类 8 /// 封装了Unity中Container的创建工作,并保证Unity Container的单例性 9 /// </summary> 10 public class UnityHelper 11 { 12 private static IUnityContainer _container = null; 13 14 /// <summary> 15 /// 获取Unity Container 16 /// </summary> 17 /// <returns>全局唯一的Unity Container实例</returns> 18 public static IUnityContainer CreateContainer() 19 { 20 if (null == _container) 21 { 22 _container = new UnityContainer(); 23 24 //从配置文件中读取IoC配置信息 25 ExeConfigurationFileMap map = new ExeConfigurationFileMap(); 26 map.ExeConfigFilename = "unity.cfg.xml"; 27 Configuration config = ConfigurationManager.OpenMappedExeConfiguration(map, ConfigurationUserLevel.None); 28 29 //通过配置信息初始化Container 30 UnityConfigurationSection section = config.GetSection("unity") as UnityConfigurationSection; 31 section.Containers["defaultContainer"].Configure(_container); 32 } 33 34 return _container; 35 } 36 } 37 }
这段代码只有一个方法,就是CreateContainer,其作用是获取全局唯一的UnityContainer实例。在写完这个代码后,我开始写单元测试,我能想到有四个点要测:
1)能正确返回UnityContainer
2)返回的UnityContainer能正确创建对象
3)保证创建的UnityContainer是单例的,即全局唯一实例
4)返回的UnityContainer在创建配置为单例的对象时,返回的对象应该是单例的
有了这四点想法,我写了如下的单元测试:
1 using NUnit.Framework; 2 using SPMS.Common.Utils; 3 using SPMS.Repository.IRepository; 4 using SPMS.Services.IServices; 5 6 namespace SPMS.XUnit.CommonTests 7 { 8 /// <summary> 9 /// UnityHelper的单元测试类 10 /// </summary> 11 [TestFixture] 12 public class UnityHelperTests 13 { 14 /// <summary> 15 /// 测试获取Container是否正常 16 /// </summary> 17 [Test] 18 public void TestCreateUnityContainer() 19 { 20 Assert.IsNotNull(UnityHelper.CreateContainer()); 21 Assert.IsInstanceOf(typeof(Microsoft.Practices.Unity.IUnityContainer), UnityHelper.CreateContainer()); 22 } 23 24 /// <summary> 25 /// 测试Container创建对象是否正常 26 /// </summary> 27 [Test] 28 public void TestCreateObject() 29 { 30 IRoleRepository roleRepository = UnityHelper.CreateContainer().Resolve<IRoleRepository>(); 31 Assert.IsNotNull(roleRepository); 32 Assert.IsInstanceOf(typeof(SPMS.Repository.NHibernateRepository.NHRoleRepository), roleRepository); 33 34 IRoleServices roleServ = UnityHelper.CreateContainer().Resolve<IRoleServices>(); 35 Assert.IsNotNull(roleServ); 36 Assert.IsInstanceOf(typeof(SPMS.Services.ServicesImpls.RoleServicesImpl), roleServ); 37 } 38 39 /// <summary> 40 /// 测试Container是否是单例对象 41 /// </summary> 42 [Test] 43 public void TestSingletonContainer() 44 { 45 Assert.AreSame(UnityHelper.CreateContainer(), UnityHelper.CreateContainer()); 46 } 47 48 /// <summary> 49 /// 测试指定为Singleton的实例,是否为单例对象 50 /// </summary> 51 [Test] 52 public void TestSingletonObject() 53 { 54 Assert.AreSame(UnityHelper.CreateContainer().Resolve<IRoleRepository>(), UnityHelper.CreateContainer().Resolve<IRoleRepository>()); 55 } 56 } 57 }
即使你没用过NUnit,我想这段代码也是非常好理解的。限于篇幅,不能详细介绍NUnit,这里只简要说一下。使用NUnit首先要添加对nunit.framework.dll的引用,然后引入NUnit.Framework命名空间,最后,每个测试类添加[TestFixture]Attribute,而每个测试方法添加[Test]Attribute,这样就可以在里面写测试代码了。
其中用的最多的是NUnit.Framework.Assert类,它有很多静态方法用于断言,这些断言就是你期望的行为。例如,Assert.AreSame方法断言两个变量是否引用同一个对象,我在上面代码里使用这个方法断言UnityContainer对象的单例性。
完成这个单元测试代码后,要把测试需要的配置文件等添加到XUnit工程里,我这里包括一个unity.cfg.xml,作为Unity的配置文件。下面,编译这个工程。如果编译没有错误,下面就可以跑这个测试了。怎么跑呢,当你安装NUnit时,会同时安装一个NUnit GUI,在开始菜单中找到打开,界面大约是这样子:
图2、NUnit GUI
选择菜单栏的 file -> open project ,打开刚才编译好的SPMS.XUnit.dll,也就是测试工程的dll文件,GUI会自动加载所有测试用例,如下图所示。
图3、加载工程后的NUnit GUI
OK,我们要测试的是UnityHelperTests下的所有测试用例,所以我们在左边选中它,然后单击“run”按钮!这样GUI就会自动帮我们跑里面的测试用例了,最终结果如下。
图4、成功的测试结果
可以看到,所有UnityHelperTests的测试均为绿色,进度条也为绿色,且指示Passed: 4,这说明我们所有断言成功,测试通过。
下面我们做点手脚,我在UnityHelper代码中取消了获取Container的单例性,现在再来运行测试看结果:
图5、失败的测试结果
可以看到,创建Container及创建Object是正常的,但Container的单例性被破坏。Container都不是单例的了,两个Object更不会是单例了。这时根据结果和右侧给 出的错误信息,我们可以很快定位缺陷,并将之排除。
这个单元测试一但写好,开发团队中任何人员可以随时方便地跑它,这样我们就能在任何时刻知道UnityHelper是否工作正常,以帮助我们定位bug和排除缺陷。如果我们要做UnityHelper的重构,我们也可以使用它保证UnityHelper的行为正确。
本文首先讨论了什么是单元测试,然后讨论了开发人员对单元测试的误解以及不愿做单元测试的原因。接着,我们讨论单元测试有哪些作用,最后用一个实际项目中的片段来说明单元测试的实践。限于篇幅,不能将单元测试及NUnit工具的方方面面讨论详尽,但是NUnit真是一个非常好上手的工具,你可以参考其文档和示例,或者参看Andrew Hunt所著的《Pragmatic Unit Testing in C# with NUnit》一书。
不论你是做何种开发,我相信,单元测试一定会让你受益匪浅。请相信,单元测试不是一件索然无味的工作,它同样充满了成就感和乐趣,每次看到鲜亮的绿色进度条,都是最爽的时刻。所以,希望看完本文的朋友能尽快拿起NUnit,开始你的单元测试实践。就从你的下一个项目、或下一个Class、甚至下一个Function,开始你的单元测试之旅吧。