0Pricing
PHP Academy · 课时

使用 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 反馈 — 无需本地设置。

此课程中的所有课时

  1. 测试驱动开发工作流
  2. 使用 Mockery 进行模拟与存根
  3. 集成测试与功能测试
  4. 使用 Infection 进行变异测试
← 返回 PHP Academy