【问题标题】:Using mock objects in PHP inside functions that instantiate their own objects在 PHP 中在实例化自己的对象的函数中使用模拟对象
【发布时间】:2011-07-13 23:27:20
【问题描述】:

我一直在研究如何将单元测试覆盖率添加到用 PHP 编写的大型现有代码库中。静态类和可实例化类中的许多函数都会调用库或实例化对象,以获得与内存缓存和数据库的连接。它们通常看起来像这样:

public function getSomeData() {
    $key = "SomeMemcacheKey";
    $cache = get_memcache();

    $results = $cache->get($key);
    if (!$results) {
        $database = new DatabaseObject();
        $sql = "SELECT * from someDatabase.someTable";
        $results = $database->query($sql);

        $cache->set($key, $results);
    }

    return $results;
}

我和我的同事目前正在尝试通过 PHPUnit 为我们正在编写的一些新类实现覆盖。我试图找到一种方法,以隔离的方式为我们现有代码库中的函数创建单元测试,这些函数类似于上面的伪代码,但没有成功。

我在 PHPUnit 文档中看到的示例都依赖于类中的某些方法,通过该方法可以将模拟对象附加到它,例如: $objectBeingTested->attach($mockObject); 我查看了 SimpleUnit,在那里看到了同样的东西,模拟对象通过其构造函数传递到类中。这不会为实例化自己的数据库对象的函数留下太多空间。

有什么方法可以模拟出这类调用吗?我们可以使用另一个单元测试框架吗?还是我们将来必须更改我们使用的模式以促进单元测试?

我想做的是能够在运行测试时用模拟类替换整个类。例如,可以将 DatabaseObject 类替换为模拟类,并且在测试期间实例化的任何时候,它实际上都是模拟版本的实例。

我的团队一直在谈论重构我们在新代码中访问数据库和 memcache 的方法,可能使用单例。我想如果我们以这样一种方式编写单例,它自己的实例可以被一个模拟对象替换,那可能会有所帮助......

这是我第一次涉足单元测试。如果我做错了,请说出来。 :)

谢谢。

【问题讨论】:

  • 似乎没有办法,因为紧密的情侣。建议看看 Symfony DI。

标签: php database unit-testing mocking phpunit


【解决方案1】:

只是添加到@Ezku 答案(+1,我也会说的所有内容)到最终代码可能看起来像这样(使用Dependency injection

public function __construct(Memcached $mem, DatabaseObject $db) {
    $this->mem = $mem;
    $this->db = $db;
}

public function getSomeData() {
    $key = "SomeMemcacheKey";
    $cache = $this->mem;

    $results = $cache->get($key);
    if (!$results) {
        $database = $this->db;
        $sql = "SELECT * from someDatabase.someTable";
        $results = $database->query($sql);

        $cache->set($key, $results);
    }

    return $results;
}

这样就很容易创建模拟对象并将它们传递到代码中。

您想要这样做有几个原因(除了创建可测试的代码)。一旦它使您的代码更容易更改(想要不同的数据库?传入不同的数据库对象而不是更改 DatabaseObject 中的代码。

This Blog post 告诉您为什么静态方法不好,但在代码中使用“new”运算符与使用 $x = StaticStuff::getObject(); 几乎是一回事,因此它也适用于此。

另一个参考可以是:Why singletons are bad for testable code,因为它涉及相同的点。

如果您已经编写了更多代码,则有一些方法可以在不立即更改所有内容的情况下实现这些想法。

像这样的可选依赖注入:

public function __construct(Memcached $mem = null, DatabaseObject $db = null) {
    if($mem === null) { $mem = new DefaultCacheStuff(); }
    if($db === null) { $db = new DefaultDbStuff(); }
    $this->mem = $mem;
    $this->db = $db;
}

public function getSomeData() {
    $key = "SomeMemcacheKey";
    $cache = $this->mem;

    $results = $cache->get($key);
    if (!$results) {
        $database = $this->db;
        $sql = "SELECT * from someDatabase.someTable";
        $results = $database->query($sql);

        $cache->set($key, $results);
    }

    return $results;
}

或使用“setter 注入”:

public function __construct(Memcached $mem = null, DatabaseObject $db = null) {
    $this->mem = new DefaultCacheStuff();
    $this->db = new DefaultDbStuff();
}

public function setDatabaseObject(DatabaseObject $db) { 
    $this->db = $db;
}

public function setDatabaseObject(Memcached $mem) { 
    $this->mem = $mem;
}

public function getSomeData() {
    $key = "SomeMemcacheKey";
    $cache = $this->mem;

    $results = $cache->get($key);
    if (!$results) {
        $database = $this->db;
        $sql = "SELECT * from someDatabase.someTable";
        $results = $database->query($sql);

        $cache->set($key, $results);
    }

    return $results;
}

另外还有一些叫做dependency injection containers 的东西可以让你把你所有的反对意见都从容器中拉出来,但是因为它使测试变得有点困难(恕我直言),它只有在做得很好的情况下才能帮助你不建议从一个开始,而只是使用普通的“依赖注入”来创建可测试的代码。

【讨论】:

    【解决方案2】:

    这并没有为实例化自己的数据库对象的函数留下太多空间。

    正是如此。您正在描述一种被认为应该避免的编程风格,因为它会导致无法测试的代码。如果您的代码明确地依赖于某些外部性并且不以任何方式抽象它们,那么您将只能在这些外部性完好无损的情况下测试该代码。正如你所说,你不能模拟函数为自己创建的东西。

    为了使您的代码可测试,最好应用依赖注入:将您希望可模拟的依赖从外部传递到单元的上下文中。这通常被视为首先会带来更好的类设计。

    也就是说,您可以做一些事情来启用可模拟性而无需显式注入:使用 PHPUnit 的模拟对象工具,您甚至可以在被测单元中覆盖方法。考虑这样的重构。

    public function getSomeData() {
        $key = "SomeMemcacheKey";
        $cache = $this->getMemcache();
    
        $results = $cache->get($key);
        if (!$results) {
            $database = $this->getDatabaseObject();
            $sql = "SELECT * from someDatabase.someTable";
            $results = $database->query($sql);
    
            $cache->set($key, $results);
        }
    
        return $results;
    }
    
    public function getMemcache() {
        return get_memcache();
    }
    
    public function getDatabaseObject() {
        return new DatabaseObject();
    }
    

    现在,如果您正在测试 getSomeData(),您可以模拟 getMemcache() 和 getDatabaseObject()。下一个重构步骤是将 memcache 和数据库对象注入到类中,这样它就不会显式依赖 get_memcache() 或 DatabaseObject 类。这将避免在被测单元本身中模拟方法的需要。

    【讨论】:

      【解决方案3】:

      在理想情况下,您将有时间重构所有遗留代码以使用依赖注入或类似的东西。但在现实世界中,你经常不得不处理你已经被处理过的手。

      PHPUnit 的作者 Sebastian Bergmann 写了一个 test helpers extension,它允许您使用回调和重命名函数来覆盖 new 运算符。这些将允许您在测试期间对代码进行修补,直到您可以将其重构为更可测试。当然,使用它编写的测试越多,撤消它的工作就越多。

      注意:the Test-Helper extension is supersededhttps://github.com/krakjoe/uopz

      【讨论】:

      • +1。但是,您是否同意仅当您真的知道自己在做什么并且选项不可行时才应将其用作最后的手段?
      • @edorian - 完全同意。但是当你不得不处理你无法控制的遗留代码或认为依赖注入是邪恶的老板时,很高兴知道你有选择。 :)
      • 感谢您提供的信息。我非常想使用其他答案中推荐的模式,但很高兴知道有一种方法可以在我们庞大的遗留代码库中完成这项工作。我认为我们可能会继续对遗留代码使用这种组合,并为我们正在编写的更可测试的新代码使用新模式。
      【解决方案4】:

      我会建议一个非常简单的依赖注入器。它们可以非常容易地用于遗留代码中的新功能。您还可以轻松地重构您发布的此类代码。

      我建议一个简单的,就像我最近为类似场合开发的一样: https://packagist.org/packages/tflori/dependency-injector

      在一些引导文件或配置文件中你可以这样写:

      <?php
      
      DI::set('database', function() { return new DatabaseObject(); });
      DI::set('memcache', function() { return get_memcache(); });
      

      然后你的函数可以如下所示:

      <?php
      
      function getSomeData() {
          $key = "SomeMemcacheKey";
          $cache = DI::get('memcache');
      
          $results = $cache->get($key);
          if (!$results) {
              $database = DI::get('database');
              $sql = "SELECT * from someDatabase.someTable";
              $results = $database->query($sql);
      
              $cache->set($key, $results);
          }
      
          return $results;
      }
      

      要测试代码,您可以像这样编写 testClass:

      <?php
      
      use PHPUnit\Framework\TestCase;
      
      class GetSomeDataTest extends TestCase {
          public function tearDown() {
              Mockery::close();
              parent::tearDown();
          }
      
          public function testReturnsCached() {
              $mock = Mockery::mock('memcache_class');
              $mock->shouldReceive('get')->once()->with('SomeMemcacheKey')->andReturn('anyResult');
              DI::set('memcache', $mock);
      
              $result = getSomeData();
      
              $this->assertSame('anyResult', $result);
          }
      
          public function testQueriesDatabase() {
              $memcache = Mockery::mock('memcache_class');
              $memcache->shouldReceive('get')->andReturn(null);
              $memcache->shouldIgnoreMissing();
              DI::set('memcache', $memcache);
      
              $database = Mockery::mock(DatabaseObject::class);
              $database->shouldReceive('query')->once()->andReturn('fooBar');
              DI::set('database', $database);
      
              $result = getSomeData();
      
              $this->assertSame('fooBar', $result);
          }
      }
      

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2011-06-27
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2018-03-10
        相关资源
        最近更新 更多