TDD——不能模拟的依赖关系


TDD - Dependencies that cannot be mocked

假设我有一个类:

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;
    }
}

如何单独测试?当测试这个类时,我也在测试DomDocument类

我可以传入文档对象:

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返回我在测试中测试的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. 我实际上根本没有测试这个方法,我检查的只是这个方法返回$document->saveXML()
  2. 的结果
  3. 测试知道方法的实现(它使用domdocument生成xml)
  4. 如果类被重写为使用simplexml或其他xml库,测试将失败,即使它可以产生正确的结果

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

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

我无法想象在编写实际实现之前从模拟DOMElement::createElement的测试开始。从一个对象和预期的xml开始是很自然的。

同样,我不会称DOMElement为依赖项。它是你实现的私有细节。您永远不会将DOMElement的不同实现传递给XMLSerializer的构造函数,因此没有必要在构造函数中公开它。

测试也应该作为文档。带有对象和预期xml的简单测试将是可读的。每个人都能读到它,并确定你的班级在做什么。将此与带有mock的50行测试进行比较(PhpUnit mock非常冗长)。

编辑:这里有一篇很好的论文http://www.jmock.org/oopsla2004.pdf。简而言之,它表明,除非您使用测试来驱动您的设计(查找接口),否则使用mock没有什么意义。

还有一个很好的规则

只模拟你拥有的类型

(在论文中提到),可以应用到你的例子。

正如您所提到的,如果您想加快bug的解决速度,测试隔离是一种很好的技术。然而,编写这些测试在开发和维护方面都有很大的成本。在一天结束的时候,您真正想要的是一个不需要在每次修改被测系统时都更改的测试套件。换句话说,您针对API编写测试,而不是针对其实现细节。

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

回到你的问题,你真正想测试的是在序列化器中完成的转换逻辑,不管它是如何完成的。模拟不属于您的类型是不可取的,因为对类如何与其环境交互做出武断的假设可能会在部署代码后给您带来问题。正如m1lt0n所建议的,您可以将这个类封装在一个接口中,并模拟它以进行测试。这为序列化器的实现提供了一些灵活性,但真正的问题是,你真的需要它吗?与更简单的解决方案相比有什么好处?对于第一个实现,在我看来,一个简单的输入与输出测试应该足够了("保持简单和愚蠢")。如果有一天您需要在不同的序列化器策略之间切换,只需更改设计并增加一些灵活性。

让我来解决您在代码和测试中看到的问题:

1)我实际上并没有测试该方法,我所检查的是该方法返回$document->saveXML()

的结果

没错,通过模拟DomDocument和它的方法以这种方式返回,您只需检查该方法是否将被调用(甚至不检查该方法是否返回saveXML()的结果,因为我没有看到序列化方法的断言,但只是调用它,这触发了期望为真)。

2)测试知道方法的实现(它使用domdocument生成xml)

这也是正确的,而且非常重要,因为如果方法的内部实现发生变化,即使返回正确的结果,测试也可能失败。测试应该将该方法视为一个"黑盒",只关心具有一组给定参数的方法的返回值。

3)如果类被重写为使用simplexml或其他xml库,则测试将失败,即使它可以产生正确的结果

正确,见我对(2)的评论

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

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

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

<?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的目的,但它会使使用序列化器的代码可测试,所以它是相关的