使用Mono.Cecil解决无法Mock非虚方法和密闭类的问题

使用Mono.Cecil解决无法Mock非虚方法和密闭类的问题

2012-01-12 14:50 by 老赵, 173 visits

最近在研究单元测试,自然会用到Mock对象。我在微薄上抱怨说,为了做好单元测试,几乎每个依赖的功能都要抽象成一个组件,直到被测试的逻辑本身简单到能够测试为止,这些抽象导致项目里的接口越来越多。有些同学回复说,这是因为在C#里只能Mock接口,如果是Java的话便可以Mock普通类。不过事实上,对于普通Mock类库来说,Java和C#的实现方式几乎没有任何不同,都是通过动态生成子类,再重载个别成员来提供特殊的行为。C#中“不能Mock普通类”只不过是由于它的方法在默认情况下是非虚方法而已。如果我们把成员显式标记为virtual,自然便可以Mock了。同理,我们也无法Mock密闭类,因为它连继承都没法做到。

我个人是倾向于默认“非虚”,因为我认为扩展是设计出来的,而不是自动让每个公开成员可扩展,否则什么里氏替换原则(Liskov Substitution Principle)就太容易被打破了。当然,这也是个讨论许多遍但没有结论的问题,而且我其实也不是很在意“默认”行为如何,例如Java也可以使用final来让一个方法变得不可重载,因此我虽然十分讨厌Java,也从没拿这点出来说事。

但既然我们需要Mock某些成员,那么它必须是虚成员,但我们也的确不希望它能被重载。例如,某方法是一个模版方法模式(Template Method Pattern)的主要逻辑实现,不希望它能被替换掉,但却也要验证该方法在另一个成员里有没有被正确调用,那怎么办?我十分不愿意为了“单元测试”这个与具体功能无关的角度而破坏设计本意,因此便出现了现在这个做法。

既然我们的成员只是希望能够在测试中Mock,那么其实只要在运行测试前:

  • 把密闭类变成普通类
  • 把私密方法变成普通方法
  • 把非虚方法变成虚方法

不就行了么!使用Mono.Cecil实现其核心代码只需寥寥数行:

var asmDef = AssemblyDefinition.ReadAssembly(asmFile, new ReaderParamet
{
    ReadSymbols = hasPdbFile
});

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.IsFinal)
        {
            method.IsFinal = false;
        }

        if (!method.IsVirtual)
        {
            method.IsVirtual = true;
        }
    }
}

asmDef.Write(asmFile, new WriterParameters
{
    WriteSymbols = hasPdbFile
});

之前我们用Mono.Cecil解决了dynamic在视图中无法访问匿名类型成员的问题,现在也是相同的思路,修改后连调试都不影响。剩下的只需要在单元测试项目的Post-build事件中执行这个程序,修改特性的程序集即可。这个小程序Unseal我已经把它放在Github上面,其中还包含简单的示例。例如在Target项目中我们定义了三个类型:

public abstract class ParentClass
{
    public string NonVirtualMethod()
    {
        return "Non-virual method";
    }

    public abstract string AbstractMethod();
}

public class ChildClass : ParentClass
{
    public sealed override string AbstractMethod()
    {
        return "Sealed method";
    }

    public virtual string VirtualMethod()
    {
        return "Virtual method";
    }
}

public sealed class SealedClass : ChildClass
{
    public override string VirtualMethod()
    {
        return base.VirtualMethod() + ", overridden";
    }
}

我们使用Moq类库,便会发现在Mock某些成员会失败(注意Mock一个sealed方法时,Moq并不会在Setup阶段便抛出异常,但却不会任何效果):

var parentMock = new Mock<ParentClass>();
parentMock.Setup(p => p.AbstractMethod()).Returns("Mock an abstract method.");

try
{
    parentMock.Setup(p => p.NonVirtualMethod()).Returns("Failed");
}
catch (NotSupportedException)
{
    Console.WriteLine("Cannot mock a non-virtual method.");
}

var childMock = new Mock<ChildClass>();
childMock.Setup(c => c.VirtualMethod()).Returns("Mock a virtual method");

try
{
    childMock.Setup(c => c.AbstractMethod()).Returns("Failed");
    if (childMock.Object.AbstractMethod() == "Sealed method")
    {
        throw new NotSupportedException();
    }
}
catch (NotSupportedException)
{
    Console.WriteLine("Cannot mock a sealed method");
}

try
{
    var sealedMock = new Mock<SealedClass>();
}
catch (NotSupportedException)
{
    Console.WriteLine("Cannot mock a sealed class.");
}

则会输出:

Cannot mock a non-virtual method.
Cannot mock a sealed method.
Cannot mock a sealed class.

但在另一个项目Usage里,我们在Post-build项目中写上命令:

$(SolutionDir)src\Unseal\bin\Debug\Unseal.exe "$(TargetDir)Target.exe"

然后运行代码:

var parentMock = new Mock<ParentClass>();
parentMock.Setup(p => p.NonVirtualMethod()).Returns("Non-virtual method mocked.")
Console.WriteLine(parentMock.Object.NonVirtualMethod());

var childMock = new Mock<ChildClass>();
childMock.Setup(c => c.AbstractMethod()).Returns("Sealed method mocked.");
Console.WriteLine(childMock.Object.AbstractMethod());

var sealedMock = new Mock<SealedClass>();
sealedMock.Setup(s => s.NonVirtualMethod()).Returns("Sealed class mocked.");
Console.WriteLine(sealedMock.Object.NonVirtualMethod());

便可得到:

Non-virtual method mocked.
Sealed method mocked.
Sealed class mocked.

至此便解决了单元测试中的许多烦恼。当然,用这个简单的方法还是无法Mock静态成员和构造函数(当然用Mono.Cecil也能改写,但要复杂的多)。如果您真要这么做,则只能使用TypeMockMoles或是PowerMock等高级类库了。当然,我还是不太喜欢这些类库,因为过于自由,会倾向于引入不良的设计。为了方便单元测试,我会情愿多写一些代码,只暴露出一套窄接口,在单元测试后的时候也不用从大量的成员中挑选出Mock的目标成员。

最后还是这句话:Mono是宝库。Mono在刚过去的一年里有了令人瞩目的发展,作为一个.NET程序员,我找不到忽视Mono的理由。

此条目发表在未分类分类目录,贴了, , 标签。将固定链接加入收藏夹。