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');下一节
继续学习:代码覆盖率