PHPUnit单元测试与Mock实战避坑

2026-07-16 22 min read 1
PHPUnit单元测试与Mock实战避坑

真实场景:一个支付回调接口的测试噩梦

2024年3月,我在维护一个Laravel 10 + PHP 8.2的电商项目。支付回调接口依赖外部支付网关SDK(版本2.1.0),每次跑测试都要真实调用支付宝沙箱。测试环境网络延迟平均800ms,一个包含10个测试用例的类跑完要8秒。更坑的是,沙箱偶尔返回500,导致CI流水线随机失败。我花了3天重构测试,用Mock彻底干掉外部依赖,测试耗时从8秒降到0.2秒。

问题:为什么你的单元测试慢如蜗牛?

单元测试的核心原则是:。一个测试用例超过100ms就是失败。但现实是:

  • 测试依赖数据库:每次setUp都要建表、插数据,耗时200ms+
  • 测试依赖HTTP API:网络延迟不可控,500ms起步
  • 测试依赖文件系统:IO操作拖慢整体
  • 测试依赖时间:sleep()让测试变慢

解决方案只有一个:Mock。用模拟对象替换真实依赖,让测试只测你的代码逻辑,不测外部系统。

方案对比:PHPUnit 11.0的三种Mock方式

PHPUnit 11.0(2024年2月发布)提供了三种Mock方式。我用同一个场景(支付服务类PaymentService,依赖外部GatewayClient)做了对比测试。

方案A:createMock(推荐)

最简洁的方式,自动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']);
    }
}

方案B:getMockBuilder(灵活)

需要精细控制时使用,比如禁用原构造函数、只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']);
    }
}

方案C:Prophecy(已废弃,不推荐)

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)代码行数推荐度
createMock0.120.85★★★★★
getMockBuilder0.151.28★★★★☆
Prophecy0.282.17★☆☆☆☆

结论: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']);
    }
}

高级Mock技巧:处理复杂场景

1. Mock final类

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();

2. Mock私有方法

私有方法不应该直接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);

3. Mock静态方法

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']);

4. 链式调用Mock

很多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]]);

避坑指南:我踩过的5个坑

坑1:Mock了不该Mock的类

场景: 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();

坑2:忘记调用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);
}

坑3:Mock了void方法但没返回

场景: 一个方法返回void,但Mock时用了willReturn(),PHPUnit不报错但行为诡异。

// 错误
$loggerMock->method('log')->willReturn(true); // log()返回void

// 正确
$loggerMock->method('log'); // 不需要willReturn

坑4:Mock了__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();

坑5:Mock了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,还有几个技巧能显著提升测试速度:

  • 使用内存数据库: SQLite :memory: 替代MySQL,测试速度提升10倍
  • 禁用Xdebug: 跑测试时加-d xdebug.mode=off,耗时减少40%
  • 并行测试: PHPUnit 11.0支持--parallel参数,利用多核CPU
  • 缓存测试结果: 使用phpunit --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是单元测试的核心技能。记住三条原则:

  1. 只Mock外部依赖,不Mock框架核心
  2. 优先用createMock,需要精细控制时用getMockBuilder
  3. 永远不要Mock私有方法,那是实现细节

按照本文的方案,你的测试速度能从秒级降到毫秒级,CI流水线再也不会因为网络波动而失败。

IT搬运工

专注技术分享,坚持原创深度内容。

分类
链接
订阅

RSS 订阅关注最新文章

© 2026 IT搬运工 · 站点地图 · 鄂ICP备19007640号-3