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 协议》,转载必须注明作者和本文链接
感觉这么测试也没测到什么逻辑 :confused: