【问题标题】:Unit testing code with a file system dependency具有文件系统依赖性的单元测试代码
【发布时间】:2010-09-12 20:14:51
【问题描述】:

我正在编写一个组件,给定一个 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);
}

耶!现在它可以测试了;我可以将测试替身(模拟)输入 DoIt 方法。但代价是什么?我现在必须定义 3 个新接口才能使其可测试。我到底在测试什么?我正在测试我的 DoIt 函数是否与其依赖项正确交互。它不会测试 zip 文件是否正确解压缩等。

感觉我不再是在测试功能了。感觉就像我只是在测试班级互动。

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

edit我使用的是 .NET,但这个概念也可以应用 Java 或本机代码。

【问题讨论】:

  • 人们说不要在单元测试中写入文件系统,因为如果您想写入文件系统,那么您不了解单元测试的构成。单元测试通常与单个 real 对象(被测单元)交互,并且所有其他依赖项都被模拟并传入。然后,测试类由测试方法组成,这些方法通过对象的逻辑路径验证方法和仅被测单元中的逻辑路径。
  • 在您的情况下,唯一需要单元测试的部分是myDll.InvokeSomeSpecialMethod();,您可以在其中检查它在成功和失败情况下是否都能正常工作,所以我不会对DoIt 进行单元测试,但@987654325 @ 说滥用单元测试来仔细检查整个过程是否有效是可以接受的滥用,因为这将是伪装单元测试的集成测试,因此不需要严格应用正常的单元测试规则

标签: unit-testing dependency-injection dependencies


【解决方案1】:

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

【讨论】:

  • 好的,这样就可以注入流了。依赖注入/IOC。将流解压缩到文件中、在这些文件中加载 dll 以及在该 dll 中调用方法的部分怎么样?
【解决方案2】:

假设“文件系统交互”在框架本身中得到了很好的测试,创建你的方法来处理流,并测试它。打开 FileStream 并将其传递给方法可以不进行测试,因为 FileStream.Open 已经过框架创建者的良好测试。

【讨论】:

  • 您和 nsayer 的建议基本相同:让我的代码与流一起工作。关于将流内容解压缩到 dll 文件、打开该 dll 并在其中调用函数的部分怎么样?你会在那里做什么?
  • @JudahHimango。这些部分不一定是可测试的。你不能测试一切。将不可测试的组件抽象为它们自己的功能块,并假设它们可以工作。当你遇到这个块的工作方式的错误时,然后为它设计一个测试,瞧。单元测试并不意味着您必须测试所有内容。在某些情况下,100% 的代码覆盖率是不现实的。
【解决方案3】:

我不愿用仅为便于单元测试而存在的类型和概念污染我的代码。当然,如果它能让设计更简洁更好,那就太好了,但我认为通常情况并非如此。

我对此的看法是,您的单元测试会尽其所能,但可能不是 100% 的覆盖率。事实上,它可能只有 10%。关键是,你的单元测试应该很快并且没有外部依赖。他们可能会测试“当您为此参数传入 null 时,此方法会引发 ArgumentNullException”之类的用例。

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

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

【讨论】:

  • 是的,我听到了。在这个奇异的世界里,你已经解耦了这么多,剩下的就是对抽象对象的方法调用。通风的绒毛。当你达到这一点时,你不会觉得你真的在测试任何真实的东西。您只是在测试类之间的交互。
  • 这个答案是错误的。单元测试不像糖霜,它更像是糖。它被烤进了蛋糕里。这是编写代码的一部分……一种设计活动。因此,您永远不要用任何会“促进测试”的东西“污染”您的代码,因为测试有助于您编写代码。 99% 的情况下,测试很难编写,因为开发人员在测试之前编写了代码,最后写了evil untestable code
  • @Christopher:为了扩展你的类比,我不希望我的蛋糕最终像香草片那样我可以使用糖。我所提倡的只是实用主义。
  • @Christopher:您的简历说明了一切:“我是 TDD 狂热者”。另一方面,我是务实的。我在适合的地方做 TDD,而不是在不适合的地方做 - 我的回答中没有任何内容表明我不做 TDD,尽管你似乎认为它会做。而且不管是不是TDD,为了方便测试,我不会引入大量的复杂性。
  • @ChristopherPerry 您能解释一下如何以 TDD 方式解决 OP 的原始问题吗?我一直遇到这个;我需要编写一个函数,其唯一目的是执行具有外部依赖性的操作,就像在这个问题中一样。因此,即使在先编写测试的情况下,该测试甚至会是什么?
【解决方案4】:

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

【讨论】:

    【解决方案5】:

    点击文件系统并没有错,只需将其视为集成测试而不是单元测试。我会用相对路径交换硬编码路径,并创建一个 TestData 子文件夹来包含单元测试的 zip。

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

    我同意,有时我认为基于交互的测试会导致过多的耦合,最终往往无法提供足够的价值。您真的想在这里测试解压缩文件,而不仅仅是验证您调用了正确的方法。

    【讨论】:

    • 他们运行的频率无关紧要;我们使用自动为我们运行它们的持续集成服务器。我们真的不在乎他们需要多长时间。如果“运行多长时间”不是问题,是否有任何理由区分单元测试和集成测试?
    • 并非如此。但是,如果开发人员想要在本地快速运行所有单元测试,那么有一种简单的方法可以做到这一点。
    【解决方案6】:

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

    我会将处理操作系统的代码抽象为它自己的模块(类、程序集、jar 等等)。在您的情况下,您希望加载特定的 DLL(如果找到),因此创建一个 IDllLoader 接口和 DllLoader 类。让您的应用程序使用接口从 DllLoader 获取 DLL 并测试...毕竟您不负责解压缩代码吗?

    【讨论】:

      【解决方案7】:

      这确实没有什么问题,只是您将其称为单元测试还是集成测试的问题。你只需要确保如果你确实与文件系统交互,没有意外的副作用。具体来说,请确保您自行清理——删除您创建的任何临时文件——并且您不会意外覆盖与您正在使用的临时文件具有相同文件名的现有文件。始终使用相对路径而不是绝对路径。

      在运行测试之前将chdir() 放入临时目录也是一个好主意,然后再返回chdir()

      【讨论】:

      • +1,但请注意 chdir() 是进程范围的,因此如果您的测试框架或其未来版本支持,您可能会破坏并行运行测试的能力。
      【解决方案8】:

      正如其他人所说,第一个作为集成测试很好。第二个只测试函数应该实际做的事情,这是单元测试应该做的所有事情。

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

      【讨论】:

        【解决方案9】:

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

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

        【讨论】:

          【解决方案10】:

          耶!现在它可以测试了;我可以将测试替身(模拟)输入 DoIt 方法。但代价是什么?我现在必须定义 3 个新接口才能使其可测试。我到底在测试什么?我正在测试我的 DoIt 函数是否与其依赖项正确交互。它不会测试 zip 文件是否正确解压缩等。

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

          【讨论】:

          • 上述可测试的DoIt 函数甚至不需要测试。正如提问者正确指出的那样,没有什么重要的东西可以测试了。现在需要测试的是 IZipperIFileSystemIDllRunner 的实现,但它们正是为了测试而模拟出来的东西!
          【解决方案11】:

          您的问题暴露了刚接触它的开发人员测试中最困难的部分之一:

          “我到底要测试什么?”

          您的示例不是很有趣,因为它只是将一些 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 进行测试。

          【讨论】:

          • 我不确定我是否同意您在这里的留言。你是说这种方法不需要单元测试?所以你基本上是说TDD不好?就像你做 TDD 一样,如果不先写一个测试,你就不能写这个方法。或者您是否相信您的方法不需要测试?所有单元测试框架都包含“验证”功能的原因是可以使用它。 “这很糟糕,因为现在每次更改方法的实现细节时都必须更改测试”...欢迎来到单元测试的世界。
          • 你应该测试一个方法的合同,而不是它的实现。如果您必须在每次更改该合同的实施时更改您的测试,那么您将在维护您的应用程序代码库和测试代码库时度过一段可怕的时光。
          • @Ronnie 盲目地应用单元测试是没有帮助的。项目的性质千差万别,单元测试并非对所有项目都有效。例如,我正在开发一个项目,其中 95% 的代码是关于副作用的(请注意,这种副作用严重的性质是按要求,这是基本的复杂性,而不是偶然的,因为它从各种有状态的来源收集数据,并且几乎不进行任何操作,因此几乎没有任何纯粹的逻辑)。单元测试在这里无效,集成测试有效。
          • 副作用应该被推到系统的边缘,它们不应该在整个层中交织在一起。在边缘测试副作用,即行为。在其他任何地方,您都应该尝试使用没有副作用的纯函数,这些函数易于测试,易于推理、重用和组合。
          • 很好的解释,但是“对于您的 DoIt() 方法,这将更适合使用集成测试进行测试。”假设您将根据您在进行集成测试时仍处于这种情况的实现更改您的测试:“恭喜,您基本上将 DoIt() 方法的实现细节复制粘贴到测试中。快乐维护。”。那么,如果集成测试在实现更改后会中断,那么进行集成测试有什么意义呢?情况变得类似于“进行糟糕的测试实际上比根本没有测试更糟糕
          猜你喜欢
          • 1970-01-01
          • 2014-07-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2011-12-20
          • 2010-10-04
          • 2018-01-24
          • 1970-01-01
          相关资源
          最近更新 更多