【问题标题】:How to write tests without so many mocks?如何在没有这么多模拟的情况下编写测试?
【发布时间】:2010-12-08 10:22:43
【问题描述】:

我是正确的测试驱动设计或行为驱动设计的大力倡导者,我喜欢编写测试。但是,我一直把自己编码到一个角落,我需要在一个特定的测试用例中为单个类使用 3-5 个模拟。无论我以哪种方式开始,自上而下或自下而上,我最终的设计都需要来自最高抽象级别的至少三个协作者。

有人可以就如何避免这个陷阱给出好的建议吗?

这是一个典型的场景。我设计了一个从给定文本值生成 Midget 的 Widget。在我进入细节之前,它总是开始非常简单。我的 Widget 必须与几个难以测试的东西交互,比如文件系统、数据库和网络。

因此,我没有将所有这些都设计到我的小部件中,而是创建了一个 Bridget 合作者。 Bridget 处理了一半的复杂性,即数据库和网络,让我可以专注于另一半,即多媒体演示。所以,然后我制作了一个执行多媒体片段的小工具。整个事情需要在后台发生,所以现在我包括一个 Thridget 来实现它。当一切都说完了,我最终得到了一个 Widget,它将工作交给 Thridget,Thrridget 通过 Bridget 将其结果提供给 Gidget。

因为我在 CocoaTouch 中工作并试图避免模拟对象,所以我使用自分流模式,其中对协作者的抽象成为我的测试采用的协议。与 3 个以上的合作者一起,我的测试气球变得太复杂了。即使使用 OCMock 模拟对象之类的东西,也会给我留下一个我宁愿避免的复杂性顺序。我尝试将我的大脑围绕着一个菊花链的协作者(A 代表 B,B 代表 C 等等),但我无法想象。

编辑 下面举个例子,假设我们有一个对象必须从套接字读取/写入并显示返回的电影数据。

//Assume myRequest is a String param...
InputStream   aIn  = aSocket.getInputStram();
OutputStream  aOut = aSocket.getOutputStram();
DataProcessor aProcessor = ...;

// This gets broken into a "Network" collaborator.
for(stuff in myRequest.charArray()) aOut.write(stuff);
Object Data = aIn.read(); // Simplified read

//This is our second collaborator
aProcessor.process(Data);

现在上面显然处理网络延迟,所以它必须是线程的。这引入了一个线程抽象来让我们摆脱线程单元测试的实践。我们现在有

AsynchronousWorker myworker = getWorker(); //here's our third collaborator
worker.doThisWork( new WorkRequest() {
//Assume myRequest is a String param...
DataProcessor aProcessor = ...;

// Use our "Network" collaborator.
NetworkHandler networkHandler = getNetworkHandler();
Object Data = networkHandler.retrieveData(); // Simplified read

//This is our multimedia collaborator
aProcessor.process(Data);
})

请原谅我在没有测试的情况下逆向工作,但我正要带我的女儿出去,我正在匆匆完成这个例子。这里的想法是,我正在从一个简单的界面后面协调几个协作者的协作,该界面将与 UI 按钮单击事件相关联。所以最外面的测试反映了一个 Sprint 任务,它说给定一个“播放电影”按钮,当它被点击时,电影就会播放。 编辑 让我们讨论一下。

【问题讨论】:

    标签: unit-testing tdd mocking


    【解决方案1】:

    要摆脱过多的模拟,您可以关注Test Pyramid,它建议进行大量单元+组件测试和少量缓慢且脆弱的系统测试。它归结为几个简单的规则:

    • 在不需要模拟的最低级别编写测试。如果您可以编写单元测试(例如解析字符串),请编写它。但是如果你想检查解析是否被上层调用,那么这将需要初始化更多的东西。
    • 模拟外部系统。您的系统需要是一个独立的、独立的部分。依赖外部应用程序(会有自己的错误)会使测试复杂化很多。编写模拟/存根要容易得多。
    • 之后进行几个测试,检查您的应用是否具有真正的集成。

    有了这种心态,你几乎可以消除所有的嘲笑。

    【讨论】:

      【解决方案2】:

      拥有许多模拟对象表明:

      1) 你有太多的依赖。 重新查看您的代码并尝试进一步分解它。特别是,尽量将数据转换和处理分开。

      由于我对您开发的环境没有经验。所以让我以我自己的经验为例。

      在 Java 套接字中,您将获得一组简单的 InputStream 和 OutputStream,以便您可以从对等点读取数据并将数据发送到对等点。所以你的程序看起来像这样:

      InputStream  aIn  = aSocket.getInputStram();
      OutputStream aOut = aSocket.getOutputStram();
      
      // Read data
      Object Data = aIn.read(); // Simplified read
      // Process
      if (Data.equals('1')) {
         // Do something
         // Write data
         aOut.write('A');
      } else {
         // Do something else 
         // Write another data
         aOut.write('B');
      }
      

      如果你想测试这个方法,你最终必须为 In 和 Out 创建 mock,这可能需要它们背后的相当复杂的类来支持。

      但是如果你仔细看,从 aIn 读取和向 aOut 写入可以与处理它分开。因此,您可以创建另一个类,该类将接受读取输入并返回输出对象。

      public class ProcessSocket {
          public Object process(Object readObject) {
              if (readObject.equals(...)) {
             // Do something
             // Write data
             return 'A';
          } else {
             // Do something else 
             // Write another data
             return 'B';
         }
      }
      

      你之前的方法是:

      InputStream   aIn  = aSocket.getInputStram();
      OutputStream  aOut = aSocket.getOutputStram();
      ProcessSocket aProcessor = ...;
      
      // Read data
      Object Data = aIn.read(); // Simplified read
      aProcessor.process(Data);
      

      这样您就可以在几乎不需要模拟的情况下测试处理。你测试可以去:

      
      ProcessSocket aProcessor = ...;
      assert(aProcessor.process('1').equals('A'));
      

      因为处理现在独立于输入、输出甚至套接字。

      2)您已经通过单元测试完成了单元测试,应该进行集成测试。

      有些测试不适用于单元测试(从某种意义上说,它需要不必要的更多努力,并且可能无法有效地获得一个好的指标)。这类测试的示例是那些涉及并发和用户界面的测试。它们需要与单元测试不同的测试方式。

      我的建议是你进一步分解它们(类似于上面的技术),直到其中一些适合单元测试。所以你有一些难以测试的部分。

      编辑

      如果你认为你已经把它分解成非常细的部分,那也许是你的问题。

      软件组件或子组件以某种方式相互关联,例如字符组合成词,词组合成句子,句子组合成段落,段落组合成小节,部分,章节等等。

      我的例子说,你应该将小节分成段落,而你已经将事情归结为单词。

      这样看,大多数时候,段落与其他段落的相关程度比与其他句子相关(或依赖于)其他句子的句子松散程度要低。小节、小节更加松散,而单词和字符则更加依赖(随着语法规则的生效)。

      因此,也许,您将其破坏得如此之好,以至于语言语法强制这些依赖项,进而迫使您拥有如此多的模拟对象。

      如果是这种情况,您的解决方案是平衡测试。如果一个部分被许多人依赖并且它需要一组复杂的模拟对象(或简单的更多努力来测试它)。可能你不需要测试它。例如,如果 A 使用 B,C 使用 B,而 B 太难测试了。那么,为什么不将 A+B 视为一个,将 C+B 视为花药。在我的示例中,如果 SocketProcessor 太难测试,太难以至于您将花费更多时间测试和维护测试而不是开发它,那么这是不值得的,我将立即测试所有内容。

      如果没有看到您的代码(而且事实上我从未开发过 CocooTouch),这将很难说。我也许可以在这里提供很好的评论。对不起:D。

      编辑 2 查看您的示例,很明显您正在处理集成问题。假设您已经分别测试播放电影和 UI。为什么需要这么多模拟对象是可以理解的。如果这是您第一次使用这种集成结构(这种并发模式),那么实际上可能需要那些模拟对象,而您对此无能为力。这就是我能说的:-p

      希望这会有所帮助。

      【讨论】:

      • 我把事情分解得很细。就像我说的,难以测试的东西被抽象化了。我最大的问题是高级别,这与您的低级别示例相反。围绕高级对象的测试需要很多合作者。也许我应该用一个更具体的例子来编辑我的问题?
      • NawaMan,感谢您抽出宝贵的时间与我一起工作。是的,我的问题是零件集成和零件设计。从上到下,设计从简单开始。给定对特定软件组件的输入,我期望得到反应。输入很简单,但设计总是从顶部立即爆炸成 3-5 个东西。如果我从下往上开始,那么我最终会得到 3-5 个需要绑定到顶层抽象的东西。我觉得我缺少一些基本的东西。
      【解决方案3】:

      我做了一些相当完整的测试,但它是自动化集成测试而不是单元测试,所以我没有模拟(除了用户:我模拟最终用户,模拟用户输入事件并测试/断言任何输出到用户):Should one test internal implementation, or only test public behaviour?


      我正在寻找的是使用 TDD 的最佳实践。

      Wikipedia describes TDD 作为,

      一种软件开发技术 依赖于一个非常的重复 开发周期短:首先 开发人员编写了一个失败的自动化 定义所需的测试用例 改进或新功能,然后 生成代码以通过该测试并 最后将新代码重构为 可接受的标准。

      然后继续开处方:

      1. 添加测试
      2. 运行所有测试,看看新的测试是否失败
      3. 写一些代码
      4. 运行自动化测试并看到它们成功
      5. 重构代码

      我做第一个,即“非常短的开发周期”,不同之处在于我在编写后进行测试。

      我之所以在写完之后进行测试,是因为我根本不需要“写”任何测试,甚至是集成测试。

      我的周期是这样的:

      1. 重新运行所有自动化集成测试(从头开始)
      2. 实现新功能(必要时重构现有代码以支持新功能)
      3. 重新运行所有自动化集成测试(回归测试以确保新开发没有破坏现有功能)
      4. 测试新功能:

        一个。最终用户(我)通过用户界面进行用户输入,旨在使用新功能

        b.最终用户(我)检查相应的程序输出,以验证输出对于给定输入是否正确

      5. 当我在步骤 4 中进行测试时,测试环境将用户输入和程序输出捕获到数据文件中;测试环境可以在未来重播这样的测试(重新创建用户输入,并断言相应的输出是否与之前捕获的预期输出相同)。因此,在第 4 步中运行/创建的测试用例被添加到所有自动化测试套件中。

      我认为这给了我 TDD 的好处:

      • 测试与开发相结合:我在编码后立即进行测试,而不是在编码前进行测试,但无论如何,新代码在签入之前都会进行测试;从来没有未经测试的代码。

      • 我有自动化测试套件,用于回归测试

      我避免了一些成本/缺点:

      • 编写测试(相反,我使用 UI 创建新测试,这样更快、更容易,并且更接近原始要求)

      • 创建模拟(单元测试所需)

      • 在重构内部实现时编辑测试(因为测试仅依赖于公共 API,而不依赖于内部实现细节)。

      【讨论】:

      • 感谢您的回复。我正在寻找的是使用 TDD 的最佳实践。我目前正在尝试与 3-4 名合作者展开设计,以便更轻松地跟踪测试和设计。
      • 我添加到我的答案中,以比较我的方法与 TDD。
      • 我应该更具体。我实际上并不是在寻找 TDD 的定义或解释。相反,我正在为如何在上面给定的场景中正确应用它而苦苦挣扎。
      • 你说“我一直把自己编码到一个需要使用 3-5 个模拟的角落”,我说我做了什么来提供一个替代方案,在那里仍然可以立即进行自动化测试,但不需要模拟完全没有。
      • 我看到了 TDD 完全不同的好处。主要的好处来自最后一个 D,设计。我真的不在乎我的代码是否经过错误测试。我主要关心的是设计,从测试开始可以获得最大的收益。捕获错误完全是偶然发生的,这不是我计划的。如果您从未设计过带有测试的对象,我建议您尝试一下。您编写的测试和实现代码要少得多。
      【解决方案4】:

      我的解决方案(不是 CocoaTouch)是继续模拟对象,而是将模拟设置重构为通用测试方法。这降低了测试本身的复杂性,同时保留了模拟基础设施来单独测试我的类。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 2021-11-30
        • 1970-01-01
        • 2022-11-17
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2015-07-22
        • 1970-01-01
        相关资源
        最近更新 更多