具有文件系统依赖项的单元测试代码

我正在编写一个组件,给定一个 ZIP 文件,它需要:

  1. 解压文件。
  2. 在解压缩的文件中找到一个特定的 dll。
  3. 通过反射加载该 dll 并对其调用方法。

我想对这个组件进行单元测试。

我很想编写直接处理文件系统的代码:

void DoIt()
{
Zip.Unzip(theZipFile, "C:\\foo\\Unzipped");
System.IO.File myDll = File.Open("C:\\foo\\Unzipped\\SuperSecret.bar");
myDll.InvokeSomeSpecialMethod();
}

但是人们经常说,“不要编写依赖于文件系统、数据库、网络等的单元测试。”

如果我以单元测试友好的方式编写这个代码,我想它应该是这样的:

void DoIt(IZipper zipper, IFileSystem fileSystem, IDllRunner runner)
{
string path = zipper.Unzip(theZipFile);
IFakeFile file = fileSystem.Open(path);
runner.Run(file);
}

耶!现在它是可测试的; 我可以将 test double (mock)提供给 DoIt 方法。但代价是什么?我现在必须定义3个新的接口来使这个接口具有可测试性。我到底在测试什么?我正在测试我的 DoIt 函数是否与它的依赖项正确地交互。它不会测试压缩文件是否被正确解压,等等。

我感觉不再是在测试功能,而是在测试类交互。

我的问题是这样的 : 对依赖于文件系统的东西进行单元测试的正确方法是什么?

编辑 我正在使用.NET,但是这个概念也可以应用 Java 或本机代码。

56157 次浏览

一种方法是编写 unzip 方法以获取 InputStreams。然后单元测试可以使用 ByteArrayInputStream 从字节数组构造这样的 InputStream。该字节数组的内容可以是单元测试代码中的常量。

假设“文件系统交互”在框架本身中得到了很好的测试,那么创建处理流的方法,并对其进行测试。打开一个 FileStream 并将其传递给方法可以不用进行测试,比如 FileStream。框架创建者对 Open 进行了很好的测试。

我不愿意用那些只是为了方便单元测试而存在的类型和概念来污染我的代码。当然,如果它使设计更干净,更好,然后伟大,但我认为往往不是这种情况。

我对此的看法是,您的单元测试将尽可能多地进行,而这可能不是100% 的覆盖率。事实上,可能只有10% 。关键是,您的单元测试应该是快速的,并且没有外部依赖关系。他们可能会测试“当您为这个参数传入 null 时,这个方法抛出 ArgumentNullException”这样的情况。

然后,我将添加集成测试(也是自动的,并且可能使用相同的单元测试框架) ,这些测试可以具有外部依赖关系和测试端到端场景,例如。

在测量代码覆盖率时,我同时测量单元测试和集成测试。

不应该测试类交互和函数调用。相反,您应该考虑集成测试。测试所需的结果,而不是文件加载操作。

命中文件系统没有什么错,只要把它看作一个集成测试,而不是单元测试。我将用一个相对路径交换硬编码的路径,并创建一个 TestData 子文件夹来包含单元测试的 zip。

如果您的集成测试运行时间太长,那么将它们分离出来,这样它们就不会像您的快速单元测试那样频繁地运行。

我同意,有时我认为基于交互的测试会导致过多的耦合,并且经常最终没有提供足够的价值。您确实需要在这里测试解压缩文件,而不仅仅是验证调用的方法是否正确。

这似乎更像是一个集成测试,因为您依赖于理论上可能发生变化的特定细节(文件系统)。

我将把处理操作系统的代码抽象为它自己的模块(类、汇编、 jar 等等)。在您的情况下,如果找到了,您希望加载特定的 DLL,因此创建一个 IDllLoader 接口和 DllLoader 类。让您的应用程序从 DllLoader 中获取 DLL,并使用该界面进行测试。.你不用为邮政编码负责,好吗?

这真的没有什么问题,只是你把它叫做单元测试还是集成测试的问题。您只需要确保如果与文件系统进行交互,不会产生意外的副作用。具体来说,确保在您自己之后进行清理——删除您创建的所有临时文件——并且确保您不会意外地覆盖与您正在使用的临时文件具有相同文件名的现有文件。总是使用相对路径,而不是绝对路径。

最好在运行测试之前将 chdir()放入一个临时目录中,然后将 chdir()返回。

正如其他人所说,第一个测试作为集成测试是可行的。第二个测试只测试函数实际应该做什么,这是单元测试应该做的全部工作。

如图所示,第二个示例看起来有点没有意义,但它确实为您提供了测试函数如何响应任何步骤中的错误的机会。在这个示例中没有任何错误检查,但在实际系统中可能有,而且依赖注入可以让您测试对任何错误的所有响应。那么付出的代价就是值得的。

对于单元测试,我建议您在项目中包含测试文件(EAR 文件或等价文件) ,然后在单元测试中使用相对路径。./testdata/testfile”。

只要项目正确导出/导入,单元测试就应该正常工作。

耶!现在它是可测试的; 我可以将 test double (mock)提供给 DoIt 方法。但代价是什么?我现在必须定义3个新的接口来使这个接口具有可测试性。我到底在测试什么?我正在测试我的 DoIt 函数是否与它的依赖项正确地交互。它不会测试压缩文件是否被正确解压,等等。

你正中要害。您想要测试的是方法的逻辑,而不一定是真正的文件是否可以被寻址。您不需要测试(在此单元测试中)文件是否正确解压缩,您的方法认为这是理所当然的。接口本身很有价值,因为它们提供了可以编程的抽象,而不是隐式或显式地依赖于一个具体的实现。

您的问题暴露了开发人员刚刚进入其中的测试最困难的部分之一:

“我到底要测试什么?”

您的示例并不十分有趣,因为它只是将一些 API 调用粘合在一起,所以如果您要为它编写单元测试,您最终将只是断言方法已被调用。这样的测试将您的实现细节与测试紧密地耦合在一起。这很糟糕,因为现在每次更改方法的实现细节时都必须更改测试,因为更改实现细节会破坏测试!

有糟糕的测试实际上比没有测试更糟糕。

在你的例子中:

void DoIt(IZipper zipper, IFileSystem fileSystem, IDllRunner runner)
{
string path = zipper.Unzip(theZipFile);
IFakeFile file = fileSystem.Open(path);
runner.Run(file);
}

虽然可以传入模拟,但是要测试的方法中没有逻辑。如果您尝试进行单元测试,它可能看起来像这样:

// Assuming that zipper, fileSystem, and runner are mocks
void testDoIt()
{
// mock behavior of the mock objects
when(zipper.Unzip(any(File.class)).thenReturn("some path");
when(fileSystem.Open("some path")).thenReturn(mock(IFakeFile.class));


// run the test
someObject.DoIt(zipper, fileSystem, runner);


// verify things were called
verify(zipper).Unzip(any(File.class));
verify(fileSystem).Open("some path"));
verify(runner).Run(file);
}

祝贺您,您基本上将 DoIt()方法的实现细节复制粘贴到了测试中。

编写测试时,您希望测试的是 什么,而不是 怎么做 有关更多信息,请参见 黑匣子测试

什么是方法的名称(或者至少应该是)。怎么做是存在于方法中的所有小的实现细节。好的测试允许您在不破坏 什么的情况下交换 怎么做

这样想想,问问你自己:

“如果我改变这个方法的实现细节(不改变公共契约) ,它会破坏我的测试吗?”

如果答案是肯定的,那么您测试的是 怎么做而不是 什么

为了回答您关于使用文件系统依赖项测试代码的具体问题,假设您对一个文件进行了一些更有趣的操作,并且希望将 byte[]的 Base64编码内容保存到一个文件中。您可以为此使用流来测试您的代码是否做了正确的事情,而不必检查 怎么做。一个例子可能是这样的(在 Java 中) :

interface StreamFactory {
OutputStream outStream();
InputStream inStream();
}


class Base64FileWriter {
public void write(byte[] contents, StreamFactory streamFactory) {
OutputStream outputStream = streamFactory.outStream();
outputStream.write(Base64.encodeBase64(contents));
}
}


@Test
public void save_shouldBase64EncodeContents() {
OutputStream outputStream = new ByteArrayOutputStream();
StreamFactory streamFactory = mock(StreamFactory.class);
when(streamFactory.outStream()).thenReturn(outputStream);


// Run the method under test
Base64FileWriter fileWriter = new Base64FileWriter();
fileWriter.write("Man".getBytes(), streamFactory);


// Assert we saved the base64 encoded contents
assertThat(outputStream.toString()).isEqualTo("TWFu");
}

测试使用的是 ByteArrayOutputStream,但在应用程序中(使用依赖注入) ,真正的 StreamFactory (可能称为 FileStreamFactory)将从 outputStream()返回 FileOutputStream,并写入 File

这里 write方法的有趣之处在于它将 Base64编码的内容写出来,这就是我们测试的内容。对于您的 DoIt()方法,使用 综合测试进行测试更为合适。