我正在编写一个组件,给定一个 ZIP 文件,它需要:
解压缩文件。在解压缩的文件中查找特定的 dll。通过反射加载该 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);
}
耶!现在它可以测试了;我可以将测试替身(模拟)输入 DoIt 方法。但代价是什么?我现在必须定义 3 个新接口才能使其可测试。我到底在测试什么?我正在测试我的 DoIt 函数是否与其依赖项正确交互。它不会测试 zip 文件是否已正确解压缩等。
感觉我不再在测试功能了。感觉就像我只是在测试课堂互动。
我的问题是:对依赖于文件系统的东西进行单元测试的正确方法是什么?
编辑我正在使用 .NET,但这个概念也可以应用 Java 或本机代码。
myDll.InvokeSomeSpecialMethod();
,您将在其中检查它在成功和失败情况下是否都能正常工作,因此我不会对 DoIt
进行单元测试,但 DllRunner.Run
表示滥用 UNIT 测试仔细检查整个过程是否正常工作是可以接受的误用,因为这将是伪装单元测试的集成测试,因此不需要严格应用正常的单元测试规则
耶!现在它可以测试了;我可以将测试替身(模拟)输入 DoIt 方法。但代价是什么?我现在必须定义 3 个新接口才能使其可测试。我到底在测试什么?我正在测试我的 DoIt 函数是否与其依赖项正确交互。它不会测试 zip 文件是否已正确解压缩等。
你已经击中了它的头。你要测试的是你的方法的逻辑,不一定是一个真正的文件是否可以被寻址。您不需要测试(在此单元测试中)文件是否正确解压缩,您的方法认为这是理所当然的。接口本身很有价值,因为它们提供了可以编程的抽象,而不是隐式或显式地依赖于一个具体的实现。
您的问题暴露了刚刚进入它的开发人员测试中最困难的部分之一:
“我到底要测试什么?”
您的示例不是很有趣,因为它只是将一些 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()
方法的实现细节复制粘贴到了测试中。快乐维护。
当您编写测试时,您想测试的是 WHAT 而不是 HOW。请参阅 Black Box Testing 了解更多信息。
WHAT 是您的方法的名称(或者至少应该是)。 HOW 是你方法中的所有小实现细节。好的测试可以让你在不破坏 WHAT 的情况下更换 HOW。
这样想,问问自己:
“如果我更改此方法的实施细节(不更改公共合同)会破坏我的测试吗?”
如果答案是肯定的,那么您正在测试 HOW 而不是 WHAT。
要回答有关使用文件系统依赖项测试代码的具体问题,假设您对文件进行了一些更有趣的事情,并且您想将 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()
方法,这将更适合使用 integration test 进行测试。
这真的没有什么问题,只是你称它为单元测试还是集成测试的问题。你只需要确保如果你确实与文件系统交互,没有意外的副作用。具体来说,请确保您自行清理——删除您创建的任何临时文件——并且您不会意外覆盖与您正在使用的临时文件具有相同文件名的现有文件。始终使用相对路径而不是绝对路径。
在运行测试之前 chdir()
进入一个临时目录,然后再返回 chdir()
也是一个好主意。
chdir()
是进程范围的,因此如果您的测试框架或其未来版本支持,您可能会破坏并行运行测试的能力。
我不愿用仅用于促进单元测试的类型和概念来污染我的代码。当然,如果它使设计更简洁更好,那就更好了,但我认为通常情况并非如此。
我对此的看法是,您的单元测试会尽其所能,但可能不是 100% 的覆盖率。事实上,它可能只有 10%。关键是,你的单元测试应该很快并且没有外部依赖。他们可能会测试诸如“当您为此参数传入 null 时,此方法会引发 ArgumentNullException”之类的案例。
然后,我将添加可以具有外部依赖关系的集成测试(也是自动化的,并且可能使用相同的单元测试框架)并测试诸如此类的端到端场景。
在测量代码覆盖率时,我会同时测量单元测试和集成测试。
打文件系统没有错,只需将其视为集成测试而不是单元测试。我会用相对路径交换硬编码路径,并创建一个 TestData 子文件夹来包含单元测试的 zip。
如果您的集成测试运行时间过长,请将它们分开,这样它们就不会像您的快速单元测试那样频繁地运行。
我同意,有时我认为基于交互的测试会导致过多的耦合,并且通常最终无法提供足够的价值。您真的想在这里测试解压缩文件,而不仅仅是验证您正在调用正确的方法。
一种方法是编写 unzip 方法来获取 InputStreams。然后单元测试可以使用 ByteArrayInputStream 从字节数组构造这样的 InputStream。该字节数组的内容可以是单元测试代码中的常量。
这似乎更像是一种集成测试,因为您依赖于理论上可能会改变的特定细节(文件系统)。
我会将处理操作系统的代码抽象为它自己的模块(类、程序集、jar 等等)。在您的情况下,您希望加载特定的 DLL(如果找到),因此创建一个 IDllLoader 接口和 DllLoader 类。让您的应用程序使用该接口从 DllLoader 获取 DLL 并进行测试……毕竟您不负责解压缩代码吗?
假设“文件系统交互”在框架本身中得到了很好的测试,创建你的方法来处理流,并测试它。打开 FileStream 并将其传递给方法可以忽略不计,因为 FileStream.Open 已由框架创建者进行了很好的测试。
您不应该测试类交互和函数调用。相反,您应该考虑集成测试。测试所需的结果,而不是文件加载操作。
正如其他人所说,第一个作为集成测试很好。第二个只测试函数应该实际做的事情,这是单元测试应该做的所有事情。
如图所示,第二个示例看起来有点毫无意义,但它确实让您有机会测试函数如何响应任何步骤中的错误。您在示例中没有任何错误检查,但在您可能拥有的真实系统中,依赖注入将让您测试对任何错误的所有响应。那么成本将是值得的。
对于单元测试,我建议您在项目中包含测试文件(EAR 文件或等效文件),然后在单元测试中使用相对路径,即“../testdata/testfile”。
只要您的项目正确导出/导入,您的单元测试就应该可以工作。
DoIt
函数甚至不需要测试。正如提问者正确指出的那样,没有什么重要的东西可以测试了。现在需要测试的是IZipper
、IFileSystem
和IDllRunner
的实现,但它们正是为了测试而模拟出来的东西!