TDD - 不能模拟的依赖

TDD - Dependencies that cannot be mocked

假设我有一个 class:

class XMLSerializer {
    public function serialize($object) {
        $document = new DomDocument();
        $root = $document->createElement('object');
        $document->appendChild($root);

        foreach ($object as $key => $value) {
            $root->appendChild($document->createElement($key, $value);
        }

        return $document->saveXML();
    }

    public function unserialze($xml) {
        $document = new DomDocument();
        $document->loadXML($xml);

        $root = $document->getElementsByTagName('root')->item(0);

        $object = new stdclass;
        for ($i = 0; $i < $root->childNodes->length; $i++) {
            $element = $root->childNodes->item($i);
            $tagName = $element->tagName;
            $object->$tagName = $element->nodeValue();
        }

        return $object;
    }

}

我如何单独测试它?在测试这个 class 时,我也在测试 DomDocument class

我可以传入文档对象:

class XMLSerializer {
    private $document;

    public function __construct(\DomDocument $document) {
        $this->document = $document;
    }

    public function serialize($object) {
        $root = $this->document->createElement('object');
        $this->document->appendChild($root);

        foreach ($object as $key => $value) {
            $root->appendChild($this->document->createElement($key, $value);
        }

        return $this->document->saveXML();
    }

    public function unserialze($xml) {
        $this->document->loadXML($xml);

        $root = $this->document->getElementsByTagName('root')->item(0);

        $object = new stdclass;
        for ($i = 0; $i < $root->childNodes->length; $i++) {
            $element = $root->childNodes->item($i);
            $tagName = $element->tagName;
            $object->$tagName = $element->nodeValue();
        }

        return $object;
    }

}

这似乎解决了问题,但是,现在我的测试实际上没有做任何事情。我需要制作一个模拟 DomDocument return 我正在测试的 XML:

$object = new stdclass;
$object->foo = 'bar';

$mockDocument = $this->getMock('document')
                ->expects($this->once())
                ->method('saveXML')
                ->will(returnValue('<?xml verison="1.0"?><root><foo>bar</foo></root>'));

$serializer = new XMLSerializer($mockDocument);

$serializer->serialize($object);

其中有几个问题:

  1. 我实际上根本没有测试该方法,我只是在检查该方法 return 是 $document->saveXML()
  2. 的结果
  3. 测试知道方法的实现(它使用 domdocument 生成 xml)
  4. 如果 class 被重写为使用简单的xml 或另一个 xml 库,测试将失败,即使它可能产生正确的结果

那么我可以单独测试这段代码吗?看起来我不能..是否有这种类型的依赖项的名称不能被模拟,因为它的行为本质上是被测试方法所必需的?

这是一个关于 TDD 的问题。 TDD意味着先写测试。

我无法想象在编写实际实现之前从模拟 DOMElement::createElement 的测试开始。您从一个对象开始并期望 xml.

是很自然的

此外,我不会将 DOMElement 称为依赖项。这是您实施的私人细节。您永远不会将 DOMElement 的不同实现传递给 XMLSerializer 的构造函数,因此无需在构造函数中公开它。

测试也应该作为文档。使用对象进行简单测试,预期 xml 将是可读的。每个人都可以阅读它并确定您的 class 在做什么。将此与带有模拟的 50 行测试进行比较(PhpUnit 模拟非常冗长)。

编辑: 这是一篇关于它的好论文 http://www.jmock.org/oopsla2004.pdf。 简而言之,它指出除非您使用测试来驱动您的设计(查找接口),否则使用模拟毫无意义。

还有一个很好的规则

Only Mock Types You Own

(论文中提到)可以应用于您的示例。

让我谈谈您在代码和测试中看到的 questions/problems:

1) I'm not actually testing the method at all, all I'm checking is that the method returns the result of $document->saveXML()

没错,通过以这种方式模拟 DomDocument 及其方法 return,您只需检查该方法是否会被调用(即使该方法是 return 保存的结果XML(),因为我没有看到序列化方法的断言,只是调用它,这会触发预期为真)。

2) The test is aware of the implementation of the method (it uses domdocument to generate the xml)

这也是正确的,而且非常重要,因为如果方法的内部实现发生变化,即使它 return 是正确的结果,测试也可能会失败。测试应将方法视为 'black box',仅担心具有一组给定参数的方法的 return 值。

3) The test will fail if the class is rewritten to use simplexml or another xml library, even though it could be producing the correct result

是的,请参阅我对 (2) 的评论

那么,还有什么选择呢?考虑到 XMLSerializer 的实现,DomDocument 只是 facilitates/is 实际执行序列化的助手。除此之外,该方法只是迭代对象的属性。所以 XMLSerializer 和 DomDocument 在某种程度上是不可分割的,这可能很好。

关于测试本身,我的方法是提供一个已知对象并断言序列化方法return是一个预期的xml结构(因为对象是已知的,所以结果是已知的, 也)。这样,您就不受方法的实际实现的束缚(因此使用 DomDocument 或其他东西来实际执行 XML 文档创建并不重要)。

现在,关于您提到的另一件事(注入 DomDocument),在当前的实现中没有用。为什么?因为如果你想使用另一个工具来创建 XML 文档(如你提到的简单 xml 等),你需要更改方法的主要部分。另一种实现如下:

<?php

    interface Serializer
    {
      public function serialize($object);

      public function unserialize($xml);
    }


    class DomDocumentSerializer
    {
      public function serialize($object)
      {
     // the actual implementation, same as the sample code you provide
      }

      public function unserialize($xml)
      {
     // the actual implementation, same as the sample code you provide
      }
    }

上述实现的好处是,无论何时你需要一个序列化器,你都可以对接口进行类型提示并注入任何实现,所以下次你创建一个新的 SimplexmlSerializer 实现时,你只需要通过需要(这就是依赖注入有意义的地方)序列化器作为参数的 类 的实例化,然后仅更改实现。

抱歉,最后一部分和代码,它可能有点偏离 TDD 的目的,但它会使使用序列化程序的代码可测试,所以它有点相关。

正如您所提到的,如果您想加速错误解决,测试隔离是一种很好的技术。但是,编写这些测试可能会在开发和维护方面产生重大成本。 归根结底,您真正想要的是一个不必在每次修改被测系统时都进行更改的测试套件。换句话说,您针对一个API,不反对其实施细节。

当然,有一天您可能会遇到一个难以发现的错误,需要测试隔离才能被发现,但您现在可能不需要它。因此,我建议首先测试系统的输入和输出(端到端测试)。如果有一天,您需要更多,那么,您仍然可以进行更细粒度的测试。

回到你的问题,你真正想要测试的是在序列化器中完成的转换逻辑,无论它是如何完成的。模拟您不拥有的类型不是一种选择,因为对 class 如何与其环境交互做出任意假设可能会在部署代码后导致您遇到问题。正如 m1lt0n 所建议的,您可以将此 class 封装在一个接口中,并模拟它以进行测试。这为序列化程序的实现提供了一些灵活性,但真正的问题是,你真的需要它吗? 与更简单的解决方案相比有什么好处? 对于第一个实现,在我看来,一个简单的输入与输出测试就足够了("Keep it simple and stupid")。如果有一天您需要在不同的序列化器策略之间切换,只需更改设计并增加一些灵活性。