用纯方法和有副作用的方法模拟 class

Mocking a class with both pure methods and ones with side effects

假设我们有这个接口:

public interface IFileSystem
{
    string ReadFile(string filename);
    string CombinePaths(string path1, string path2);
}

以及下面的具体实现

public class ConcreteFileSystem : IFileSystem
{
    public string CombinePaths(string path1, string path2)
    {
        return path1 + "/" + path2;
    }
}

ReadFile的实现在这里并不重要。

拥有文件系统实例非常好,因为它允许模拟具有副作用的文件系统调用(如 ReadFile)。

然后我们进行测试

var mock = new Mock<IFileSystem>();
var fs = mock.Object;

// How do I forward the calls to Mock<IFileSystem>.CombinePaths to ConcreteFileSystem.CombinePaths?

Assert.IsTrue(SomeClass.SomeMethodThatUsesCombinePaths("specificString1"))

目前在我的代码库中做的是每次测试中这样的代码:

mock.Setup(f => f.CombinePaths("specificString1", "specificString2"))
    .Returns("specificString1/specificString2");

但是由于测试应该测试接口,而不是实现,这似乎是一个糟糕的方法。此外,当复制一些测试代码时,可能会出现细微的错误。


我想到的是

mock.Setup(f => f.CombinePaths(It.IsAny<string>(), It.IsAny<string>()))
    .Returns<string, string>((path1, path2) => new FileSystem().CombinePaths(path1, path2));

(也许这可以用一些 C# 特定的语法来缩短)。

IFilesystem 的每个方法测试的 [SetUp] 部分中的此类代码不再依赖于 SomeClass.SomeMethodThatUsesCombinePaths 的实现。

我的问题是,这是否是一种好的方法,或者如何改进这种方法。也许有一种更基本的不同做事方式。

如果按原样使用测试中的实现没有副作用,则使用它们。

如果要在实现中使用的成员有副作用,则在单独测试时替换它们以避免不需要的行为

模拟具体实现,启用 CallBase 因此它知道调用未被覆盖的实际成员

var mock = new Mock<ConcreteFileSystem>() {
    CallBase = true;
};

setup/override有副作用的成员,.

mock.Setup(_ => _.ReadFile(It.IsAny<string>())
    .Returns(string.Empty); //Or what ever content you want

但是请注意,要在实现中重写的成员需要是虚拟的或抽象的。

public class ConcreteFileSystem : IFileSystem {
    public virtual string CombinePaths(string path1, string path2) {
        return System.IO.Path.Combine(path1, path2);
    }

    public virtual string ReadFile(string path) {
        return System.IO.File.ReadAllText(path);
    }
}

我想知道你为什么要嘲笑 CombinePaths。嘲笑应该是有原因的。充分的理由是:

  • 您无法轻松地将依赖组件(DOC,在本例中为 CombinePaths)置于测试所需的状态。这不适用于此处。
  • 调用 DOC 是否会导致任何非确定性行为(date/time、随机性、网络连接)?这里不是这样。
  • 使用原始 DOC 是否会导致构建/执行时间过长?可能不是。
  • 是否存在使测试不可靠的 DOC 稳定性(成熟度)问题,或者更糟糕的是,DOC 是否还不可用?这里不是这样。

如果以上都不适用,为什么要模拟?你不必教条地嘲笑一切。例如,您也不要模拟 sincos 等标准库数学函数,因为它们也不存在上述任何问题。

换个话题:你说 "tests should test the interface, not the implementation"。我不同意:测试应该最重要的是找到代码中的错误。错误在实现中。相同功能的不同实现会有不同的错误。如果实现不重要,为什么要关心代码覆盖率?当然,拥有可维护的测试和在重构的情况下不会不必要地中断的测试是很好的目标(为什么通过 public API 进行测试通常是一个很好的方法 - 但并非总是如此),但它们是次要目标与找到所有错误的目标相比。