Skip to content

Mock 对象

概述

Mock 对象(模拟对象)是单元测试中用于隔离被测代码与外部依赖的关键技术。通过创建 Mock 对象替代真实的数据库连接、HTTP 客户端、第三方服务等依赖,可以确保测试仅关注被测单元的逻辑,而不受外部因素的影响。PHPUnit 提供了内置的 Mock 对象生成器,同时也可以使用 Prophecy 作为替代方案。

前置知识

阅读本节前,建议先了解:PHPUnit 单元测试 的基础使用方法。

基础概念

Test Double 类型

Test Double(测试替身)是一个统称,包含多种类型:

类型说明是否验证交互
Dummy(假对象)仅用于填充参数,不参与断言
Stub(桩对象)返回预设值
Mock(模拟对象)验证方法是否被调用
Spy(间谍对象)记录调用信息供后续验证
Fake(伪造对象)有实际逻辑但简化实现

Stub vs Mock

  • Stub:关注"返回什么" -- 预设方法的返回值
  • Mock:关注"是否调用" -- 验证方法是否被调用及调用参数

详细说明

PHPUnit 内置 Mock

createMock

php
<?php
declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

/**
 * 被测试的接口
 */
interface UserRepositoryInterface
{
    public function find(int $id): ?array;
    public function save(array $data): bool;
    public function delete(int $id): bool;
    public function findByEmail(string $email): ?array;
}

/**
 * 使用 createMock 创建 Mock 对象
 */
class CreateMockTest extends TestCase
{
    public function testFindUserReturnsData(): void
    {
        // 创建 Mock 对象
        $repository = $this->createMock(UserRepositoryInterface::class);

        // 配置 Stub:find(42) 返回预设数据
        $repository->method('find')
            ->with(42)
            ->willReturn(['id' => 42, 'name' => 'Alice']);

        // 使用 Mock
        $result = $repository->find(42);

        // 断言返回值
        $this->assertSame(['id' => 42, 'name' => 'Alice'], $result);
    }

    public function testSaveIsCalledOnce(): void
    {
        $repository = $this->createMock(UserRepositoryInterface::class);

        // 配置 Mock:验证 save 被调用一次
        $repository->expects($this->once())
            ->method('save')
            ->with($this->isType('array'))
            ->willReturn(true);

        // 执行
        $repository->save(['id' => 1, 'name' => 'Bob']);
    }

    public function testDeleteIsNeverCalled(): void
    {
        $repository = $this->createMock(UserRepositoryInterface::class);

        // 验证 delete 从未被调用
        $repository->expects($this->never())
            ->method('delete');

        // 仅调用 find,不调用 delete
        $repository->method('find')->willReturn(['id' => 1]);
        $repository->find(1);
    }
}

方法配置器

php
<?php
declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;

class MockMethodConfigTest extends TestCase
{
    private MockObject $mock;

    protected function setUp(): void
    {
        $this->mock = $this->createMock(UserRepositoryInterface::class);
    }

    /**
     * 返回固定值
     */
    public function testReturnValue(): void
    {
        $this->mock->method('find')->willReturn(['id' => 1]);
        $this->assertSame(['id' => 1], $this->mock->find(1));
    }

    /**
     * 根据参数返回不同值
     */
    public function testReturnDifferentValues(): void
    {
        $this->mock->method('find')
            ->willReturnMap([
                [1, ['id' => 1, 'name' => 'Alice']],
                [2, ['id' => 2, 'name' => 'Bob']],
                [3, null],
            ]);

        $this->assertSame(['id' => 1, 'name' => 'Alice'], $this->mock->find(1));
        $this->assertSame(['id' => 2, 'name' => 'Bob'], $this->mock->find(2));
        $this->assertNull($this->mock->find(3));
    }

    /**
     * 返回回调结果
     */
    public function testReturnCallback(): void
    {
        $this->mock->method('find')
            ->willReturnCallback(function (int $id): array {
                return ['id' => $id, 'name' => "User {$id}"];
            });

        $this->assertSame(['id' => 5, 'name' => 'User 5'], $this->mock->find(5));
    }

    /**
     * 从队列中依次返回值
     */
    public function testReturnOnConsecutiveCalls(): void
    {
        $this->mock->method('save')
            ->willReturnOnConsecutiveCalls(
                true,
                false,
                true
            );

        $this->assertTrue($this->mock->save([]));
        $this->assertFalse($this->mock->save([]));
        $this->assertTrue($this->mock->save([]));
    }

    /**
     * 抛出异常
     */
    public function testThrowException(): void
    {
        $this->mock->method('find')
            ->willThrowException(new \RuntimeException('数据库错误'));

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('数据库错误');
        $this->mock->find(1);
    }

    /**
     * 返回自身(链式调用)
     */
    public function testReturnSelf(): void
    {
        // 当方法应返回 $this 时
        $builder = $this->createMock(\App\Builder\QueryBuilder::class);
        $builder->method('where')->willReturnSelf();
        $builder->method('orderBy')->willReturnSelf();
        $builder->method('limit')->willReturnSelf();

        // 支持链式调用
        $result = $builder->where('id', 1)
            ->orderBy('name')
            ->limit(10);
    }
}

调用次数验证

php
<?php
declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

class InvocationExpectationsTest extends TestCase
{
    /**
     * 验证调用次数
     */
    public function testExactInvocationCount(): void
    {
        $mock = $this->createMock(\stdClass::class);
        $mock->expects($this->exactly(3))
            ->method('someMethod');

        $mock->someMethod();
        $mock->someMethod();
        $mock->someMethod();
    }

    /**
     * 至少调用 N 次
     */
    public function testAtLeastInvocations(): void
    {
        $mock = $this->createMock(\stdClass::class);
        $mock->expects($this->atLeast(2))
            ->method('someMethod');

        $mock->someMethod();
        $mock->someMethod();
        $mock->someMethod(); // 3 次也可以
    }

    /**
     * 最多调用 N 次
     */
    public function testAtMostInvocations(): void
    {
        $mock = $this->createMock(\stdClass::class);
        $mock->expects($this->atMost(2))
            ->method('someMethod');

        $mock->someMethod();
        $mock->someMethod();
    }

    /**
     * 任意次数(含 0 次)
     */
    public function testAnyInvocation(): void
    {
        $mock = $this->createMock(\stdClass::class);
        $mock->expects($this->any())
            ->method('someMethod')
            ->willReturn('result');
    }
}

MockObject 接口

php
<?php
declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;

class MockObjectInterfaceTest extends TestCase
{
    /**
     * MockObject 常用方法
     */
    public function testMockObjectMethods(): void
    {
        $mock = $this->createMock(\stdClass::class);

        // __phpunit_getInvocationRecorder() - 获取调用记录
        // __phpunit_setReturnValueGenerator() - 设置返回值生成器
        // __phpunit_hasMatchers() - 是否有配置的匹配器

        // 配置方法
        $mock->expects($this->once())
            ->method('doSomething')
            ->with(
                $this->equalTo(42),           // 精确匹配
                $this->stringContains('test'), // 字符串包含
                $this->isType('int'),          // 类型匹配
                $this->anything(),             // 任意值
                $this->callback(fn($v) => $v > 0) // 自定义匹配
            );
    }

    /**
     * 参数匹配器
     */
    public function testParameterMatchers(): void
    {
        $mock = $this->createMock(\stdClass::class);

        $mock->expects($this->once())
            ->method('process')
            ->with(
                $this->equalTo('expected'),    // 等于
                $this->identicalTo($value),     // 全等于
                $this->greaterThan(10),         // 大于
                $this->lessThanOrEqual(100),    // 小于等于
                $this->isTrue(),                // 为 true
                $this->isFalse(),               // 为 false
                $this->isNull(),                // 为 null
                $this->isInstanceOf(\stdClass::class), // 实例类型
                $this->arrayHasKey('name'),     // 数组包含键
                $this->logicalAnd(             // 逻辑与
                    $this->greaterThan(0),
                    $this->lessThan(100)
                ),
                $this->logicalOr(              // 逻辑或
                    $this->equalTo('a'),
                    $this->equalTo('b')
                ),
                $this->logicalNot(             // 逻辑非
                    $this->identicalTo(null)
                )
            );
    }
}

Prophecy

Prophecy 是 PHPUnit 内置的另一种 Mock 方案(基于 Prophecy 库),提供了更灵活的 API:

php
<?php
declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use App\Repositories\UserRepositoryInterface;

class ProphecyMockTest extends TestCase
{
    /**
     * 使用 Prophecy 创建 Mock
     */
    public function testProphecyStub(): void
    {
        // 创建 Prophet
        $prophet = $this->prophesize(UserRepositoryInterface::class);

        // 配置 Stub
        $prophet->find(42)->willReturn(['id' => 42, 'name' => 'Alice']);
        $prophet->findByEmail('test@example.com')->willReturn(null);

        // 揭示 Mock 对象
        $mock = $prophet->reveal();

        // 使用
        $result = $mock->find(42);
        $this->assertSame(['id' => 42, 'name' => 'Alice'], $result);
    }

    /**
     * 使用 Prophecy 验证调用
     */
    public function testProphecyMock(): void
    {
        $prophet = $this->prophesize(UserRepositoryInterface::class);

        // 配置预期调用
        $prophet->save(['name' => 'Alice'])->shouldBeCalledTimes(1);
        $prophet->delete(42)->shouldNotBeCalled();

        $mock = $prophet->reveal();

        // 执行
        $mock->save(['name' => 'Alice']);

        // Prophecy 自动验证(在 tearDown 中)
    }

    /**
     * 使用 Prophecy 的参数预测
     */
    public function testProphecyArgumentPrediction(): void
    {
        $prophet = $this->prophesize(UserRepositoryInterface::class);

        // 使用通配符
        $prophet->find(\Prophecy\Argument::type('int'))
            ->willReturn(['id' => 1]);

        // 使用条件预测
        $prophet->save(
            \Prophecy\Argument::that(function (array $data): bool {
                return isset($data['name']) && strlen($data['name']) >= 2;
            })
        )->willReturn(true);

        $mock = $prophet->reveal();
    }
}

Stub vs Mock 选择指南

php
<?php
declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

class StubVsMockGuideTest extends TestCase
{
    /**
     * 使用 Stub:当只需要预设返回值,不关心调用
     */
    public function testWithStub(): void
    {
        $mock = $this->createMock(\App\Repositories\UserRepositoryInterface::class);

        // Stub: 预设返回值
        $mock->method('find')->willReturn(['id' => 1]);

        $service = new UserService($mock);
        $user = $service->getUser(1);

        // 仅断言返回值
        $this->assertSame(['id' => 1], $user);
    }

    /**
     * 使用 Mock:当需要验证方法是否被调用
     */
    public function testWithMock(): void
    {
        $mock = $this->createMock(\App\Repositories\UserRepositoryInterface::class);

        // Mock: 验证调用
        $mock->expects($this->once())
            ->method('save')
            ->with($this->callback(function (array $data): bool {
                return $data['name'] === 'Alice';
            }));

        $service = new UserService($mock);
        $service->createUser('Alice', 'alice@example.com');
    }
}

实战示例

Mock 数据库连接

php
<?php
declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;

class DatabaseMockTest extends TestCase
{
    /**
     * Mock PDO 语句
     */
    public function testQueryReturnsData(): void
    {
        $stmt = $this->createMock(\PDOStatement::class);
        $stmt->expects($this->once())
            ->method('execute')
            ->with(['id' => 1])
            ->willReturn(true);

        $stmt->expects($this->once())
            ->method('fetchAll')
            ->with(\PDO::FETCH_ASSOC)
            ->willReturn([
                ['id' => 1, 'name' => 'Alice'],
            ]);

        $pdo = $this->createMock(\PDO::class);
        $pdo->expects($this->once())
            ->method('prepare')
            ->with("SELECT * FROM users WHERE id = :id")
            ->willReturn($stmt);

        // 使用 Mock PDO
        $repository = new UserRepository($pdo);
        $users = $repository->findById(1);

        $this->assertCount(1, $users);
        $this->assertSame('Alice', $users[0]['name']);
    }
}

Mock HTTP 客户端

php
<?php
declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;

class HttpClientMockTest extends TestCase
{
    public function testFetchExternalApi(): void
    {
        // Mock Guzzle HTTP 客户端
        $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class);
        $response->method('getStatusCode')->willReturn(200);
        $response->method('getBody')
            ->willReturn(json_encode(['status' => 'ok', 'data' => [1, 2, 3]]));

        $client = $this->createMock(\GuzzleHttp\Client::class);
        $client->expects($this->once())
            ->method('get')
            ->with('https://api.example.com/data')
            ->willReturn($response);

        $service = new ExternalApiService($client);
        $result = $service->fetchData();

        $this->assertSame([1, 2, 3], $result['data']);
    }
}

抽象类 Mock

php
<?php
declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

abstract class AbstractParser
{
    abstract protected function parseLine(string $line): array;

    public function parseFile(string $content): array
    {
        $lines = explode("\n", trim($content));
        return array_values(array_filter(
            array_map(fn(string $line) => $this->parseLine($line), $lines),
        ));
    }
}

class AbstractParserTest extends TestCase
{
    public function testParseFile(): void
    {
        // Mock 抽象类中的受保护方法
        $parser = $this->getMockForAbstractClass(AbstractParser::class);

        $parser->expects($this->exactly(3))
            ->method('parseLine')
            ->willReturnMap([
                ['name:alice', ['name' => 'alice']],
                ['name:bob', ['name' => 'bob']],
                ['name:charlie', ['name' => 'charlie']],
            ]);

        $result = $parser->parseFile("name:alice\nname:bob\nname:charlie");

        $this->assertCount(3, $result);
        $this->assertSame(['name' => 'alice'], $result[0]);
    }
}

注意事项

Mock 滥用问题

php
<?php
declare(strict_types=1);

// 错误:过度 Mock 导致测试脱离实际
class OverMockedTest extends TestCase
{
    public function testUserFlow(): void
    {
        // 每一层都 Mock,测试变得毫无意义
        $db = $this->createMock(PDO::class);
        $logger = $this->createMock(LoggerInterface::class);
        $cache = $this->createMock(CacheInterface::class);
        $event = $this->createMock(EventDispatcherInterface::class);

        // ... 这不是单元测试,这是在验证 Mock 的配置
    }

    // 正确:只 Mock 外部依赖,保留内部逻辑
    public function testUserCreationLogic(): void
    {
        $repository = $this->createMock(UserRepositoryInterface::class);
        // 只 Mock 数据层,保留 Service 的业务逻辑
    }
}

Final 类和方法的 Mock

php
<?php
declare(strict_types=1);

// PHPUnit 默认不能 Mock final 类和 final 方法
// 解决方案:

// 1. 避免使用 final(最佳实践)
// 2. 使用接口代替具体类
// 3. 使用 Prophecy(某些场景下可行)

// PHP 8.0+ 可以使用 attributes 配置
#[\AllowDynamicProperties]
class DynamicClass
{
    public function __set(string $name, mixed $value): void
    {
        // 动态属性
    }
}

最佳实践

1. 优先使用接口 Mock

php
<?php
// 好:基于接口 Mock
$mock = $this->createMock(UserRepositoryInterface::class);

// 不好:基于具体类 Mock
$mock = $this->createMock(ConcreteUserRepository::class);

2. 最小化 Mock 范围

只 Mock 必要的方法,不要对所有方法进行 Mock。

3. 测试行为而非实现

php
<?php
// 好:测试行为
$service->expects($this->once())->method('sendNotification');

// 不好:测试内部实现细节
$mock->expects($this->exactly(3))->method('formatData');

下一节

继续学习:代码覆盖率

参考链接