- 示例工程
- 数据库
- 教程
- 后端
【免费下载链接】sql-server-samples
Azure Data SQL Samples - Official Microsoft GitHub Repository containing code samples for SQL Server, Azure SQL, Azure Synapse, and Azure SQL Edge
本文以 sql-server-samples 仓库所携带的 Mockery 依赖(samples/development-frameworks/laravel/vendor/mockery/mockery/)为事实依据,系统讲解如何在不改动被测代码的前提下,Mock 掉通过new关键字在方法内部直接实例化的"硬依赖"对象。读完本文,你将掌握overload:前缀的实例 Mock 用法、理解其底层实现原理,并学会配合 PHPUnit 进程隔离注解写出稳定通过的单元测试。
一、什么是"硬依赖",为什么它难以 Mock
在面向对象的 PHP 代码中,依赖注入(构造器注入、Setter 注入)是提升可测试性的首选手段——依赖以参数形式传入,测试时可以轻松替换为 Mock 对象。但现实中经常遇到如下风格的代码:
<?php namespace App; class Service { function callExternalService($param) { $externalService = new Service\External(); $externalService->sendSomething($param); return $externalService->getSomething(); } }这里Service\External是在方法内部直接通过new关键字创建的,外部调用者(包括测试代码)无法在调用callExternalService()之前把真实对象替换成 Mock,这种在类内部自行实例化、无法从外部注入的依赖,就是典型的"硬依赖"(Hard Dependency)。
Mockery 针对这种场景提供了专门的解决方案:实例 Mock(Instance Mock)。通过它,可以让new \App\Service\External()这样的语句在实际执行时返回一个 Mock 对象,从而做到"零改动被测代码"地完成测试。
二、前置条件:被测代码必须使用自动加载(Autoloading)
Mock 硬依赖有一个硬性前提:被测代码必须依赖自动加载(autoloading)机制来加载类,而不是通过require/include显式引入类文件。
这一要求并非空穴来风,而是由 Mockery 的实现机制决定的:Mockery 会"顶替"真实类来响应new调用,如果代码是通过require显式加载的,真实类文件会被 PHP 直接包含进当前进程,Mockery 将无法拦截。Mockery 官方文档在 instance_mocking.rst 中同样明确指出:
这并不会阻止
require语句引入真实类并触发 PHP 致命错误,它适用于以自动加载作为主要类加载机制的场合。
在 Laravel 这类现代框架中,Composer 自动加载是标配(vendor/autoload.php),因此天然满足该前提。仓库中的 Laravel 示例项目同样遵循 Composer 依赖管理(samples/development-frameworks/laravel/composer.json、composer.lock),其中mockery/mockery正是以 Composer 包的形式被引入。
三、核心用法:overload:前缀创建实例 Mock
Mockery 通过在 mock 名称前加overload:前缀来声明一个实例 Mock,语法为:
m::mock('overload:App\Service\External');下面的测试示例演示了完整的用法——对App\Service::callExternalService()内部的new Service\External()进行拦截:
<?php namespace AppTest; use Mockery as m; class ServiceTest extends \PHPUnit_Framework_TestCase { public function testCallingExternalService() { $param = 'Testing'; $externalMock = m::mock('overload:App\Service\External'); $externalMock->shouldReceive('sendSomething') ->once() ->with($param); $externalMock->shouldReceive('getSomething') ->once() ->andReturn('Tested!'); $service = new \App\Service(); $result = $service->callExternalService($param); $this->assertSame('Tested!', $result); } }逐段拆解:
m::mock('overload:App\Service\External'):声明对App\Service\External的实例 Mock。此后在该进程中执行new \App\Service\External()时,得到的将是这个 Mock 实例,而非真实对象;shouldReceive('sendSomething')->once()->with($param):设置期望——sendSomething方法应被恰好调用一次,且参数必须为$param(即'Testing');shouldReceive('getSomething')->once()->andReturn('Tested!'):设置期望——getSomething应被调用一次,并返回'Tested!';$service = new \App\Service():被测类本身照常实例化(Service并未被 overload,仍是真实类);$this->assertSame('Tested!', $result):断言callExternalService()的返回值。由于内部的$externalService已被替换为 Mock,getSomething()返回的正是 Mock 设置的'Tested!'。
若上述期望未被满足(例如sendSomething没有被调用、调用次数不对或参数不符),Mockery 会在测试结束时抛出异常,使测试失败——这正是 Mockery"期望驱动"测试风格的核心价值:既验证行为结果,又验证交互过程。
四、源码级原理:overload:前缀在 Mockery 内部如何被处理
理解了用法之后,我们深入 Mockery 源码确认其底层实现。在仓库的 Container.php 中,mock()方法对字符串参数做前缀解析:
} elseif (is_string($arg) && substr($arg, 0, 6) == 'alias:') { $name = array_shift($args); $name = str_replace('alias:', '', $name); $builder->addTarget('stdClass'); $builder->setName($name); continue; } elseif (is_string($arg) && substr($arg, 0, 9) == 'overload:') { $name = array_shift($args); $name = str_replace('overload:', '', $name); $builder->setInstanceMock(true); $builder->addTarget('stdClass'); $builder->setName($name); continue; }关键差异一目了然:
alias:与overload:都会把 mock 命名为目标类名(setName($name)),并且以stdClass为生成基底;- 二者唯一的区别是
overload:额外调用了$builder->setInstanceMock(true),即把生成的 Mock 标记为"实例 Mock"。
实例 Mock 的语义在 instance_mocking.rst 中有明确阐述:
实例 Mock 意味着
$obj = new \MyNamespace\Foo;这样的语句实际上会生成一个 Mock 对象。它通过用实例 Mock 替换真实类来实现(与别名 Mock 类似)……这让你可以在无法简单地注入替换对象的场景下拦截实例化。
也就是说:一旦用overload:声明了实例 Mock,该进程中所有针对这个类名的new操作都会被"掉包"成 Mock 实例,这正是callExternalService()内部那句new Service\External()能被拦截的根本原因。
随后,Mockery 通过 Loader 将生成的 mock 定义加载进当前进程。仓库中的默认加载器 EvalLoader.php 实现如下:
class EvalLoader implements Loader { public function load(MockDefinition $definition) { if (class_exists($definition->getClassName(), false)) { return; } eval("?>" . $definition->getCode()); } }注意class_exists(..., false)的第二个参数false——它表示不触发自动加载,仅检查类是否已在当前进程中定义。这正是"真实类文件绝不能已被加载"这一约束的源码级体现:若真实类已经被包含进进程(类已存在),Mockery 生成的同名 mock 类将无法被eval定义,最终在 Container.php 中抛出异常:
if (class_exists($def->getClassName(), $attemptAutoload = false)) { $rfc = new \ReflectionClass($def->getClassName()); if (!$rfc->implementsInterface("Mockery\MockInterface")) { throw new \Mockery\Exception\RuntimeException( "Could not load mock {$def->getClassName()}, class already exists" ); } }即文档中所说的"class already exists" 异常。由此可以反推出一个清晰的因果链:正因为 PHP 的类加载机制对"同一类名只允许定义一次",所以 overload 的类文件一旦被真实加载,Mockery 就无法再顶替它。
五、关键难点:避免真实类被重复加载
"Mockery 能顶替真实类"依赖一个脆弱前提——在生成 mock 的那一刻,真实类尚未被加载。问题随之而来:假如我们还想在别的测试中测试App\Service\External本身,或者该类的真实文件在其他测试里已经被加载进同一进程,那么再执行m::mock('overload:App\Service\External')就会触发上述 "class already exists" 异常。
Mockery 官方文档给出的解决方案非常明确:利用 PHPUnit 的进程隔离能力,让包含 overload 类的测试在独立进程中运行,并且不保留全局状态。这样,真实类文件就不会跨越测试被重复包含到同一进程。
具体做法是在测试类上添加两个 PHPUnit 注解:
/** * @runTestsInSeparateProcesses * @preserveGlobalState disabled */@runTestsInSeparateProcesses:让该测试类中的每个测试方法在独立的 PHP 子进程中执行,从而保证每个测试进程内都是"干净"的类加载状态;@preserveGlobalState disabled:关闭全局状态保留,避免 PHPUnit 试图跨进程保存/恢复全局变量(包括已经加载的类状态),防止与 Mockery 的类顶替机制冲突。
加入注解后,完整示例变为:
<?php namespace AppTest; use Mockery as m; /** * @runTestsInSeparateProcesses * @preserveGlobalState disabled */ class ServiceTest extends \PHPUnit_Framework_TestCase { public function testCallingExternalService() { $param = 'Testing'; $externalMock = m::mock('overload:App\Service\External'); $externalMock->shouldReceive('sendSomething') ->once() ->with($param); $externalMock->shouldReceive('getSomething') ->once() ->andReturn('Tested!'); $service = new \App\Service(); $result = $service->callExternalService($param); $this->assertSame('Tested!', $result); } }运行该测试,Mockery 会让App\Service内部使用被 Mock 掉的External服务,测试正常通过。
六、权衡与注意事项
1. 性能代价:进程隔离测试运行更慢
文档明确提醒:进程隔离"有其缺点——这些测试会运行得更慢"。每次测试都要启动一个全新的 PHP 子进程,相比同进程内的普通测试有可观的开销。因此在实践中,应只为确实使用overload:的测试类开启进程隔离,不要把@runTestsInSeparateProcesses无差别加到所有测试类上。
仓库中 Mockery 自身的默认配置也印证了这一点——其 phpunit.xml.dist 中processIsolation="false",即默认不开启进程隔离,只有个别需要 overload 的测试才通过注解按需开启。
2. 一个进程内只能 overload 一次
由于"类只能定义一次",同一个 overload 目标类在整个测试进程生命周期内只能被 mock 一次。如果两个测试用例在同一进程中重复 overload 同一个类,后执行者会遭遇 "class already exists" 异常——这是"进程隔离"注解之所以必要的根本原因。
3. 自动加载是硬前提
如本文第二节所述,被测代码若使用require/include显式加载类文件,Mockery 无法保证拦截成功。使用overload:前请确认被测类及目标类都通过 Composer 自动加载。
4. 源码与测试佐证
Mockery 自身的测试套件中大量使用了overload:语法来验证该特性,例如 tests/Mockery/ContainerTest.php 中的一系列用例:
$m = $this->container->mock('overload:MyNamespace\MyClass4');这些用例既是该特性的回归测试,也是学习overload:各种边界写法的现成参考资料(包括overload:\MyNamespace\MyClass11这类带前导反斜杠的写法)。如果你需要深入了解实例 Mock 的更多细节,可直接阅读 instance_mocking.rst 与 phpunit_integration.rst。
七、总结
Mocking 硬依赖是单元测试中绕不开的实战场景,Mockery 通过overload:前缀 + 实例 Mock 机制,在不修改被测代码的前提下,让new关键字创建的依赖对象"变身"为可控的 Mock。其背后的完整技术链条为:
overload:前缀被 Container.php 解析为"实例 Mock"标记;- 生成的 Mock 类由 EvalLoader.php 以
eval方式注入进程,前提是目标类尚未被加载; - PHPUnit 的
@runTestsInSeparateProcesses+@preserveGlobalState disabled注解保证每个 overload 测试拥有独立的类加载环境,避免 "class already exists" 冲突; - 代价是进程级开销带来的测试速度下降,故应精准、克制地使用。
掌握了这套方法,你就能在 Laravel 等自动加载环境下,对那些"内部new出来的硬依赖"进行彻底的行为验证,让难以测试的遗留代码重新回到可控的测试覆盖之下。
- 示例工程
- 数据库
- 教程
- 后端
【免费下载链接】sql-server-samples
Azure Data SQL Samples - Official Microsoft GitHub Repository containing code samples for SQL Server, Azure SQL, Azure Synapse, and Azure SQL Edge
相关推荐
SQL Server Samples 仓库中的 Laravel 示例:Mockery PHP 模拟对象框架使用指南
SQL Server Samples 仓库中的 Laravel 示例:Mockery PHP 模拟对象框架使用指南 Mockery 是一个简洁而灵活的 PHP
示例工程数据库教程后端sql-server-samples 之 Laravel 示例中的 Mockery 入门:用 Simple Example 吃透 PHP Mock 测试
sql server samples 之 Laravel 示例中的 Mockery 入门:用 Simple Example 吃透 PHP Mock 测试 导读
示例工程数据库教程后端Lama Cleaner:一条命令完成 AI 图片擦除,去水印、物体消除、老照片修复都在这里
Lama Cleaner:一条命令完成 AI 图片擦除,去水印、物体消除、老照片修复都在这里 照片里的水印、闯入画面的路人、想擦掉的文字,平时打开 Photos
人工智能AI 应用计算机视觉图像处理媒体生成后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考