PHP Mockery 静态方法测试问题与解决方案

问题描述

在使用 PHPUnit 和 Mockery 对包含静态方法调用的 PHP 类进行单元测试时,遇到了一个棘手的问题。具体来说,我们在测试一个调用静态方法的类时,Mockery 的模拟似乎在多个测试方法之间产生了干扰。

涉及的代码

首先,让我们看一下被测试的类:

namespace App\Service;

class TestService
{
    public function list()
    {
        return StaticService::list();
    }
}

class StaticService
{
    public static function list(): int
    {
        return time();
    }
}

初始测试代码

我们最初的测试代码如下:

namespace Tests\Unit;

use App\Service\TestService;
use Mockery;
use PHPUnit\Framework\TestCase;

class TestServiceTest extends TestCase
{
    protected TestService $testService;

    protected function setUp(): void
    {
        parent::setUp();
        $this->testService = new TestService();
    }

    protected function tearDown(): void
    {
        Mockery::close();
        parent::tearDown();
    }

    public function testList()
    {
        $testService = new TestService();
        $result = $testService->list();
        $this->assertIsInt($result);
    }

    public function testLists()
    {
        $testService = new TestService();
        $mockedTime = 1625097600;
        Mockery::mock('alias:App\Service\StaticService')
            ->shouldReceive('list')
            ->once()
            ->andReturn($mockedTime);

        $result = $testService->list();
        $this->assertEquals($mockedTime, $result);
    }
}

testList 模拟功能测试,testLists 模拟单元测试

当运行这些测试时,我们遇到了以下问题:

  • 如果 testList 在 testLists 之前运行,testLists 会失败,报错:
    bash-5.0# composer test -- --filter=TestServiceTest
    > co-phpunit -c phpunit.xml --colors=always --testdox --coverage-html ./coverage '--filter=TestServiceTest'
    PHPUnit 9.6.19 by Sebastian Bergmann and contributors.
    Warning:       No code coverage driver available
    Service (Tests\Unit\Service)
    ✔ List
    ✘ Lists
     ┐
     ├ Mockery\Exception\RuntimeException: Could not load mock App\Service\StaticService, class already exists
     │
     ╵ /opt/www/vendor/mockery/mockery/library/Mockery/Container.php:401
     ╵ /opt/www/vendor/mockery/mockery/library/Mockery.php:477
     ╵ /opt/www/tests/Unit/TestServiceTest.php:46
     ╵ /opt/www/vendor/hyperf/testing/co-phpunit:40
  • 如果 testLists 在 testList 之前运行,testList 会失败,报错:
    composer test -- --filter=TestServiceTest
    > co-phpunit -c phpunit.xml --colors=always --testdox --coverage-html ./coverage '--filter=TestServiceTest'
    PHPUnit 9.6.19 by Sebastian Bergmann and contributors.
    Warning:       No code coverage driver available
    Service (Tests\Unit\Service)
    ✔ Lists
    ✘ List
     ┐
     ├ Error: Call to a member function __call() on null
     │
     ╵ /opt/www/app/Service/TestService.php:14/opt/www/tests/Unit/TestServiceTest.php:52/opt/www/vendor/hyperf/testing/co-phpunit:40

这表明 Mockery 的静态方法模拟在测试方法之间存在某种持续效应,导致测试相互干扰。

解决方法

使用 @runInSeparateProcess 注解

我们首先尝试使用 PHPUnit 的 @runInSeparateProcess@preserveGlobalState disabled 注解:

/**
 * @runInSeparateProcess
 * @preserveGlobalState disabled
 */
public function testLists()
{
    $testService = new TestService();
        $mockedTime = 1625097600;
        Mockery::mock('alias:App\Service\StaticService')
            ->shouldReceive('list')
            ->once()
            ->andReturn($mockedTime);

        $result = $testService->list();
        $this->assertEquals($mockedTime, $result);
}

然后执行测试:

 composer test -- --filter=TestServiceTest
> co-phpunit -c phpunit.xml --colors=always --testdox --coverage-html ./coverage '--filter=TestServiceTest'
PHPUnit 9.6.19 by Sebastian Bergmann and contributors.
Warning:       No code coverage driver available
Service (Tests\Unit\Service)
 ✔ List
 ✔ Lists
Time: 00:00.467, Memory: 18.00 MB
OK (2 tests, 2 assertions)
bash-5.0# composer test -- --filter=TestServiceTest
> co-phpunit -c phpunit.xml --colors=always --testdox --coverage-html ./coverage '--filter=TestServiceTest'
PHPUnit 9.6.19 by Sebastian Bergmann and contributors.
Warning:       No code coverage driver available

问题2 数据库操作的处理

假如我们在 TestService list 方法中执行 数据库操作该如何处理?
我们修改 TestService 类如下:

public function list()
{
     $list = StaticService::list();
      Db::beginTransaction();
 try {
        //写入班组信息
      CompanyLaborTeam::query()->insert($demoData = []);
      Db::commit();
         return $list;
  } catch (\Exception $e) {
        Db::rollBack();
         return false;  
 }
}

我们在 List 方法中模拟数据库事务操作然后执行测试代码:

composer test -- --filter=TestServiceTest
> co-phpunit -c phpunit.xml --colors=always --testdox --coverage-html ./coverage '--filter=TestServiceTest'
PHPUnit 9.6.19 by Sebastian Bergmann and contributors.
Warning:       No code coverage driver available
Service (Tests\Unit\Service)
 ✘ List
   ┐
   ├ Failed asserting that Hyperf\Database\Model\Collection Object &000000001c188f120000000006c8a136 ('items' => Array &0 ()) is of type "int".
   │
   ╵ /opt/www/tests/Unit/TestServiceTest.php:41
   ╵ /opt/www/vendor/hyperf/testing/co-phpunit:40
   ┴
 ✘ Lists
   ┐
   ├ PHPUnit\Framework\Exception: PHP Fatal error:  Uncaught Swoole\Error: API must be called in the coroutine in /opt/www/vendor/hyperf/database/src/Connectors/Connector.php:106
   ├ Stack trace:
   ├ #0 /opt/www/vendor/hyperf/database/src/Connectors/Connector.php(106): PDO->__construct()#1 /opt/www/vendor/hyperf/database/src/Connectors/Connector.php(51): Hyperf\Database\Connectors\Connector->createPdoConnection()#2 /opt/www/vendor/hyperf/database/src/Connectors/MySqlConnector.php(32): Hyperf\Database\Connectors\Connector->createConnection()#3 /opt/www/vendor/hyperf/database/src/Connectors/ConnectionFactory.php(204): Hyperf\Database\Connectors\MySqlConnector->connect()#4 [internal function]: Hyperf\Database\Connectors\ConnectionFactory->Hyperf\Database\Connectors\{closure}()#5 /opt/www/vendor/hyperf/database/src/Connection.php(609): call_user_func()#6 /opt/www/vendor/hyperf/database/src/Connection.php(634): Hyperf\Database\Connection->getPdo()#7 /opt/www/vendor/hyperf/database/src/Connection.php(1000): Hyperf\Database\Connection->getReadPdo()#8 /opt/www/vendor/ in /opt/www/vendor/hyperf/database/src/Connectors/Connector.php on line 106
   ├ Fatal error: Uncaught Swoole\Error: API must be called in the coroutine in /opt/www/vendor/hyperf/database/src/Connectors/Connector.php:106
   ├ Stack trace:
   ├ #0 /opt/www/vendor/hyperf/database/src/Connectors/Connector.php(106): PDO->__construct()#1 /opt/www/vendor/hyperf/database/src/Connectors/Connector.php(51): Hyperf\Database\Connectors\Connector->createPdoConnection()#2 /opt/www/vendor/hyperf/database/src/Connectors/MySqlConnector.php(32): Hyperf\Database\Connectors\Connector->createConnection()#3 /opt/www/vendor/hyperf/database/src/Connectors/ConnectionFactory.php(204): Hyperf\Database\Connectors\MySqlConnector->connect()#4 [internal function]: Hyperf\Database\Connectors\ConnectionFactory->Hyperf\Database\Connectors\{closure}()#5 /opt/www/vendor/hyperf/database/src/Connection.php(609): call_user_func()#6 /opt/www/vendor/hyperf/database/src/Connection.php(634): Hyperf\Database\Connection->getPdo()#7 /opt/www/vendor/hyperf/database/src/Connection.php(1000): Hyperf\Database\Connection->getReadPdo()#8 /opt/www/vendor/ in /opt/www/vendor/hyperf/database/src/Connectors/Connector.php on line 106
   │
   ╵ /opt/www/vendor/hyperf/testing/co-phpunit:40

从输出来看 hyperf 的数据库操作必须要在 协程运行,所以我们也需要在 testLists 方法中也模拟数据库的操作
我们修改 testLists 测试方法如下:


    /**
     * @runInSeparateProcess
     * @preserveGlobalState disabled
     */
    public function testLists()
    {
        $mockedTime = 1625097600;

        // 模拟 StaticService
        $staticServiceMock = Mockery::mock('alias:App\Service\StaticService');
        $staticServiceMock->shouldReceive('list')->once()->andReturn($mockedTime);

        // 模拟数据库连接
        $connectionMock = Mockery::mock(ConnectionInterface::class);
        $connectionMock->shouldReceive('beginTransaction')->andReturn(null);
        $connectionMock->shouldReceive('commit')->andReturn(null);

        // 模拟查询构建器
        $queryBuilderMock = Mockery::mock('Hyperf\Database\Query\Builder');
        $queryBuilderMock->shouldReceive('insert')->once()->andReturn(true);

        // 模拟 Db Facade
        $dbMock = Mockery::mock('alias:Hyperf\DbConnection\Db');
        $dbMock->shouldReceive('beginTransaction')->andReturn(null);
        $dbMock->shouldReceive('commit')->andReturn(null);
        $dbMock->shouldReceive('connection')->andReturn($connectionMock);

        // 模拟 CompanyLaborTeam 模型
        $companyLaborTeamMock = Mockery::mock('alias:App\Model\CompanyLaborTeam');
        $companyLaborTeamMock->shouldReceive('query')->andReturn($queryBuilderMock);

        // 模拟容器
        $containerMock = Mockery::mock(ContainerInterface::class);
        $containerMock->shouldReceive('get')
            ->with('db.connection')
            ->andReturn($connectionMock);

        ApplicationContext::setContainer($containerMock);

        $result = $this->testService->list();

        $this->assertEquals($mockedTime, $result);
    }

然后执行测试:

composer test -- --filter=TestServiceTest
> co-phpunit -c phpunit.xml --colors=always --testdox --coverage-html ./coverage '--filter=TestServiceTest'
PHPUnit 9.6.19 by Sebastian Bergmann and contributors.
Warning:       No code coverage driver available
Service (Tests\Unit\Service)
 ✔ List
 ✔ Lists
Time: 00:00.471, Memory: 20.00 MB
OK (2 tests, 2 assertions)

最佳实践建议

  • 尽量避免在代码中直接使用静态方法,特别是那些难以测试的方法。
  • 如果必须使用静态方法,考虑将其封装在一个普通方法中,以便于测试。例如:
namespace App\Service;

class TestService
{
    public function list()
    {
        return $this->getlist();
    }
    public function getlist()
    {
        return StaticService::list();
    }
}

class StaticService
{
    public static function list(): int
    {
        return time();
    }
}
本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 2

感觉这么测试也没测到什么逻辑 :confused:

3周前 评论
微加加的朋友 (楼主) 3周前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!