使用 Mockery 进行模拟与存根
使用灵活的测试替身隔离单元
使用 Mockery 进行模拟与存根 是 CoddyKit 上的免费 PHP Academy 课时。 这是第 2 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 PHP Academy 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 PHP Academy 课程共包含 4 节课。
为什么需要专用模拟库
测试框架自带测试替身接口,但专用模拟库提供了更流畅、更具表现力的语法,以及测试框架模拟对象所缺少的能力——部分模拟对象、间谍、灵活的参数匹配和有序期望。对于协作密集型代码,专用模拟库通常具有更好的可读性。本课将介绍各种测试替身,以及何时使用每一种。
composer require --dev mockery/mockery存根、模拟对象与间谍
精确的术语可以避免令人困惑的测试:
- 存根——返回预设值;您断言的是状态(结果)。
- 模拟对象——对调用设定期望;您断言的是行为(它是否被正确调用)。未满足的期望会使测试失败。
- 间谍——记录调用,并允许您事后进行断言。
经验法则:查询优先使用存根,命令优先使用模拟对象或间谍。
使用允许返回值的存根
使用 Mockery::mock()和 allows()(或 shouldReceive()->andReturn())提供预设返回值,而不必断言调用确实发生过。这里的网关存根提供一个已知汇率,以便我们独立测试计算过程。
<?php
use Mockery;
use PHPUnit\Framework\TestCase;
final class ConverterTest extends TestCase
{
public function test_converts_using_rate(): void
{
$rates = Mockery::mock(RateGateway::class);
$rates->allows()->rateFor('USD', 'EUR')->andReturn(0.9);
$sut = new Converter($rates);
self::assertSame(90.0, $sut->convert(100.0, 'USD', 'EUR'));
Mockery::close();
}
}
使用期望调用的模拟对象
当交互本身就是契约时——例如“邮件必须恰好发送一次”——请使用 expects()或 shouldReceive()->once()。模拟库会在 Mockery::close()期间验证期望;未满足的调用会使测试失败。
<?php
use Mockery;
$mailer = Mockery::mock(Mailer::class);
$mailer->expects()
->send(Mockery::type(Message::class))
->once();
$service = new SignupService($mailer);
$service->register('ada@example.com');
Mockery::close(); // fails here if send() was never called
参数匹配器
模拟库的匹配器可以根据需要设定宽松或严格的期望:
Mockery::any()——任意值。Mockery::type('string')或类名——类型检查。Mockery::on(fn($a) => ...)——自定义谓词。Mockery::capture($var)——捕获参数,以便稍后断言。
<?php
use Mockery;
$repo = Mockery::mock(UserRepo::class);
$repo->expects()
->save(Mockery::on(fn(User $u) => $u->isActive()))
->once()
->andReturnTrue();
返回序列与动态返回值
您可以为多次调用编排多个返回值,也可以使用 andReturnUsing()根据参数计算返回值。这可以模拟重试逻辑、分页或有状态的协作者。
<?php
use Mockery;
$api = Mockery::mock(HttpClient::class);
// First call throws, second succeeds (retry test):
$api->shouldReceive('get')
->twice()
->andThrow(new \RuntimeException('timeout'))
->andReturn('{"ok":true}');
// Or derive the return from the input:
$api->shouldReceive('echo')
->andReturnUsing(fn(string $in) => strtoupper($in));
间谍:事后断言
间谍会颠倒顺序:先执行操作,再进行断言。Mockery::spy()会记录调用;之后可以使用 shouldHaveReceived()查询这些调用。当您不希望预先设定的期望使准备阶段变得杂乱时,间谍可以让准备—执行—断言结构保持清晰。
<?php
use Mockery;
$logger = Mockery::spy(Logger::class);
$service = new PaymentService($logger);
$service->charge(500);
// Assertions happen AFTER the action:
$logger->shouldHaveReceived('info')
->with(Mockery::pattern('/charged 500/'))
->once();
Mockery::close();
部分模拟对象
有时您希望使用真实对象,但只重写其中一个方法——这就是部分模拟对象。模拟库的 makePartial()会调用真实方法,只有您设定期望的方法例外。请谨慎使用:过度依赖部分模拟对象,通常说明一个类承担了过多职责。
<?php
use Mockery;
$report = Mockery::mock(Report::class)->makePartial();
// Only stub the slow/external method; the rest runs for real
$report->shouldReceive('fetchRawData')->andReturn(['a', 'b', 'c']);
// real summarize() runs, using the stubbed data
$summary = $report->summarize();
不要模拟不属于您的对象
测试的一项核心原则:避免直接模拟第三方类。它们的接口可能发生变化,而您的模拟对象却会在不知不觉中偏离现实。相反,请将它们包装在您自己的接口后面,并模拟该接口。这样,模拟对象验证的是您的契约,而适配器集成测试则验证真实绑定。
<?php
interface PaymentGateway { // you own this
public function charge(int $cents, string $token): string;
}
final class StripeGateway implements PaymentGateway {
public function __construct(private \Stripe\StripeClient $client) {}
public function charge(int $cents, string $token): string {
return $this->client->paymentIntents->create([/* ... */])->id;
}
}
// Tests mock PaymentGateway, never \Stripe\StripeClient directly.
始终关闭并验证调用次数
有两项必须完成的操作:
- 在
tearDown()中调用Mockery::close()(或使用MockeryPHPUnitIntegration特征),这样才能真正验证期望并清理全局状态。 - 使用明确的次数(
once()、times(n)、never())——含糊的模拟对象会让错误漏网。
<?php
use PHPUnit\Framework\TestCase;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
final class OrderServiceTest extends TestCase
{
use MockeryPHPUnitIntegration; // auto-calls Mockery::close()
public function test_does_not_refund_paid_orders(): void
{
$gw = \Mockery::mock(PaymentGateway::class);
$gw->shouldReceive('refund')->never();
// ... exercise SUT ...
}
}
有序期望
有时调用顺序属于契约的一部分——必须先调用 beginTransaction(),再调用 commit()。模拟库的 ordered()会强制执行顺序;如果调用顺序错误,测试就会失败。只有在顺序确实重要时才使用它;过度规定顺序会让测试变得脆弱。
<?php
use Mockery;
$tx = Mockery::mock(Transaction::class);
$tx->shouldReceive('begin')->once()->ordered();
$tx->shouldReceive('commit')->once()->ordered();
$service = new TransferService($tx);
$service->run();
Mockery::close(); // fails if commit() happened before begin()
快速检查
什么情况下使用存根,什么情况下使用模拟对象?
总结
您已经掌握了模拟库中的测试替身:
- 查询使用存根(
allows);命令使用模拟对象(expects);事后断言使用间谍。 - 参数匹配器(
type、on、capture)可以调节严格程度。 - 返回序列和
andReturnUsing可以模拟有状态或动态的协作者。 - 部分模拟对象可以重写单个方法;请谨慎使用。
- 不要模拟不属于您的对象——请将第三方对象包装在自己的接口后面。
- 始终调用
Mockery::close(),并断言明确的调用次数。
下一步:集成测试和功能测试。
常见问题解答
「使用 Mockery 进行模拟与存根」课时是免费的吗?
是的 — 「使用 Mockery 进行模拟与存根」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 PHP Academy 课程的其余内容,请升级到 CoddyKit PRO。 PHP Academy 课程共包含 4 节课。
「使用 Mockery 进行模拟与存根」这节课中我会学到什么?
使用灵活的测试替身隔离单元 你通过在浏览器中直接运行的动手代码来练习 PHP Academy,全天候 AI 导师会在你学习这节课的过程中回答你的问题。
学习 PHP Academy 需要有经验吗?
无需任何先前经验。CoddyKit 上的 PHP Academy 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 2 节课,共 4 节。
「使用 Mockery 进行模拟与存根」课时需要多长时间?
大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。
我能在这节 PHP Academy 课中编写并运行代码吗?
能。每节 PHP Academy 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。
此课程中的所有课时
- 测试驱动开发工作流
- 使用 Mockery 进行模拟与存根
- 集成测试与功能测试
- 使用 Infection 进行变异测试