如何 Mock 非虚方法和密封类?
更好的排版建议到我的博客中阅读:
http://www.dozer.cc/2012/11/how-to-mock-non-virtual-method-and-sealed-class/
问题
很常见的问题,没有接口,那如何 Mock 非虚方法和密封类?
我在上一篇文章(单元测试有感)中介绍了单元测试的原则,也提到了一些技巧,但是代码是以前写的,总会有很多不能克服的地方,还有也不可能把所有的方法改成 vitrual ,或者所有的类都有接口。
寻找
一开始搜索:mock non-virtual ,找到了一篇文章:传送门
文中提到了一个神器:Typemock,貌似它可以实现,但是它是收费的…
大致看了看它的原理,和 PostSharp (PostSharp是用来做 AOP 的)差不多,都是会去修改编译完成的 dll 文件,简单粗暴!
虽然粗暴,但貌似的确是一个不错的方法,别的项目正常编译,Test 项目中为了测试,把所有的方法加上 Virtual 关键字,这样不就行了吗?
思路是清晰了,可惜工具都是收费了,直到看到了老赵的博客。
- http://blog.zhaojie.me/2012/01/make-things-mockable-with-mono-cecil.html
- http://www.findex.cn/item.php?id=421685
上面的链接是老赵的原文,可惜他好像误操作,被另一篇文章替换了。
下面的链接是别人转载的,可以看,虽然代码缩进都不对。
另外,老赵的文章提供了一个很好的思路,但是没有后续具体的操作细节,我也摸索了很久。所以,下面我会给大家介绍一下完整的、具体的实现步骤。
实现思路和技术细节
实现思路其实收费的 Mock 工具已经提供了:
- 项目中按照之前的设计原则,编写自己的代码;
- 测试项目每次编译完成后,运行一个程序,修改需要 Mock 的 dll ;
- 利用 Moq 等 Mock 框架,在运行时动态生成代理类;
这里,只会修改复制到 Test 运行目录的 dll,所以不会影响别的项目。
技术细节的话,这里就需要用到老赵博客中提到的 Mono.Cecil 了,建议用 NuGet 获取最新版本。
Mono.Cecil 可以帮助你修改编译好的 dll 文件。
核心代码如下(这部分逻辑由老赵提供,我做了一定的修改):
private static void OverWrite(string file, bool hasSymbols) { var asmDef = AssemblyDefinition.ReadAssembly(file, new ReaderParameters { ReadSymbols = hasSymbols }); var classTypes = asmDef.Modules .SelectMany(m => m.Types) .Where(t => t.IsClass) .ToList(); foreach (var type in classTypes) { if (type.IsSealed) { type.IsSealed = false; } foreach (var method in type.Methods) { if (method.IsStatic) continue; if (method.IsConstructor) continue; if (method.IsAbstract) continue; if (!method.IsVirtual) { method.IsVirtual = true; method.IsNewSlot = true; method.IsReuseSlot = false; } else { method.IsFinal = false; } } } asmDef.Write(file, new WriterParameters { WriteSymbols = hasSymbols }); }
只要把这个代码封装成一个控制台应用程序,每次编译测试项目后运行一下即可。
具体实现步骤
源代码解决方案结构
MockHelper 是核心工具,作用就是修改编译好的 dll,一般情况下也只要使用这个即可,别的几个项目只是用来演示的。
TestDll 内包含了一个密封类和非虚函数,后面会用这个做演示,把它变成可以 Mock 的。
Test 项目就是一个 MSTest 项目,里面演示了怎么使用 MockHelper。
NUnit 项目同样是一个演示的测试项目,但是用的是 NUnit。
MockHelper 的使用
这个控制台应用程序其实没有什么难度,核心代码上面已经贴出来了。
另外使用的时候需要复制 MockHelper.exe、mock.txt 和 Mono.Cecil*.dll 到你的测试项目中,一共六个文件。
使用方法就是直接运行这个控制台应用程序,然后可以传入一个参数:代表 dll 所在的文件夹。如果不传参数的话默认是在运行目录。
然后把你需要修改的 dll 全部写到 mock.txt 中。
配置自动运行 MockHelper
把 MockHelper 复制过去后的关键就是要让这个 exe 可以自动运行啦!
这里用的是:后期生成事件命令行
右击项目 — 属性 — 生成事件 — 后期生成事件命令行:
"$(ProjectDir)MockHelper\MockHelper.exe"
这里不用传参数,因为运行这个工具的是 Test 项目,而这个项目默认的运行位置就是 bin/Debug|Release,所以需要修改的 dll 就在下面。
编写测试代码
TestDll 是非虚函数,而且是密封类:
public sealed class TestClass : TestClassBase { public string NormalMethod() { return "TestClass"; } public override string VirtualMethod() { return base.VirtualMethod(); } public sealed override string SealedMethod() { return base.VirtualMethod(); } public override string AbstractMethod() { return "TestClass"; } } public abstract class TestClassBase { public virtual string VirtualMethod() { return "TestClass"; } public virtual string SealedMethod() { return "TestClass"; } public abstract string AbstractMethod(); }
测试代码如下:
[TestClass] public class UnitTest { [TestMethod] public void TestMethod1() { var test = new Mock<TestClass>(); test.Setup(t => t.NormalMethod()).Returns("Mock"); test.Setup(t => t.VirtualMethod()).Returns("Mock"); test.Setup(t => t.SealedMethod()).Returns("Mock"); test.Setup(t => t.AbstractMethod()).Returns("Mock"); Assert.AreEqual(test.Object.NormalMethod(), "Mock"); Assert.AreEqual(test.Object.VirtualMethod(), "Mock"); Assert.AreEqual(test.Object.SealedMethod(), "Mock"); Assert.AreEqual(test.Object.AbstractMethod(), "Mock"); } }
MSTest 运行结果如下:
NUnit 运行结果如下:
去掉这个工具后会报如下错误:
注意事项
不要看上面的步骤简单,我在配置这个的时候走了很多弯路,这里也跟大家分享一下:
一定要用 Moq 等 Mock 框架
为什么一定要自动 Mock 框架?它的核心不就是继承一个类吗?
因为这个工具是在代码编译后才去修改 IL 代码的。也就是说,在编写的时候,它依然是密封类或者是非虚方法。
所以你如果自己去编写的话,是无法编译通过的。
那自动 Mock 框架为何可以呢?
因为这些框架是在运行的时候动态生成一个类去继承需要 Mock 的类的。在运行的时候,这个类已经被修改过了,所以是不会出错的。
注意配置一下 MSTest
我在一开始研究这个的时候,遇到了一个很纠结的问题。
在我的 Demo 中它是可以的,但是到了真正的项目中,它却一直出错。
后来研究后发现,在出错的项目中, Test 的运行目录不是在 bin/Debug 下,
而是在 TestResults/dozer_DOZER-PC 2012-11-27 11_11_22/Out
而且这个文件夹会在每次运行测试的时候创建一个新的。里面的 dll 并不是从 bin/Debug 中复制过去的,所以我工具修改后的 dll 没有起到作用。
可是为什么我的 Demo 中没有这样?后来发现后面一个项目启用了测试部署功能,虽然不知道这个功能具体的用处,但是取消后出错的项目也正常了!
取消方法:测试 — 编辑测试设置 — 本地(另一个也要同样配置) — 部署 — 取消启用部署。
注意!配置有两份,要同时取消后才可以生效。
所有测试框架都支持吗?
原则上,只要你有办法在运行测试之前跑一下这个工具就可以支持所有的测试框架。
从上面可以看到,MSTest 和 NUnit 的配置方法是完全一样的。
经过测试,我们公司的自动化部署、测试框架是可以支持这个的,别的环境可能需要一些修改和配置,难度并不是很大。
最后
项目地址:https://github.com/dozer47528/MockHelper
最后,感谢老赵提供的思路!我这里其实只是具体实现一下。
其实,这个是无奈之举,大家最好还是老老实实地多用接口吧!