使用填充码隔离应用以进行单元测试

填充码类型是 Microsoft Fakes 框架使用的两种关键技术之一,有助于在测试期间隔离应用的组件。 它们的工作原理是截获调用并将其转换为特定方法,之后可以定向到测试中的自定义代码。 此功能使你能够管理这些方法的结果,确保在每次调用时结果一致且可预测,而不考虑外部条件。 这种控制级别简化了测试过程,有助于获得更可靠且准确的结果。

如果需要在代码和不构成解决方案一部分的程序集之间创建边界,请使用填充码。 当目的是相互隔离解决方案的组件时,建议使用存根。

(有关更详细的存根说明,请参阅使用存根隔离应用的各个部分以供单元测试使用。)

填充码限制

请务必注意,填充码确实有其限制。

填充码不是在 .NET Framework 中 .NET 基类的某些库(具体而言,mscorlib 和 System)中的所有类型以及 .NET Core 或 .NET 5+ 中 System.Runtime 中的所有类型上都能使用。 在测试规划和设计阶段应考虑此约束,以确保成功有效的测试策略。

创建填充码:分步指南

假定你的组件包含对 System.IO.File.ReadAllLines 的调用:

// Code under test:
this.Records = System.IO.File.ReadAllLines(path);

创建类库

  1. 打开 Visual Studio 并创建 Class Library 项目

    Screenshot of NetFramework Class Library project in Visual Studio.

  2. 设置项目名称 HexFileReader

  3. 设置解决方案名称 ShimsTutorial

  4. 将项目的目标框架设置为 .NET Framework 4.8

  5. 删除默认文件 Class1.cs

  6. 添加新文件 HexFile.cs 并添加以下类定义:

    // HexFile.cs
    public class HexFile
    {
        public string[] Records { get; private set; }
    
        public HexFile(string path)
        {
            this.Records = System.IO.File.ReadAllLines(path);
        }
    }
    

创建测试项目

  1. 右键单击解决方案并添加新项目 MSTest Test Project

  2. 设置项目名称 TestProject

  3. 将项目的目标框架设置为 .NET Framework 4.8

    Screenshot of NetFramework Test project in Visual Studio.

添加 Fakes 程序集

  1. 添加对 HexFileReader 的项目引用

    Screenshot of the command Add Project Reference.

  2. 添加 Fakes 程序集

    • 在“解决方案资源管理器”中:

      • 对于旧版 .NET Framework 项目(非 SDK 样式),展开单元测试项目的“引用”节点。

      • 对于定目标到 .NET Framework、.NET Core 或 .NET 5+ 的 SDK 样式项目,展开“依赖项”节点,以在“程序集”、“项目”或“包”下找到要虚设的程序集。

      • 如果使用的是 Visual Basic,请选择“解决方案资源管理器”工具栏中的“显示所有文件”,以查看“引用”节点。

    • 选择包含 System.IO.File.ReadAllLines 定义的程序集 System

    • 在快捷菜单上,选择“添加 Fakes 程序集”。

    Screnshot of the command Add Fakes Assembly.

由于生成会导致一些警告和错误(因为并非所有类型都可以与填充码一起使用),因此必须修改 Fakes\mscorlib.fakes 的内容以排除它们。

<Fakes xmlns="http://schemas.microsoft.com/fakes/2011/" Diagnostic="true">
  <Assembly Name="mscorlib" Version="4.0.0.0"/>
  <StubGeneration>
    <Clear/>
  </StubGeneration>
  <ShimGeneration>
    <Clear/>
    <Add FullName="System.IO.File"/>
    <Remove FullName="System.IO.FileStreamAsyncResult"/>
    <Remove FullName="System.IO.FileSystemEnumerableFactory"/>
    <Remove FullName="System.IO.FileInfoResultHandler"/>
    <Remove FullName="System.IO.FileSystemInfoResultHandler"/>
    <Remove FullName="System.IO.FileStream+FileStreamReadWriteTask"/>
    <Remove FullName="System.IO.FileSystemEnumerableIterator"/>
  </ShimGeneration>
</Fakes>

创建单元测试

  1. 修改默认文件 UnitTest1.cs 以添加以下 TestMethod

    [TestMethod]
    public void TestFileReadAllLine()
    {
        using (ShimsContext.Create())
        {
            // Arrange
            System.IO.Fakes.ShimFile.ReadAllLinesString = (s) => new string[] { "Hello", "World", "Shims" };
    
            // Act
            var target = new HexFile("this_file_doesnt_exist.txt");
    
            Assert.AreEqual(3, target.Records.Length);
        }
    }
    

    下面是显示所有文件的解决方案资源管理器

    Screenshot of Solution Explorer showing all files.

  2. 打开测试资源管理器并运行测试。

正确释放每个填充码上下文至关重要。 根据经验,请调用 using 语句内的 ShimsContext.Create,以便确保清除已注册的填充码。 例如,您可能为某一测试方法注册了填充码,而且该方法会将 DateTime.Now 方法替换为始终返回 2000 年 1 月 1 日的委托。 如果忘记清除测试方法中的已注册填充码,则剩余的测试运行将始终返回 2000 年 1 月 1 日作为 DateTime.Now 值。 这可能会让人感到惊讶和困惑。


填充码类的命名约定

填充码类名称是通过在原始类型名称前加上 Fakes.Shim 前缀构成的。 在方法名称后面将会追加参数名称。 (无需向 System.Fakes 添加任何程序集引用。)

    System.IO.File.ReadAllLines(path);
    System.IO.Fakes.ShimFile.ReadAllLinesString = (path) => new string[] { "Hello", "World", "Shims" };

了解填充码的工作原理

填充码通过向受测应用程序的代码库引入绕道来运行。 每当调用原始方法时,Fakes 系统就会进行干预以重定向该调用,从而导致执行自定义填充码代码而不是原始方法。

请务必注意,这些绕道是在运行时动态创建和删除的。 应始终在 ShimsContext 的生命周期内创建绕道。 释放 ShimsContext 后,在此期间创建的任何活动填充码也会被删除。 为了有效地管理这一点,建议在 using 语句中封装绕道的创建。


用于不同方法类型的存根

填充码支持各种类型的方法。

静态方法

填充静态方法时,保留填充码的属性位于填充码类型中。 这些属性仅拥有一个资源库,用于将委托附加到目标方法。 例如,如果我们有一个名为 MyClass 且具有静态方法 MyMethod 的类:

//code under test
public static class MyClass {
    public static int MyMethod() {
        ...
    }
}

我们可以将填充码附加到 MyMethod,以便始终返回 5:

// unit test code
ShimMyClass.MyMethod = () => 5;

实例方法(对于所有实例)

与静态方法类似,可以为所有实例填充实例方法。 为了避免混淆,保留这些填充码的属性放置在名为 AllInstances 的嵌套类型中。 如果我们有一个具有实例方法 MyMethod 的类 MyClass

// code under test
public class MyClass {
    public int MyMethod() {
        ...
    }
}

无论任何实例,我们都可以将一个填充码附加到 MyMethod,以便始终返回 5:

// unit test code
ShimMyClass.AllInstances.MyMethod = () => 5;

所生成的 ShimMyClass 的类型结构如下所示:

// Fakes generated code
public class ShimMyClass : ShimBase<MyClass> {
    public static class AllInstances {
        public static Func<MyClass, int>MyMethod {
            set {
                ...
            }
        }
    }
}

在此方案中,Fakes 会将运行时实例作为委托的第一个自变量传递。

实例方法(单个运行时实例)

根据调用的接收方,也可以使用不同的委托来填充实例方法。 这样一来,同一实例方法可以根据类型实例而呈现不同的行为。 保留这些填充码的属性是填充码类型本身的实例方法。 每个实例化的填充码类型会与一个所填充类型的原始实例相关联。

例如,给定一个具有静态方法 MyClass 的类 MyMethod

// code under test
public class MyClass {
    public int MyMethod() {
        ...
    }
}

我们可以创建两种填充码类型的 MyMethod,以便第一个始终返回 5,而第二个始终返回 10:

// unit test code
var myClass1 = new ShimMyClass()
{
    MyMethod = () => 5
};
var myClass2 = new ShimMyClass { MyMethod = () => 10 };

所生成的 ShimMyClass 的类型结构如下所示:

// Fakes generated code
public class ShimMyClass : ShimBase<MyClass> {
    public Func<int> MyMethod {
        set {
            ...
        }
    }
    public MyClass Instance {
        get {
            ...
        }
    }
}

实际填充的类型实例可通过 Instance 属性来访问:

// unit test code
var shim = new ShimMyClass();
var instance = shim.Instance;

填充码类型还包括到所填充类型的隐式转换,允许你直接使用填充码类型:

// unit test code
var shim = new ShimMyClass();
MyClass instance = shim; // implicit cast retrieves the runtime instance

构造函数

对于填充,构造函数也不例外;也可以填充它们,以将填充码类型附加到将来创建的对象。 例如,每个构造函数在填充码类型中表示为名为 Constructor 的静态方法。 让我们考虑一个具有接受整数的构造函数的类 MyClass

public class MyClass {
    public MyClass(int value) {
        this.Value = value;
    }
    ...
}

可以设置构造函数的填充码类型,这样,无论传递给构造函数的值是什么,在调用值 getter 时,每个未来实例都返回 -5:

// unit test code
ShimMyClass.ConstructorInt32 = (@this, value) => {
    var shim = new ShimMyClass(@this) {
        ValueGet = () => -5
    };
};

每个填充码类型都会公开两种类型的构造函数。 当需要新实例时,应使用默认构造函数,而以填充的实例为参数的构造函数只应在构造函数填充码中使用:

// unit test code
public ShimMyClass() { }
public ShimMyClass(MyClass instance) : base(instance) { }

所生成的 ShimMyClass 的类型结构可按如下所示进行说明:

// Fakes generated code
public class ShimMyClass : ShimBase<MyClass>
{
    public static Action<MyClass, int> ConstructorInt32 {
        set {
            ...
        }
    }

    public ShimMyClass() { }
    public ShimMyClass(MyClass instance) : base(instance) { }
    ...
}

访问基成员

基成员的填充码属性可以通过以下方式来访问:为基类型创建填充码,并将子实例输入到基填充码类的构造函数中。

例如,考虑一个具有实例方法 MyMethod 和子类型 MyChild 的类 MyBase

public abstract class MyBase {
    public int MyMethod() {
        ...
    }
}

public class MyChild : MyBase {
}

可以通过启动新的 ShimMyBase 填充码来设置 MyBase 的填充码:

// unit test code
var child = new ShimMyChild();
new ShimMyBase(child) { MyMethod = () => 5 };

请务必注意,当作为参数传递给基填充码构造函数时,子填充码类型将隐式转换为子实例。

可以将所生成的 ShimMyChildShimMyBase 的类型结构比作以下代码:

// Fakes generated code
public class ShimMyChild : ShimBase<MyChild> {
    public ShimMyChild() { }
    public ShimMyChild(Child child)
        : base(child) { }
}
public class ShimMyBase : ShimBase<MyBase> {
    public ShimMyBase(Base target) { }
    public Func<int> MyMethod
    { set { ... } }
}

静态构造函数

填充码类型会公开静态方法 StaticConstructor,以便填充类型的静态构造函数。 由于静态构造函数仅执行一次,因此,在访问任何类型成员之前,你需要确保配置填充码。

终结器

Fakes 中不支持终结器。

私有方法

Fakes 代码生成器会为仅具有签名中的可见类型的私有方法创建填充码属性,即可见的参数类型和返回类型。

绑定接口

当填充的类型实现某一接口时,代码生成器将发出一个方法,以便它同时绑定该接口的所有成员。

例如,给定一个实现 MyClass 的类 IEnumerable<int>

public class MyClass : IEnumerable<int> {
    public IEnumerator<int> GetEnumerator() {
        ...
    }
    ...
}

通过调用 Bind 方法,可以填充 MyClass 中的 IEnumerable<int> 实现:

// unit test code
var shimMyClass = new ShimMyClass();
shimMyClass.Bind(new List<int> { 1, 2, 3 });

所生成的 ShimMyClass 类型结构类似于以下代码:

// Fakes generated code
public class ShimMyClass : ShimBase<MyClass> {
    public ShimMyClass Bind(IEnumerable<int> target) {
        ...
    }
}

更改默认行为

每个生成的填充码类型都包括 IShimBehavior 接口的一个实例(可以通过 ShimBase<T>.InstanceBehavior 属性进行访问)。 只要客户端调用未显式填充的实例成员,系统就会调用此行为。

默认情况下,如果未设置特定行为,则会使用静态 ShimBehaviors.Current 属性返回的实例,这通常会引发 NotImplementedException 异常。

通过调整任何填充码实例的 InstanceBehavior 属性,可以随时修改此行为。 例如,以下代码片段将该行为更改为不执行任何操作或返回返回类型的默认值,即 default(T)

// unit test code
var shim = new ShimMyClass();
//return default(T) or do nothing
shim.InstanceBehavior = ShimBehaviors.DefaultValue;

还可以通过设置静态 ShimBehaviors.Current 属性来全局更改所有填充实例的行为(其中 InstanceBehavior 属性尚未显式定义):

// unit test code
// change default shim for all shim instances where the behavior has not been set
ShimBehaviors.Current = ShimBehaviors.DefaultValue;

标识与外部依赖项的交互

为了帮助标识代码何时与外部系统或依赖项交互(称为 environment),可以使用填充码将特定行为分配给类型的所有成员。 这包括静态方法。 通过在填充码类型的静态 Behavior 属性上设置 ShimBehaviors.NotImplemented 行为,对尚未显式填充的该类型成员的任何访问都将引发 NotImplementedException。 在测试期间,这可以作为一个有用的信号,指示代码正在尝试访问外部系统或依赖项。

下面是如何在单元测试代码中设置此项的示例:

// unit test code
// Assign the NotImplementedException behavior to ShimMyClass
ShimMyClass.Behavior = ShimBehaviors.NotImplemented;

为方便起见,还提供了速记方法来实现相同的效果:

// Shorthand to assign the NotImplementedException behavior to ShimMyClass
ShimMyClass.BehaveAsNotImplemented();

从填充码方法中调用原始方法

在某些情况下,可能需要在执行填充码方法期间执行原始方法。 例如,你可能想要在验证文件名已传递给方法之后,再将文本写到文件系统。

处理此情况的一种方法是使用委托和 ShimsContext.ExecuteWithoutShims() 来封装对原始方法的调用,如下面的代码中所示:

// unit test code
ShimFile.WriteAllTextStringString = (fileName, content) => {
  ShimsContext.ExecuteWithoutShims(() => {

      Console.WriteLine("enter");
      File.WriteAllText(fileName, content);
      Console.WriteLine("leave");
  });
};

或者,可以将填充码设置为 null,调用原始方法,然后还原填充码。

// unit test code
ShimsDelegates.Action<string, string> shim = null;
shim = (fileName, content) => {
  try {
    Console.WriteLine("enter");
    // remove shim in order to call original method
    ShimFile.WriteAllTextStringString = null;
    File.WriteAllText(fileName, content);
  }
  finally
  {
    // restore shim
    ShimFile.WriteAllTextStringString = shim;
    Console.WriteLine("leave");
  }
};
// initialize the shim
ShimFile.WriteAllTextStringString = shim;

填充码类型的并发处理

填充码类型跨 AppDomain 中的所有线程运行,且不具有线程关联性。 如果计划使用支持并发的测试运行器,则必须记住此属性。 值得注意的是,涉及填充码类型的测试无法同时运行,尽管 Fakes 运行时未强制实施此限制也是如此。

Shimming System.Environment

如果要填充 System.Environment 类,则需要对 mscorlib.fakes 文件进行一些修改。 在 Assembly 元素之后添加以下内容:

<ShimGeneration>
    <Add FullName="System.Environment"/>
</ShimGeneration>

进行这些更改并重新生成解决方案后,现在可以填充 System.Environment 类中的方法和属性。 下面是一个示例,说明如何向 GetCommandLineArgsGet 方法分配行为:

System.Fakes.ShimEnvironment.GetCommandLineArgsGet = ...

通过进行这些修改,你可以控制和测试代码与系统环境变量的交互方式,这是一种用于全面单元测试的基本工具。