部分嘲笑SUT的内部布线以进行单元测试

时间:2016-10-19 13:43:09

标签: unit-testing mocking

对不起,这是一篇很长的帖子。我已经阅读了关于这个主题的几乎所有内容,而且我还不相信部分模仿SUT来干扰测试是一个坏主意。因此,我需要首先解决所有针对它的推理,以避免重复的答案。请耐心等待。

为了让测试更加干燥,你有没有想过部分模仿SUT本身的冲动?减少嘲笑,减少疯狂,更可读的测试?!

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

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的外部调用,只需通过早期模拟进行交互的内部方法。那个模拟方法已经进行了测试。

另外一个原因:

  

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

示例代码只有3个外部依赖项,我不认为它太多了。同样,重要的是要注意为什么我想部分模仿SUT;只是为了更容易测试,避免过多的嘲笑。

顺便说一句,推理可能是某种方式。在某些情况下,我可能需要进行集成测试。更多相关内容将在下一节中介绍。

最后一个说:

  

这些都是测试人,而不是生产代码,他们不需要干!

我实际上读过这样的东西!而我根本就不这么认为。我需要把我的生命付诸实践!你也是!

底线:要模拟还是不模拟?

当我们选择模拟时,我们正在编写白盒单元测试。我们或多或少地将测试绑定到SUT的实现细节。然后,如果我们决定沿着PURE的方式走下去,并且从根本上延迟维持SUT的隔离,我们发现自己陷入了嘲笑疯狂......以及脆弱的测试的地狱。维护十个月后,您发现自己正在进行单元测试,而不是为您服务!您发现自己为一个SUT方法的实现中的单个更改重新实现了多个测试。痛苦吧?

所以,如果我们这样做,为什么不部分嘲笑SUT?为什么不让我们的生活变得更容易?我认为没有理由不这样做?你呢?

我已阅读并阅读,最后鲍勃叔叔发现了这篇文章: https://8thlight.com/blog/uncle-bob/2014/05/10/WhenToMock.html

要考虑最重要的部分:

  

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

我认为这是我告诉你的所有嘲笑疯狂的补救办法。因为我盲目地学习,所以不需要从根本上维持SUT的隔离。即使它可能在大多数时间都有效,它也可能会迫使你生活在你的私人嘲笑地狱中,把头撞在墙上。

这个小宝石的建议,是唯一有理由不部分模仿SUT的理由。事实上,这与此相反。但现在问题是,不是集成测试吗?那仍然被称为单元测试吗? UNIT在这里是什么?建筑上的重要界限?

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

回顾一下

  • 如果我们采用纯粹的隔离方式,假设SUT已经被分解成细小的部分,并且可能的外部deps最小,是否有任何理由不部分模拟SUT?为了避免过度嘲弄并使单元测试更干燥?

  • 如果我们把鲍勃叔叔的建议放在心上,只有" 模仿跨越建筑上的重要界限,而不是在这些界限之内。",那仍然是考虑单元测试?这里的单位是什么?

感谢您的阅读。

P.S。那些相反的推理或多或少来自我在该主题上发现的现有SO答案或文章。不幸的是,我目前没有链接的参考资料。

1 个答案:

答案 0 :(得分:3)

单元测试不必进行隔离单元测试,至少如果您接受Martin Fowler和Kent Beck等作者推广的definition。 Kent是JUnit的创建者,可能是TDD的主要支持者。这些家伙不做嘲笑。

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

就个人而言,我赞成使用最少或(最好)不进行模拟的集成测试。只要您的测试稳定且运行速度足够快,它们就可以了。重要的是,测试不会成为编写,维护的痛苦,更重要的是不会阻止程序员运行他们。 (这就是为什么我要避免功能 UI驱动的测试 - 它们往往很难运行。)