【问题标题】:Partially mocking the internal wirings of SUT to DRY up unit-tests部分模拟 SUT 的内部布线以干燥单元测试
【发布时间】:2017-03-01 04:44:07
【问题描述】:

对不起,这篇文章很长。我已经阅读了有关该主题的几乎所有内容,但我还不相信部分模拟 SUT 以干掉测试是一个坏主意。所以,我需要首先解决所有反对它的理由,以避免重复的答案。请多多包涵。


您是否曾经有过部分模拟 SUT 本身以使测试更加 DRY 的冲动?更少的模拟,更少的疯狂,更易读的测试?!

让我们举个例子来更清楚地讨论这个主题:

class Sut
{
    public function fn0(...)
    {
        // Interacts with: Dp0
    }

    public function fn1(...)
    {
        // Calls: fn0
        // Interacts with: Dp1
    }

    public function fn2(...)
    {
        // Calls: fn0
        // Interacts with: Dp1
    }

    public function fn3(...)
    {
        // Calls: fn2
    }

    public function fn4(...)
    {
        // Calls: fn1(), fn2(), fn3()
        // Interacts with: Dp2
    }
}

现在,让我们测试 SUT 的行为。每个fn*() 代表被测类的一种行为。在这里,我不是试图对 SUT 的每个方法进行单元测试,而是对它的暴露行为进行单元测试。

class SutTest extends \PHPUnit_Framework_Testcase
{
    /**
     * @covers Sut::fn0
     */
    public function testFn0()
    {
        // Mock Dp0
    }

    /**
     * @covers Sut::fn1
     */
    public function testFn1()
    {
        // Mock Dp1, which is a direct dependency
        // Mock Dp0, which is an indirect dependency
    }

    /**
     * @covers Sut::fn2
     */
    public function testFn2()
    {
        // Mock Dp1 with different expectations than testFn1()
        // Mock Dp0 with different expectations
    }

    /**
     * @covers Sut::fn3
     */
    public function testFn3()
    {
        // Mock Dp1, again with different expectations
        // Mock Dp0, with different expectations
    }

    /**
     * @covers Sut::fn4
     */
    public function testFn4()
    {
        // Mock Dp2 which is a direct dependency
        // Mock Dp0, Dp1 as indirect dependencies
    }
}

你得到了可怕的想法!你需要不断地重复自己。它根本不干。由于每个测试对每个模拟对象的期望可能不同,因此您不能只模拟所有依赖项并为整个测试用例设置一次期望。您需要明确说明每个测试的模拟行为。

让我们也有一些真实的代码,看看当测试需要通过它正在测试的代码路径模拟所有依赖项时会是什么样子:

/** @test */
public function dispatchesActionsOnACollectionOfElementsFoundByALocator()
{
    $elementMock = $this->mock(RemoteWebElement::class)
        ->shouldReceive('sendKeys')
        ->times(3)
        ->with($text = 'some text...')
        ->andReturn(Mockery::self())
        ->mock();

    $this->inject(RemoteWebDriver::class)
        ->shouldReceive('findElements')
        ->with(WebDriverBy::class)
        ->andReturn([$elementMock, $elementMock, $elementMock])
        ->shouldReceive('getCurrentURL')
        ->zeroOrMoreTimes();

    $this->inject(WebDriverBy::class)
        ->shouldReceive('xpath')
        ->once()
        ->with($locator = 'someLocatorToMatchMultipleElements')
        ->andReturn(Mockery::self());

    $this->inject(Locator::class)
        ->shouldReceive('isLocator', 'isXpath')
        ->andReturn(true, false);

    $this->type($text, $locator);
}

疯狂!为了测试一个小方法,您会发现自己编写了这样一个极不可读的测试,并且在其链上与 3 或 4 个其他依赖方法的实现细节耦合。看到整个测试用例就更可怕了;许多模拟块,重复设置不同的期望,覆盖不同的代码路径。该测试反映其他少数几个实施细节。这是痛苦的。

解决方法

好的,回到第一个伪代码;在测试fn3() 时,您开始思考,如果我可以模拟对fn2() 的调用并制止所有疯狂的嘲弄怎么办?我对 SUT 进行了部分模拟,设置了对 fn2() 的期望,并确保被测方法与 fn2() 正确交互。

换句话说,为了避免过度模拟外部依赖项,我只关注 SUT 的一种行为(可能是一种或几种方法)并确保其行为正确。我模拟了属于 SUT 其他行为的所有其他方法。不用担心他们,他们都有自己的测试。

相反的推理

有人可能会讨论:

类中的存根/模拟方法的问题在于您违反了封装。您的测试应该检查对象的外部行为是否符合规范。对象内部发生的任何事情都与它无关。通过模拟公共方法以测试对象,您可以假设该对象是如何实现的。

在进行单元测试时,您很少会总是通过提供输入和期望输出来处理完全可测试的行为;将它们视为黑匣子。大多数时候,您需要测试它们如何相互作用。因此,我们至少需要掌握一些关于 SUT 内部实现的信息,以便能够对其进行全面测试。

当我们模拟一个依赖项时,我们已经对 SUT 的工作方式做出了假设。我们将测试绑定到 SUT 的实现细节。那么,既然我们深陷泥潭,为什么不嘲笑一种让我们的生活更轻松的内部方法呢?!

有人会说:

模拟方法是将对象 (SUT) 分成两部分。一件正在被嘲笑,而另一件正在被测试。你所做的本质上是对对象的临时分解。如果是这样的话,就已经分解对象了。


单元测试应将它们测试的类视为黑盒。唯一重要的是它的公共方法的行为方式符合预期。类如何通过内部状态和私有方法实现这一点并不重要。当你觉得无法以这种方式创建有意义的测试时,这表明你的类太强大了,做得太多了。您应该考虑将它们的一些功能移动到可以单独测试的单独类中。


如果有支持这种分离的参数(部分模拟 SUT),可以使用相同的参数将类重构为两个类,这正是您应该这样做的。

如果它是 SRP 的味道,是的,可以将功能提取到另一个类中,然后您可以轻松地模拟该类并愉快地回家。但事实并非如此。 SUT 的设计还可以,没有 SRP 问题,体积小,可以完成一项工作并遵守 SOLID 原则。查看 SUT 的代码时,没有理由要将功能分解为其他一些类。它已经破碎成非常细的碎片了。

为什么当您查看 SUT 测试时,您决定打破课程?为什么在测试fn3() 时可以一路模拟所有这些依赖项,但模拟它拥有的唯一真实 依赖项(即使它是内部依赖项)是不行的? fn2()。无论哪种方式,我们都受制于 SUT 的实现细节。无论哪种方式,测试都是脆弱的。

重要的是要注意我们为什么要模拟这些方法。我们只是想要更轻松的测试,更少的模拟,同时保持 SUT 的绝对隔离(稍后会详细介绍)。

其他一些可能的原因:

在我看来,一个对象具有外部和内部行为。外部行为包括返回值、调用其他对象等。显然,应该测试该类别中的任何内容。但内部行为不应该真正被测试。我不直接针对内部行为编写测试,只是通过外部行为间接编写测试。

对,我也这样做。但我们不是在测试 SUT 的内部结构,我们只是在使用它的公共 API,我们希望避免过度模拟。

推理表明,外部行为包括对其他对象的调用;我同意。我们也在这里尝试测试 SUT 的外部调用,只是通过 early-mocking 进行交互的内部方法。该模拟方法已经进行了测试。

另一个原因:

模拟太多并且已经完美地分成多个类?您通过对应该进行集成测试的内容进行单元测试来进行过度单元测试。

示例代码只有 3 个外部依赖项,我认为这并不过分。同样,重要的是要注意我为什么要部分模拟 SUT;仅且仅用于更轻松的测试,避免过多的模拟。

顺便说一句,这个推理在某种程度上可能是正确的。在某些情况下,我可能需要进行集成测试。下一节将对此进行更多介绍。

最后一句说:

这些都是测试人员,而不是生产代码,它们不需要是 DRY!

我真的读过这样的东西!而我根本不这么认为。我需要用我的生命!你也是!

底线:模拟还是不模拟?

当我们选择模拟时,我们正在编写白盒单元测试。我们或多或少地对 SUT 的实现细节进行了边界测试。然后,如果我们决定走 PURE 的道路,并从根本上保持 SUT 的隔离,那么迟早我们会发现自己陷入了疯狂的嘲弄……和脆弱的测试中。维护十个月后,您发现自己在为单元测试服务,而不是他们为您服务!您会发现自己为一个 SUT 方法的实现中的单个更改重新实现了多个测试。痛对不对?

那么,如果我们这样做,为什么不部分模拟 SUT?为什么不让我们的生活更轻松呢?我看没有理由不这样做?你?

我读了又读,终于看到了鲍勃叔叔的这篇文章: https://8thlight.com/blog/uncle-bob/2014/05/10/WhenToMock.html

引用最重要的部分:

模拟跨架构重要边界,但不在这些边界内。

我认为这是对我告诉你的所有嘲弄疯狂的补救措施。正如我盲目地学习的那样,没有必要从根本上保持 SUT 的隔离。尽管它可能在大部分时间都有效,但它也可能迫使你生活在你私人的嘲弄地狱中,把你的头撞到墙上。

这个小小的建议,是不部分模拟 SUT 的唯一理由。事实上,这与这样做正好相反。但是现在的问题是,这不是集成测试吗?那还叫单元测试吗?这里的单位是什么?具有建筑意义的边界?

这是 Google 测试团队的另一篇文章,暗示了相同的做法: https://testing.googleblog.com/2013/05/testing-on-toilet-dont-overuse-mocks.html

回顾

  • 如果我们采用纯粹的隔离方式,假设 SUT 已经被分解成细小块,外部部门可能最少,有什么理由不部分模拟 SUT?为了避免过度嘲笑并使单元测试更加干燥?

  • 如果我们把 Bob 叔叔的建议放在心上,并且只“模拟在架构上重要的边界,但不在这些边界内。”,这仍然被认为是单元测试吗?这里的单位是什么?

感谢您的阅读。

附注这些相反的推理或多或少来自我在该主题上找到的现有 SO 答案或文章。不幸的是,我目前没有要链接的参考文献。

【问题讨论】:

    标签: unit-testing mocking


    【解决方案1】:

    单元测试不必是孤立的单元测试,至少如果您接受 Martin Fowler 和 Kent Beck 等作者提倡的 definition。 Kent 是 JUnit 的创建者,并且可能是 TDD 的主要支持者。这些人不会嘲讽。

    根据我自己的经验(作为高级 Java 模拟 library 的长期开发人员),我看到程序员一直在滥用和滥用模拟 API。特别是,当他们认为部分模拟 SUT 是一个有效的想法时。它不是。让测试代码更加 DRY 不应该成为过度嘲笑的借口。

    就我个人而言,我更喜欢使用最少或(最好)没有 mocking 的集成测试。只要您的测试稳定且运行速度足够快,就可以了。重要的是测试不会成为编写、维护的痛苦,更重要的是不要阻止程序员运行它们。 (这就是我避免 功能性 UI 驱动测试的原因 - 它们运行起来往往很痛苦。)

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2013-07-08
      • 2011-05-13
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多