You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

PHPUnit中Mock复用异常:多次创建Mock返回结果不符合预期

单元测试Mock复用导致返回值异常的解决方案

问题场景

在同一个PHPUnit测试方法内,多次调用自定义Mock生成方法,期望通过参数控制Mock方法的返回值,但实际第二次调用始终返回第一次的结果,不符合预期。

复现代码

class UpdateListener
{
    public function handleStatusChange(UpdateUpdated $event): void
    {
        // 检查$event的各种条件,修改$candidate->successful_update_at
    }
}

class CandidateTest extends TestCase
{
    public function testSuccessfulUpdateTimestampManagement(): void
    {
        $updateListener = new UpdateListener();

        // 场景1:传入true,验证设置时间戳
        $updateEvent = $this->mockUpdateEvent(UpdateEvent::class, true);
        $updateListener->handleStatusChange($updateEvent);
        $this->assertEquals('2024-05-19', $candidate->successful_update_at->format('Y-m-d'));

        // 场景2:传入false,期望属性为null
        $updateEvent = $this->mockUpdateEvent(UpdateEvent::class, false);
        $updateListener->handleStatusChange($updateEvent);
        $this->assertNull($candidate->successful_update_at);
    }

    private function mockUpdateEvent(string $class, bool $findsPreviousUpdate): UpdateEvent
    {
        $mockUpdate = \Mockery::mock(Update::class);
        $mockUpdate->makePartial();

        $previousSuccessfulUpdate = new Update();
        $previousSuccessfulUpdate->register_date = '2024-05-19';

        $mockQueryBuilder = \Mockery::mock(Builder::class);

        // 预期根据参数返回不同结果,但未生效
        $mockQueryBuilder->shouldReceive('getResult')->andReturn(
            $findsPreviousUpdate ? $previousSuccessfulUpdate : null
        );

        $mockUpdate->shouldReceive('query')->andReturn($mockQueryBuilder);

        return new $class($mockUpdate);
    }
}

原因分析

问题核心是测试缺乏隔离性:

  1. 同一个测试方法内共享了$candidate对象的状态,第一次调用已修改属性值,第二次调用的状态依赖前一次结果;
  2. Mockery的全局容器会跟踪所有创建的Mock及对应的预期,即使新建Mock对象,部分场景下仍会复用同类Mock的旧预期,导致返回值不符合当前参数设置;
  3. andReturn是静态绑定返回值,一旦设置就会持续生效,直到预期被清除。

解决方案

1. 拆分测试方法(推荐)

将两个测试场景拆分为独立的测试方法,利用PHPUnit的setUp/tearDown机制,每次测试前重置Mock和被测试对象的状态,完全隔离测试场景。这是单元测试的最佳实践,能避免绝大多数隐性问题。

class CandidateTest extends TestCase
{
    private UpdateListener $updateListener;
    private Candidate $candidate;

    protected function setUp(): void
    {
        parent::setUp();
        $this->updateListener = new UpdateListener();
        // 每次测试新建干净的Candidate实例
        $this->candidate = new Candidate();
    }

    public function testSuccessfulUpdateSetsTimestamp(): void
    {
        $updateEvent = $this->mockUpdateEvent(UpdateEvent::class, true);
        $this->updateListener->handleStatusChange($updateEvent);
        $this->assertEquals('2024-05-19', $this->candidate->successful_update_at->format('Y-m-d'));
    }

    public function testUnsuccessfulUpdateLeavesTimestampNull(): void
    {
        $updateEvent = $this->mockUpdateEvent(UpdateEvent::class, false);
        $this->updateListener->handleStatusChange($updateEvent);
        $this->assertNull($this->candidate->successful_update_at);
    }

    // 原mockUpdateEvent方法保持不变
}

2. 使用动态返回值(同方法内兼容方案)

如果必须在同一个测试方法内完成多个场景验证,将andReturn替换为andReturnUsing,通过闭包动态计算返回值,确保每个Mock实例的返回逻辑独立。

修改mockUpdateEvent中的Mock设置部分:

$mockQueryBuilder->shouldReceive('getResult')
    ->andReturnUsing(function () use ($findsPreviousUpdate, $previousSuccessfulUpdate) {
        return $findsPreviousUpdate ? $previousSuccessfulUpdate : null;
    });

这种方式无需依赖调用顺序,完全根据参数动态返回,比硬编码队列更灵活。

3. 手动重置Mock容器(不推荐)

在创建新Mock前调用Mockery::resetContainer()清除所有已有的Mock和预期,但会影响整个测试方法内的其他Mock,破坏测试内的依赖关系,仅作为临时应急方案:

private function mockUpdateEvent(string $class, bool $findsPreviousUpdate): UpdateEvent
{
    // 清除全局Mock容器
    \Mockery::resetContainer();
    
    $mockUpdate = \Mockery::mock(Update::class);
    $mockUpdate->makePartial();

    $previousSuccessfulUpdate = new Update();
    $previousSuccessfulUpdate->register_date = '2024-05-19';

    $mockQueryBuilder = \Mockery::mock(Builder::class);

    $mockQueryBuilder->shouldReceive('getResult')->andReturn(
        $findsPreviousUpdate ? $previousSuccessfulUpdate : null
    );

    $mockUpdate->shouldReceive('query')->andReturn($mockQueryBuilder);

    return new $class($mockUpdate);
}

总结

  • 优先选择拆分测试方法,遵循单元测试的隔离性原则,每个测试只验证单一场景,代码更清晰且不易出错;
  • 若需在同方法内处理多场景,使用andReturnUsing实现动态返回值,避免硬编码队列的局限性;
  • 尽量避免手动重置Mock容器,以免引入新的隐性问题。

内容的提问来源于stack exchange,提问作者Kristians

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.23 20:24:50