在PHP中使用模拟对象,在实例化自己的对象的函数中

时间:2011-03-12 08:10:33

标签: php database unit-testing mocking phpunit

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

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类可以替换为mock类,并且在测试期间实例化它时,它实际上是模拟版本的实例。

我的团队一直在讨论重构我们在新代码中访问数据库和内存缓存的方法,也许是使用单例。我想如果我们以这样的方式编写单例,那么它本身的实例可以用模拟对象替换,那会有所帮助......

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

感谢。

4 个答案:

答案 0 :(得分:12)

只是添加到@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;
}

使用它可以很容易地创建模拟对象并将它们传递给代码。

除了创建可测试代码之外,还有几个原因可以解决这个问题。一旦它使你的代码更容易改变(想要不同的db?传递一个不同的db对象而不是改变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 injection”:

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的东西可以让你把所有的异议都放在一边,把所有的东西从那个容器里拉出来,但是因为它让测试变得更加困难(imho),它只会帮助你真正做到好吧,我不建议从一个开始,而只是使用正常的“依赖注入”来创建可测试的代码。

答案 1 :(得分:7)

  

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

准确地说。您正在描述一种被认为是一种可以避免的编程风格,因为它会导致不可测试的代码。如果您的代码明确依赖于某些外部因素,并且不以任何方式对它们进行抽象,那么您将只能在这些外部性完整的情况下测试该代码。如你所说,你不能模仿功能为自己创造的东西。

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

也就是说,有些事情可以在没有显式注入的情况下启用可模拟性:使用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类没有明确的依赖性。这样可以避免在被测单元中对模拟方法的需求。

答案 2 :(得分:5)

在一个完美的世界中,您将有时间重构所有遗留代码以使用依赖注入或类似的东西。但在现实世界中,你经常需要处理你曾经处理过的手。

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

  

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

答案 3 :(得分:0)

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

我建议像我最近为类似场合开发的一个简单的: 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);
    }
}