PHP单元测试Mock怎么用?从入门到实战,一篇讲透
目录导读
- 为什么你需要理解Mock?
- Mock的核心概念与术语澄清
- PHPUnit中Mock的三种创建方式
- 实战:Mock方法与返回值控制
- 进阶:Mock约束与参数验证
- 常见陷阱与最佳实践
- FAQ高频问答
- 从会用走向精通
为什么你需要理解Mock?
在真实项目中,你的代码往往依赖外部服务:数据库、REST API、文件系统或第三方SDK,如果每次跑单元测试都要连接这些真实资源,测试会变得缓慢、脆弱且不可控,Mock(模拟对象)技术应运而生——它让你用替身对象替换真实依赖,并精确控制其行为。

打个比方:测试汽车刹车系统,你不需要真的开到马路上,而是在台架上用假轮胎、假路面模拟,Mock就是测试中的“假路面”。
Mock的核心概念与术语澄清
| 术语 | 含义 | 举例 |
|---|---|---|
| Stub(桩) | 只返回预设数据,不验证调用 | $mock->method('getPrice')->willReturn(100) |
| Mock(模拟) | 桩 + 验证方法是否被调用 | ->expects($this->once())->method('save') |
| Spy(间谍) | 记录实际调用,事后断言 | 部分框架支持,PHPUnit用Mock实现 |
关键区分:Stub关注“返回什么”,Mock还要关注“怎么被调用的”。
PHPUnit中Mock的三种创建方式
PHPUnit框架提供了灵活且链式调用的API,以下是标准用法:
1 基础Mock(无参数)
$mock = $this->createMock(UserRepository::class);
2 完整MockBuilder(精细控制)
$mock = $this->getMockBuilder(UserRepository::class)
->disableOriginalConstructor() // 禁用原构造函数
->onlyMethods(['findByEmail']) // 只mock指定方法
->addMethods(['sendWelcomeEmail']) // 添加不存在的方法
->getMock();
3 上下文标记(PHPUnit 8+)
$mock = $this->createMock(LoggerInterface::class);
差异总结:createMock是快捷方式,自动禁用构造函数;getMockBuilder提供更多定制选项,适合复杂场景。
实战:Mock方法与返回值控制
假设我们有一个OrderService,依赖PaymentGateway:
class PaymentGateway {
public function charge($amount) {
// 真实调用第三方API
}
}
class OrderService {
private $gateway;
public function __construct(PaymentGateway $gateway) {
$this->gateway = $gateway;
}
public function placeOrder($amount) {
$this->gateway->charge($amount);
}
}
测试代码:
public function testOrderPlaced() {
$gatewayMock = $this->createMock(PaymentGateway::class);
$gatewayMock->expects($this->once())
->method('charge')
->with(99.99) // 精确参数
->willReturn(true); // 设置返回值
$service = new OrderService($gatewayMock);
$result = $service->placeOrder(99.99);
$this->assertTrue($result);
}
多返回值:
$mock->method('getStatus')
->willReturnOnConsecutiveCalls('pending', 'paid', 'shipped');
异常抛出:
$mock->method('charge')
->willThrowException(new \Exception('Payment failed'));
进阶:Mock约束与参数验证
1 使用约束匹配参数
$mock->expects($this->once())
->method('search')
->with(
$this->stringContains('keyword'),
$this->greaterThan(50)
);
其他常用约束:
$this->anything()— 任何值$this->arrayHasKey('name')— 数组键$this->callback(fn($value) => ...)— 自定义逻辑$this->count(5)— 数组长度
2 验证调用次数
// 精确一次 $this->once() // 至少两次 $this->atLeast(2) // 从未调用 $this->never() // 调用顺序(谨慎使用) $this->at(0)
3 回调动态响应
$mock->method('process')
->willReturnCallback(function ($input) {
return $input * 2;
});
常见陷阱与最佳实践
❌ 陷阱1:Mock私有方法
PHPUnit默认无法mock私有/受保护方法,解决方案:将依赖通过接口注入,或使用setAccessible反射(仅限特殊场景)。
❌ 陷阱2:过度Mock
如果测试中Mock了所有类,测试就变成了“测试Mock本身”。建议:只Mock真正外部依赖(IO、第三方库、随机数生成器),自己的逻辑用真的实现。
❌ 陷阱3:忽略with()参数
不写with()时,Mock接受任意参数,但一旦写了with(),参数必须完全匹配,建议始终明确参数。
✅ 最佳实践清单
- 每个测试方法最少断言逻辑,最多一个Mock行为定义
- 使用
@covers注解指明测试的类/方法 - 对Mock的调用次数要求严格,利于暴露问题
- 在
setUp()中创建公共Mock,减少重复代码 - 使用
willReturn()而不是willReturnValue()(后者已弃用)
FAQ高频问答
Q1:Mock和Stub到底选哪个?
A:如果你只需要“返回预设数据”,用Stub;如果你还需要“验证是否被正确调用”,用Mock,PHPUnit中createMock()生成的是Mock对象,你可以通过expects()让它同时扮演Stub角色——Mock是Stub的超集。
Q2:怎么Mock一个静态方法?
A:PHPUnit原生无法直接mock静态方法(除非使用uopz扩展或PHPUnit 9+的MockFile),更优雅的方案是:引入门面模式(Facade)或依赖注入容器,将静态调用包裹在非静态方法中。
Q3:Mock对象能测试异常吗?
A:可以,用willThrowException()模拟异常,配合expectException()断言异常抛出的类型和消息,这在测试错误处理逻辑时非常有用。
Q4:为什么我的Mock调用没生效?
A:最常见原因是——你Mock的是真实类,但真实类中方法被声明为final或private,PHPUnit不会警告,但Mock直接返回null,检查方法可见性,确保非final。
Q5:createMock和getMockBuilder何时用谁?
A:90%场景用createMock,它默认禁用原构造函数、克隆、自动加载,只有当需要addMethods(给类添加原本不存在的方法)或灵活设置构造函数参数时,才升级到getMockBuilder。
从会用走向精通
Mock技术是PHP单元测试的核心武器,掌握了本文的内容,你已经能:
- ✅ 区分Stub/Mock/Spy的概念
- ✅ 熟练使用三种Mock创建方式
- ✅ 用
willReturn/willThrowException控制行为 - ✅ 用约束和调用次数验证互动
- ✅ 避开常见陷阱,写出干净可靠的测试
Mock不是目的——它只是测试孤独代码的桥梁,如果一段代码需要惊人的Mock配置才能测试,请审视你的设计是否过度耦合。
下一步行动:
- 在现有项目中对一个Service类写一个Mock测试
- 尝试把过度Mock的测试重构为小型真实集成测试(如使用内存数据库)
- 阅读PHPUnit官方文档的“Test Doubles”章节
你的单元测试越贴近真实行为,你的代码就越能自然进化,Mock用得多了,你会自然领悟到依赖注入和接口设计本身的意义——测试驱动设计(TDD)的真正魅力。
(文章结束)