2024年3月,我在维护一个Laravel 10 + PHP 8.2的电商项目。支付回调接口依赖外部支付网关SDK(版本2.1.0),每次跑测试都要真实调用支付宝沙箱。测试环境网络延迟平均800ms,一个包含10个测试用例的类跑完要8秒。更坑的是,沙箱偶尔返回500,导致CI流水线随机失败。我花了3天重构测试,用Mock彻底干掉外部依赖,测试耗时从8秒降到0.2秒。
单元测试的核心原则是:快。一个测试用例超过100ms就是失败。但现实是:
解决方案只有一个:Mock。用模拟对象替换真实依赖,让测试只测你的代码逻辑,不测外部系统。
PHPUnit 11.0(2024年2月发布)提供了三种Mock方式。我用同一个场景(支付服务类PaymentService,依赖外部GatewayClient)做了对比测试。
最简洁的方式,自动Mock所有方法,返回默认值(null、0、空数组)。
// PHP 8.2, PHPUnit 11.0
use PHPUnit\Framework\TestCase;
use App\Services\PaymentService;
use App\Clients\GatewayClient;
class PaymentServiceTest extends TestCase
{
public function testProcessPaymentSuccess(): void
{
$gatewayMock = $this->createMock(GatewayClient::class);
$gatewayMock->method('charge')->willReturn(['status' => 'success', 'transaction_id' => 'TXN123']);
$service = new PaymentService($gatewayMock);
$result = $service->processPayment(100.00, 'USD');
$this->assertEquals('completed', $result['status']);
}
}
需要精细控制时使用,比如禁用原构造函数、只Mock部分方法。
// PHP 8.2, PHPUnit 11.0
class PaymentServiceTest extends TestCase
{
public function testProcessPaymentWithCustomConstructor(): void
{
$gatewayMock = $this->getMockBuilder(GatewayClient::class)
->disableOriginalConstructor()
->onlyMethods(['charge'])
->getMock();
$gatewayMock->expects($this->once())
->method('charge')
->with(100.00, 'USD')
->willReturn(['status' => 'success']);
$service = new PaymentService($gatewayMock);
$result = $service->processPayment(100.00, 'USD');
$this->assertEquals('completed', $result['status']);
}
}
PHPUnit 11.0标记Prophecy为废弃,将在12.0移除。性能最差,语法怪异。
// PHP 8.2, PHPUnit 11.0 - 不推荐使用
class PaymentServiceTest extends TestCase
{
public function testProcessPaymentProphecy(): void
{
$gatewayProphecy = $this->prophesize(GatewayClient::class);
$gatewayProphecy->charge(100.00, 'USD')->willReturn(['status' => 'success']);
$gatewayMock = $gatewayProphecy->reveal();
$service = new PaymentService($gatewayMock);
$result = $service->processPayment(100.00, 'USD');
$this->assertEquals('completed', $result['status']);
}
}
测试环境:MacBook Pro M1, 16GB RAM, PHP 8.2.15, PHPUnit 11.0.0。每个方案跑1000次测试,取平均值。
| 方案 | 平均耗时(ms) | 内存占用(MB) | 代码行数 | 推荐度 |
|---|---|---|---|---|
| createMock | 0.12 | 0.8 | 5 | ★★★★★ |
| getMockBuilder | 0.15 | 1.2 | 8 | ★★★★☆ |
| Prophecy | 0.28 | 2.1 | 7 | ★☆☆☆☆ |
结论:createMock比getMockBuilder快23%,比Prophecy快57%。日常开发无脑用createMock,需要禁用构造函数或只Mock部分方法时用getMockBuilder。
下面是一个完整的测试类,覆盖正常、异常、边界情况。
// tests/Unit/Services/PaymentServiceTest.php
// PHP 8.2, PHPUnit 11.0, Laravel 10
namespace Tests\Unit\Services;
use PHPUnit\Framework\TestCase;
use App\Services\PaymentService;
use App\Clients\GatewayClient;
use App\Exceptions\PaymentException;
class PaymentServiceTest extends TestCase
{
private PaymentService $service;
private GatewayClient|\PHPUnit\Framework\MockObject\MockObject $gatewayMock;
protected function setUp(): void
{
parent::setUp();
$this->gatewayMock = $this->createMock(GatewayClient::class);
$this->service = new PaymentService($this->gatewayMock);
}
public function testProcessPaymentSuccess(): void
{
$this->gatewayMock->method('charge')
->willReturn(['status' => 'success', 'transaction_id' => 'TXN123']);
$result = $this->service->processPayment(100.00, 'USD');
$this->assertEquals('completed', $result['status']);
$this->assertEquals('TXN123', $result['transaction_id']);
}
public function testProcessPaymentFailure(): void
{
$this->gatewayMock->method('charge')
->willReturn(['status' => 'failed', 'error' => 'insufficient_funds']);
$result = $this->service->processPayment(50.00, 'USD');
$this->assertEquals('failed', $result['status']);
$this->assertEquals('insufficient_funds', $result['error']);
}
public function testProcessPaymentThrowsException(): void
{
$this->gatewayMock->method('charge')
->willThrowException(new \Exception('Gateway timeout'));
$this->expectException(PaymentException::class);
$this->expectExceptionMessage('Payment processing failed');
$this->service->processPayment(100.00, 'USD');
}
public function testProcessPaymentWithZeroAmount(): void
{
$this->gatewayMock->method('charge')
->willReturn(['status' => 'success', 'transaction_id' => 'TXN000']);
$result = $this->service->processPayment(0.00, 'USD');
$this->assertEquals('completed', $result['status']);
}
public function testProcessPaymentWithLargeAmount(): void
{
$this->gatewayMock->method('charge')
->willReturn(['status' => 'success', 'transaction_id' => 'TXN999']);
$result = $this->service->processPayment(999999.99, 'USD');
$this->assertEquals('completed', $result['status']);
}
}
PHP 8.0+支持final类,PHPUnit默认无法Mock。解决方案:使用Mockery库或PHPUnit的disableOriginalConstructor配合反射。
// PHP 8.2, PHPUnit 11.0
// 假设GatewayClient是final类
final class GatewayClient {
public function charge(float $amount, string $currency): array { ... }
}
// 方案1:使用Mockery(需要composer require mockery/mockery)
use Mockery;
$gatewayMock = Mockery::mock(GatewayClient::class);
$gatewayMock->shouldReceive('charge')->andReturn(['status' => 'success']);
// 方案2:使用PHPUnit的getMockBuilder + disableOriginalConstructor
$gatewayMock = $this->getMockBuilder(GatewayClient::class)
->disableOriginalConstructor()
->onlyMethods(['charge'])
->getMock();
私有方法不应该直接Mock。正确做法:测试公有方法,私有方法作为内部实现细节。如果非要测,用反射。
// PHP 8.2, PHPUnit 11.0
class PaymentService {
private function validateAmount(float $amount): bool {
return $amount > 0 && $amount < 1000000;
}
public function processPayment(float $amount, string $currency): array {
if (!$this->validateAmount($amount)) {
throw new \InvalidArgumentException('Invalid amount');
}
// ...
}
}
// 测试私有方法(不推荐,但有时需要)
$reflection = new \ReflectionMethod(PaymentService::class, 'validateAmount');
$reflection->setAccessible(true);
$result = $reflection->invoke($this->service, 100.00);
$this->assertTrue($result);
PHPUnit原生不支持Mock静态方法。使用Mockery::mock('alias:' . ClassName::class)。
// PHP 8.2, PHPUnit 11.0, Mockery 1.6
use Mockery;
class PaymentService {
public function processPayment(float $amount): array {
$response = GatewayClient::charge($amount); // 静态调用
return $response;
}
}
// 测试
$gatewayMock = Mockery::mock('alias:' . GatewayClient::class);
$gatewayMock->shouldReceive('charge')->with(100.00)->andReturn(['status' => 'success']);
$service = new PaymentService();
$result = $service->processPayment(100.00);
$this->assertEquals('completed', $result['status']);
很多Builder模式类有链式调用。PHPUnit支持willReturnSelf()。
// PHP 8.2, PHPUnit 11.0
class QueryBuilder {
public function where(string $field, $value): self { return $this; }
public function orderBy(string $field, string $direction): self { return $this; }
public function get(): array { return []; }
}
// 测试
$builderMock = $this->createMock(QueryBuilder::class);
$builderMock->method('where')->willReturnSelf();
$builderMock->method('orderBy')->willReturnSelf();
$builderMock->method('get')->willReturn([['id' => 1]]);
场景: 2023年11月,我Mock了Laravel的User模型,结果测试通过了,但生产环境报错。因为Mock返回的对象没有Eloquent的save()方法。
教训: 只Mock外部依赖(API客户端、第三方SDK、文件系统),不要Mock框架核心类(模型、请求、响应)。框架类用真实实例,或者用Laravel提供的Mockery门面。
// 错误做法
$userMock = $this->createMock(User::class);
$userMock->method('save')->willReturn(true);
// 正确做法:用工厂创建真实模型
$user = User::factory()->create();
parent::setUp()场景: 重写setUp()时没调父类方法,导致PHPUnit的Mock计数器不工作,expects($this->once())永远不报错。
// 错误
protected function setUp(): void
{
$this->gatewayMock = $this->createMock(GatewayClient::class);
}
// 正确
protected function setUp(): void
{
parent::setUp(); // 必须调用
$this->gatewayMock = $this->createMock(GatewayClient::class);
}
void方法但没返回场景: 一个方法返回void,但Mock时用了willReturn(),PHPUnit不报错但行为诡异。
// 错误
$loggerMock->method('log')->willReturn(true); // log()返回void
// 正确
$loggerMock->method('log'); // 不需要willReturn
__call魔术方法场景: Laravel的Eloquent模型大量使用__call(如whereName())。直接Mock模型会触发__call,导致意外行为。
// 错误
$userMock = $this->createMock(User::class);
$userMock->method('whereName')->willReturnSelf(); // 不会触发__call
// 正确:使用Laravel的Mockery门面
$userMock = \Mockery::mock(User::class);
$userMock->shouldReceive('whereName')->andReturnSelf();
new关键字场景: 类内部new了一个对象,无法直接Mock。需要重构代码,使用依赖注入或工厂模式。
// 错误:无法Mock
class PaymentService {
public function process(): void {
$client = new GatewayClient(); // 硬编码,无法Mock
}
}
// 正确:依赖注入
class PaymentService {
public function __construct(private GatewayClient $client) {}
public function process(): void {
$this->client->charge();
}
}
// 或者使用工厂
class PaymentService {
public function __construct(private GatewayClientFactory $factory) {}
public function process(): void {
$client = $this->factory->create();
$client->charge();
}
}
除了Mock,还有几个技巧能显著提升测试速度:
-d xdebug.mode=off,耗时减少40%--parallel参数,利用多核CPUphpunit --cache-result,只跑变更过的测试# 禁用Xdebug跑测试
php -d xdebug.mode=off vendor/bin/phpunit
# 并行测试(需要安装paratest)
composer require brianium/paratest --dev
./vendor/bin/paratest --processes=4
# 缓存测试结果
phpunit --cache-result-file=.phpunit.cache
Mock是单元测试的核心技能。记住三条原则:
createMock,需要精细控制时用getMockBuilder按照本文的方案,你的测试速度能从秒级降到毫秒级,CI流水线再也不会因为网络波动而失败。
专注技术分享与实战