我正在编写一个组件,给定一个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或本机代码.
好极了!现在它是可测试的; 我可以将测试双打(模拟)提供给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.
想一想,问问自己:
"如果我改变这种方法的实施细节(不改变公共合同),它会破坏我的考试吗?"
如果答案是肯定的,那么您正在测试HOW而不是WHAT.
要回答有关使用文件系统依赖性测试代码的特定问题,假设您对文件进行了一些更有趣的事情,并且希望将a的Base64编码内容保存byte[]
到文件中.您可以使用流来测试您的代码是否做正确的事情,而无需检查它是如何做到的.一个例子可能是这样的(在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)将返回FileOutputStream
距离outputStream()
,并会写入File
.
write
这里的方法很有趣的是它将内容编写成Base64编码,这就是我们测试的内容.对于您的DoIt()
方法,使用集成测试可以更好地测试它.
这真的没什么问题,只是你把它称为单元测试还是集成测试的问题.您只需要确保如果您与文件系统进行交互,就不会出现意外的副作用.具体来说,请确保在自己之后清理 - 删除您创建的任何临时文件 - 并且您不会意外覆盖与您正在使用的临时文件具有相同文件名的现有文件.始终使用相对路径而不是绝对路径.
chdir()
在运行测试之前进入临时目录并在chdir()
之后返回也是一个好主意.
我很谨慎地使用仅为了便于单元测试而存在的类型和概念来污染我的代码.当然,如果它使设计更清洁,更好,那么很好,但我认为通常情况并非如此.
我对此的看法是,你的单元测试会尽可能多地完成,而这可能不是100%覆盖.事实上,它可能只有10%.关键是,您的单元测试应该很快并且没有外部依赖性.他们可能会测试类似"此方法在为此参数传入null时抛出ArgumentNullException"的情况.
然后我会添加集成测试(也是自动化的,可能使用相同的单元测试框架),这些测试可以具有外部依赖性并测试这些端到端场景.
在测量代码覆盖率时,我会测量单位和积分测试.
点击文件系统没有任何问题,只需将其视为集成测试而不是单元测试.我将硬编码路径与相对路径交换,并创建一个TestData子文件夹以包含单元测试的拉链.
如果您的集成测试运行时间太长,则将它们分开,这样它们就不会像快速单元测试那样频繁运行.
我同意,有时我认为基于交互的测试会导致过多的耦合,并且往往最终无法提供足够的价值.你真的想在这里测试解压缩文件而不仅仅是验证你正在调用正确的方法.
一种方法是编写解压缩方法来获取InputStreams.然后单元测试可以使用ByteArrayInputStream从字节数组构造这样的InputStream.该字节数组的内容可以是单元测试代码中的常量.