重温《单元测试的艺术》,总结常用知识点
1. 前言
关于单元测试的定义和好处可以借用stephen cleary的一段话来概括:
单元测试是现代开发的基础。对项目进行单元测试的好处非常容易理解:单元测试降低了 bug 数量,缩短了上市时间,防止过度耦合的设计。这些都是很好的优势,但它还有更多与开发人员更直接相关的优点。在我编写单元测试时,我会对代码更有信心。在已测试的代码中更易于添加功能或修复 bug,因为在代码发生更改时,单元测试起着安全网的作用。
前几个月重温了。毕竟是14年的书内容有点旧,于是试着结合书中的内容和一些新的知识点写进这篇文章,希望对自己及各位读者有帮助。
tdd是另一个话题,这里就不涉及了。
2. 选择单元测试框架
《单元测试的艺术》书中推荐nunit,vs2019中新建单元测试项目只有mstest v2、nunit和xunit三种。微软自己的项目也不一定会使用mstest,例如corefx就在用xunit。
不过我更喜欢mstest v2,因为从旧的mstest升级过来几乎没有学习成本,也不用向上司解释为什么要换框架。mstest已经是个开源项目,windowscommunitytoolkit就在用mstest。
3. 怎么给单元测试命名
万事起头难,最难的就是命名。onresolveshouldaddsearchdirectorylistonaneedtobasis
这种命名简直吓死人,明明觉得每个单词都认得但感觉就是看不明白。
《单元测试的艺术》书中推荐了一组测试命名的规则。
项目 创建一个名为[projectundertest].unittests的测试项目。
类 对应被测试项目中的一个泪,创建一个名为[classname]tests的类。
工作单元 对每个工作单元(一个方法,或者几个方法组成的一个逻辑组,或者及各类),创建一个如下命名的测试方法:
[unitofworkname]_[scenarioundertest]_[expectedbehavior]
。
测试方法名称的三个部分:
- unitofworkname,被测试的方法、一组方法或者一组类。
- scenario,测试进行的假设条件。
- expectedbehavior,在测试场景指定的条件下,你对被测试方法行为的语气。
从一个简单的类开始解释这个命名规则:
public class loganalyzer { public bool isvalidlogfilename(string filename) { if (filename.endswith(".slf") == false) return false; return true; } }
为这个函数创建对应的单元测试,假设我们传入错误的文件名,预期返回false,则测试方法命名为isvalidlogfilename_badextension_returnfalse
。不需要在函数名中加入“test”,这种命名本身已暗示自己是个测试方法。
namespace logan.unittests { [testclass()] public class loganalyzertests { [testmethod()] public void isvalidlogfilename_badextension_returnsfalse() { assert.fail(); } } }
4. 单元测试的组成
单元测试通常包含三个行为:
- 准备(arrange)队形,创建对象,进行必要的设置;
- 操作(act)对象;
- 断言(assert)某件事情是预期的。
以isvalidlogfilename_badextension_returnfalse
为例:
[testmethod()] public void isvalidlogfilename_badextension_returnsfalse() { var analyzer = new loganalyzer();//arrange var result = analyzer.isvalidlogfilename("filewithbadextension.foo");//act assert.isfalse(result);//assert }
5. 一些mstest常用的功能
5.1 参数化测试
要覆盖多个测试用例可以使用datarow实现参数化测试(mstest v1没有这个attribute),并且可以为每一个测试用例命名,运行测试后可以看到测试用例的名称:
[testmethod()] [datarow("filewithbadextension.foo")] [datarow("somefile.exe")] public void isvalidlogfilename_badextension_returnsfalse(string filename) { var analyzer = new loganalyzer(); var result = analyzer.isvalidlogfilename(filename); assert.isfalse(result); } [testmethod()] [datarow("filewithbadextension.slf", displayname = "a valid filename")] [datarow("somefile.slf",displayname = "an other valid filename")] public void isvalidlogfilename_goodextension_returnstrue(string filename) { var analyzer = new loganalyzer(); var result = analyzer.isvalidlogfilename(filename); assert.istrue(result); }
5.2 捕获预期的异常
在以前很流行使用expectedexceptionattribute检查异常,代码如下:
[testmethod()] [expectedexception(typeof(argumentnullexception))] public void isvalidlogfilename_emptyfilename_throws() { var analyzer = new loganalyzer(); analyzer.isvalidlogfilename(null); }
这个方法有一些问题:
- 没有assert语句。
- 如果测试代码很多,用户将搞不清楚到底哪行抛出了异常。
更好的做法是使用assert.throwsexception
[testmethod()] public void isvalidlogfilename_emptyfilename_throws() { var analyzer = new loganalyzer(); assert.throwsexception<argumentnullexception>(()=>analyzer.isvalidlogfilename(null)); }
5.3 initialize和cleanup
进行单元测试时,很重要的一点是保证之前测试的遗留数据或者实例得到销毁,新测试的状态是重建的,就好像之前没有测试运行过一样。
mstest提供了一组attribute用于初始化及释放资源。
attribute | 功能 |
---|---|
assemblyinitialize() | 执行程序集中的所有测试之前运行 |
classinitialize() | 测试类中的任意测试执行之前运行 |
testinitialize() | 测试之前要运行 |
testcleanup() | 测试之后运行 |
classcleanup() | 测试类中所有的测试都执行以后运行 |
assemblycleanup() | 执行程序集中的所有测试之后运行 |
[assemblyinitialize()] public static void assemblyinit(testcontext context) { debug.writeline("assemblyinit " + context.testname); } [classinitialize()] public static void classinit(testcontext context) { debug.writeline("classinit " + context.testname); } [testinitialize()] public void initialize() { debug.writeline("testmethodinit"); } [testcleanup()] public void cleanup() { debug.writeline("testmethodcleanup"); } [classcleanup()] public static void classcleanup() { debug.writeline("classcleanup"); } [assemblycleanup()] public static void assemblycleanup() { debug.writeline("assemblycleanup"); }
输出结果如下:
assemblyinit isvalidlogfilename_emptyfilename_throws classinit isvalidlogfilename_emptyfilename_throws testmethodinit testmethodcleanup testmethodinit testmethodcleanup testmethodinit testmethodcleanup testmethodinit testmethodcleanup testmethodinit testmethodcleanup classcleanup assemblycleanup
6. stub(存根)和mock(模拟对象)
外部依赖项常常是不写单元测试的借口,如文件系统、网络服务甚至系统时间,开发者往往说没法控制而逃避写单元测试。这种情况可以使用stub或mock破除依赖。
6.1 stub(存根)
一个存根(stub)是对系统中存在的一个依赖项(又或者协作者)的可控制的替代物。通过使用存根,你在测试代码无需直接处理这个依赖项。
如果前面的loganalyzer改成如下形式:
public bool isvalidlogfilename(string filename) { //读取配置文件,由配置文件判断是否支持这个扩展名 }
一旦测试依赖了文件系统,你进行的就是集成测试,带来了所有集成测试相关的问题————运行速度慢,需要配置等等。这种情况下可以使用一个stub代替文件系统的依赖。
public class loganalyzer { private iextensionmanager _manager; public loganalyzer(iextensionmanager manager) //定义测试代码可以调用的构造函数 { _manager = manager; } public bool isvalidlogfilename(string filename) { return _manager.isvalid(filename); } } public interface iextensionmanager { bool isvalid(string filename); } internal class fakeextensionmanager : iextensionmanager //定义一个最简单的stub { public bool willbevalid { get; set; } = false; public bool isvalid(string filename) { return willbevalid; } } [testmethod()] public void isvalidlogfilename_namesupportedextension_returnstrue() { var myfakemanager = new fakeextensionmanager { willbevalid = true }; //准备一个返回true的stub var analyzer = new loganalyzer(myfakemanager); //传入stub var result = analyzer.isvalidlogfilename("short.ext"); assert.istrue(result); }
注入stub的方式由很多,《单元测试的艺术》中有详细的介绍,这里略过。
6.2 mock(模拟对象)
模拟对象(mock)是系统中的伪对象,它可以验证被测试对象是否按照预期的方式调用了这个伪对象,因此导致单元测试通过或者失败。通常每个测试最多有一个模拟对象。
这次loganalyer需要和一个外部的web服务交互,每次loganalyer遇到一个过短的文件名,这个web服务就会收到一个错误消息。遗憾的是,你要测试的这个wen服务还没有完全实现,就算实现了,使用这个web服务会导致测试时间过长。因此这里需要一个mock,这个mock只包括需要调用的web服务方法,然后loganalyzer调用这个接口写错误日志。
public interface iwebservice { void logerror(string message); } public class loganalyzer { private iwebservice _service; public loganalyzer(iwebservice service) //定义测试代码可以调用的构造函数 { _service= service; } public void analyze(string filename) { if (filename.length < 9) _service.logerror("filename too short:" + filename); //在产品代码中写错误日志 } }
使用mock对象测试loganalyzer,注意是对mock对象进行断言,而非loganalyer类,因为测试的是loganalyer和web服务之间的交互:
public class fakewebservice : iwebservice //定义一个最简单的mock { public string lasterror { get; private set; } public void logerror(string message) { lasterror = message; } } [testmethod()] public void analyze_tooshrtfilename_callswebservice() { var mockservice = new fakewebservice(); var analyzer = new loganalyzer(mockservice); var tooshortfilename = "abc.ext"; analyzer.analyze(tooshortfilename); stringassert.contains(mockservice.lasterror, tooshortfilename); //对模拟对象进行断言 }
6.3 stub(存根)和mock(模拟对象)和fake(伪对象)
fake(伪对象)是通用的术语,可以描述一个stub或mock,,因为stub和mock看想去都很像真实对象。一个伪对象究竟是stub还是mock取决于它在当前测试中的使用方式:如果这个伪对象用来检验一个交互(对其进行断言),它就是mock,否则就是stub。
如果一个测试只测试一件事情,测试中应该最多只有一个mock,所有其它的伪对象都是stub。如果一个测试有多个mock,这说明你在测试多件事情,会导致测试过于复杂或脆弱。
前面定义的stub和mock都使用了fake-前缀,因为在类中避免使用"mock"和"stub",那么这个类的对象就可以具有两种行为方式,以后再不同的测试中重用。
7. 隔离框架
手工编写伪对象有很多问题,最明显的问题就是产生大量的编码和维护工作。使用隔离框架是一个更优雅的方案,它可以在运行时动创建和配置伪对象。
.net的隔离框架有很多,《单元测试的艺术》书中以nsubstitute(简称nsub)为例介绍了隔离框架的基本用法(看起来最近nuget下载量比更多)。
这一节介绍一些nsub的基本操作,更多的内容详见。
7.1 创建伪对象
假设我们有一个接口:
public interface icalculator { int add(int a, int b); string mode { get; set; } event action poweringup; }
nsub用下面的代码创建一个伪对象:
_calculator = substitute.for<icalculator>();
nsub能自动生成伪对象,这个伪造的icalculator对象实例时动态生成的,实现了icalculator接口,但没有实现它的任何方法。从模拟对象创建到测试方式结束,对这个模拟对象的所有调用都会自动记录,保存供后来使用。
7.2 模拟值
使用returns
模拟函数值并断言:
_calculator.add(1, 2).returns(3); assert.areequal(_calculator.add(1, 2), 3);
模拟属性值并断言:
_calculator.mode.returns("dec"); assert.areequal(_calculator.mode, "dec"); _calculator.mode = "hex"; assert.areequal(_calculator.mode, "hex");
也可以模拟一组值并逐个断言:
_calculator.mode.returns("hex", "dec", "bin"); assert.areequal(_calculator.mode, "hex"); assert.areequal(_calculator.mode, "dec"); assert.areequal(_calculator.mode, "bin");
7.3 测试交互
使用received
断言接收到调用,以及didnotreceive
断言没接收到调用:
_calculator.add(1, 2); _calculator.received().add(1, 2); _calculator.didnotreceive().add(5, 7);
7.4 参数匹配器
arg
类成为参数匹配器,用于控制参数处理:
_calculator.add(10, -5); _calculator.received().add(10, arg.any<int>()); //断言第二个参数时int类型 _calculator.received().add(10, arg.is<int>(x => x < 0)); //断言第二个参数小于5
使用参数匹配器并传入一个function到returns
可以更好地控制返回值:
_calculator.add(arg.any<int>(), arg.any<int>()) .returns(x => (int)x[0] + (int)x[1]); assert.areequal(_calculator.add(5, 10), 15);
7.5 使用when模拟异常
_calculator.when(x => x.add(arg.is<int>(i => i < 0), arg.any<int>())) .do(context => throw new argumentexception("invalid")); assert.throwsexception<argumentexception>(() => _calculator.add(-5, 0));
这里when
后面的lambda指示当第一个参数小于0,然后用do
抛出一个异常。
7.6 测试事件
var eventwasraised = false; _calculator.poweringup += () => eventwasraised = true; _calculator.poweringup += raise.event<action>(); assert.istrue(eventwasraised);
nsub使用raise
触发事件。
8. 其它
8.1 区分单元测试和集成测试
任何测试,如果它运行速度不快,结果不稳定,或者要用到被测试单元的一个或多个真实依赖物,我就认为它是集成测试。
集成测试是对一个工作单元进行的测试,这个测试对被测试的工作单元没有完全的控制,并使用该单元的一个或多个真实依赖物,例如事件、网络、数据库、线程或随机数产生器等。
集成测试和单元测试的项目应该分开。一般来说,复杂的测试都是集成测试,由于集成测试很慢,可以考虑使用创建一个只包含单元测试的解决方案,这样才可以频繁频繁地执行测试,实行tdd。
8.2 如何测试私有方法
私有方法通常比较难测试,不过你可以这么想:私有方法不会无缘无故地存在,最终在某个地方有公共方法会调用这个私有方法。看到一个私有方法的时候,你应该找到使用这个方法的公共用例并对这个公共用例进行测试。
如果一个私有方法真的值得进行测试,那么它也许应该设为公共的,静态的。有几种方式处理私有方法:
使方法成为公共方法。如果它真的那么重要,那把它设为公共的并不一定是坏事。使它变成正式的公共契约可以防止它被任意破坏。
把方法提取到新类。
使方法成为静态方法。
使方法成为内部方法并使用internalsvisibleto。
8.3 用代码审查确保代码覆盖率
代码覆盖率100%说明什么呢?如果没有做代码审查,这个覆盖率不能说明什么。也许这些测试连断言都没有,只是为了达到更高的覆盖率所写的代码。如果你做了代码审查和测试审查,确保测试优秀而且覆盖了所有代码,那么你就拥有了一个安全网,可以避免愚蠢的错误,同时团队也获得了分享的知识,从持续的学习中获益。
9. 结语
虽然《单元测试的艺术》是一本有点旧的书,但我是不是还是会拿出来重温并推荐给别人,毕竟.net专门讲单元测试的书不多。如果有其它单元测试方面的优秀书籍请推荐给我。
另外,微软的 也是个很不错的文档。
10. 参考
单元测试 - visual studio microsoft docs
microsoft.visualstudio.testtools.unittesting namespace microsoft docs
nsubstitute a friendly substitute for .net mocking libraries
上一篇: 自己动手做鱼头怎么做色香味俱全