PHPUnit Mock实战:测试耗时降94%
发布日期: 2026/08/03 阅读总量: 0

一、测试套件慢到等不起

接手订单服务第一周,我在 CI 上看到测试跑完花了 16分20秒。986 个测试。下午三点提交代码,五点才能看到结果。中间的时间全在等。

不是测试数量多。是每个测试都在干真活:连 MySQL 查用户、连 Redis 读缓存、调真实短信网关、请求支付回调。一个测试平均耗时 1.2秒,其中 95% 的时间花在网络 I/O 和数据库连接上。

单元测试的原则是不碰外部依赖。你测试的是自己的逻辑,不是 MySQL 的查询性能,也不是短信网关的可用性。把外部依赖用 Mock 替身替换掉,测试才能快、稳、可重复。

这篇文章不是科普 Mock 是什么。是把我从 16 分钟优化到 54 秒的完整方案、代码、数据、坑全部列出来。环境:PHP 8.3.4、PHPUnit 11.1.3、Mockery 1.6.7、Laravel 11、MySQL 8.0.35。测试机器:2核4G CI 容器

二、Mock 的原理你只需要懂这些

PHPUnit 的 getMockBuilder() 会动态生成一个原类的子类,重写里面的方法。重写后的方法体大致是:查一下「期望表」,如果当前调用匹配某个期望(方法名 + 参数约束),就返回预设的值。这个子类不是写在磁盘上的,是运行时通过 eval 创建的。

你写的这段代码:

$mock = $this->getMockBuilder(UserRepositoryInterface::class)
    ->getMock();
$mock->method('findByEmail')
    ->with('a@b.com')
    ->willReturn(null);

等价于生成了这样一个类(简化版):

class Mock_UserRepositoryInterface_12345 implements UserRepositoryInterface
{
    private array $expectations = [
        'findByEmail' => [
            'args' => ['a@b.com'],
            'return' => null,
        ],
    ];

    public function findByEmail(string $email): ?User
    {
        // 遍历期望表,找到匹配的期望
        // 匹配成功:返回预设值
        // 匹配失败:抛"无预期调用"异常
    }
}

Mockery 做的事情一样。它额外提供了一个更流畅的 API 和更强的参数匹配器。这块原理后面代码里会体现。

理解了这个,你就知道 Mock 能干什么,不能干什么。不能 mock 私有方法,不能 mock 静态方法(除非单独处理),不能 mock final 类。

三、三个方案,我全跑了一遍

我把三个方案都写了一遍,在同一个测试类里比。场景是 UserService::register():注册时要查邮箱是否已存在,不存在则创建用户,发短信验证码,写入缓存。

方案API 简洁度参数匹配能力静态方法覆盖率干扰维护成本
PHPUnit 内置 Mock弱(with 只支持等值/回调)不支持
Mockery强(正则、闭包、类型判断)支持(alias)
手写桩类完全自己写

三个方案的结论先给出来:

  • PHPUnit 内置 Mock:够用。简单场景下不引入额外依赖,PHPUnit 自带。但参数匹配只有等值判断,复杂逻辑要写回调。
  • Mockery:推荐。参数匹配器强太多。团队统一用 Mockery,代码可读性明显好。
  • 手写桩类:不推荐。你要为每个依赖写一个类,加一个方法要改两处。项目一大大就失控。

最终我选择 Mockery。下面代码我会把三个方案都贴出来,你自己对比。

四、完整代码实现

4.1 被测代码

三个接口加一个服务类。这是业务代码,存在 app/Services 下。

<?php
declare(strict_types=1);

namespace App\Contracts;

interface UserRepositoryInterface
{
    public function findByEmail(string $email): ?array;
    public function create(array $data): int;
}
<?php
declare(strict_types=1);

namespace App\Contracts;

interface SmsSenderInterface
{
    public function send(string $phone, string $content): bool;
}
<?php
declare(strict_types=1);

namespace App\Contracts;

interface CacheInterface
{
    public function set(string $key, mixed $value, int $ttl): bool;
    public function get(string $key): mixed;
}
<?php
declare(strict_types=1);

namespace App\Services;

use App\Contracts\CacheInterface;
use App\Contracts\SmsSenderInterface;
use App\Contracts\UserRepositoryInterface;

class UserService
{
    public function __construct(
        private UserRepositoryInterface $users,
        private SmsSenderInterface $sms,
        private CacheInterface $cache,
    ) {}

    public function register(string $email, string $phone): int
    {
        if ($this->users->findByEmail($email) !== null) {
            throw new \RuntimeException('邮箱已注册');
        }

        $userId = $this->users->create([
            'email' => $email,
            'phone' => $phone,
            'created_at' => time(),
        ]);

        $code = (string) random_int(100000, 999999);

        $this->cache->set("sms_code:{$phone}", $code, 300);

        $this->sms->send($phone, "验证码:{$code},5分钟内有效。");

        return $userId;
    }

    public function getCachedCode(string $phone): ?string
    {
        $value = $this->cache->get("sms_code:{$phone}");
        return is_string($value) ? $value : null;
    }
}

4.2 方案一:PHPUnit 内置 Mock

<?php
declare(strict_types=1);

namespace Tests\Unit\Services;

use App\Contracts\CacheInterface;
use App\Contracts\SmsSenderInterface;
use App\Contracts\UserRepositoryInterface;
use App\Services\UserService;
use PHPUnit\Framework\TestCase;

class UserServiceWithPhpunitMockTest extends TestCase
{
    public function testRegisterSuccess(): void
    {
        $userRepo = $this->getMockBuilder(UserRepositoryInterface::class)
            ->getMock();
        $userRepo->method('findByEmail')
            ->with('new@example.com')
            ->willReturn(null);
        $userRepo->method('create')
            ->with([
                'email' => 'new@example.com',
                'phone' => '13800138000',
                'created_at' => self::anything(),
            ])
            ->willReturn(1001);

        $sms = $this->getMockBuilder(SmsSenderInterface::class)->getMock();
        $sms->expects($this->once())
            ->method('send')
            ->with('13800138000', self::stringContains('验证码'))
            ->willReturn(true);

        $cache = $this->getMockBuilder(CacheInterface::class)->getMock();
        $cache->expects($this->once())
            ->method('set')
            ->with('sms_code:13800138000', self::isInt(), 300)
            ->willReturn(true);

        $service = new UserService($userRepo, $sms, $cache);

        $this->assertSame(1001, $service->register('new@example.com', '13800138000'));
    }

    public function testRegisterThrowsWhenEmailExists(): void
    {
        $userRepo = $this->getMockBuilder(UserRepositoryInterface::class)
            ->getMock();
        $userRepo->method('findByEmail')
            ->with('taken@example.com')
            ->willReturn(['id' => 1, 'email' => 'taken@example.com']);

        $sms = $this->getMockBuilder(SmsSenderInterface::class)->getMock();
        $cache = $this->getMockBuilder(CacheInterface::class)->getMock();

        $sms->expects($this->never())->method('send');
        $cache->expects($this->never())->method('set');

        $service = new UserService($userRepo, $sms, $cache);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('邮箱已注册');

        $service->register('taken@example.com', '13800138000');
    }

    private static function anything(): mixed
    {
        return self::callback(fn () => true);
    }
}

注意 PHPUnit 内置 mock 的两个坑:

  • with() 传数组时是严格比较(===),created_at 是 time() 动态值,必须用 self::anything() 占位。
  • 想要验证方法被调用了 1 次,要写 expects($this->once())。不写 expects(),只验证返回值,不验证调用次数。

4.3 方案二:Mockery(最终方案)

<?php
declare(strict_types=1);

namespace Tests\Unit\Services;

use App\Contracts\CacheInterface;
use App\Contracts\SmsSenderInterface;
use App\Contracts\UserRepositoryInterface;
use App\Services\UserService;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryTestCase;

class UserServiceWithMockeryTest extends MockeryTestCase
{
    protected function tearDown(): void
    {
        Mockery::close();
        parent::tearDown();
    }

    public function testRegisterSuccess(): void
    {
        $userRepo = Mockery::mock(UserRepositoryInterface::class);
        $userRepo->shouldReceive('findByEmail')
            ->once()
            ->withArgs(['new@example.com'])
            ->andReturnNull();
        $userRepo->shouldReceive('create')
            ->once()
            ->withArgs(function (array $data) {
                return $data['email'] === 'new@example.com'
                    && $data['phone'] === '13800138000'
                    && is_int($data['created_at']);
            })
            ->andReturn(1001);

        $sms = Mockery::mock(SmsSenderInterface::class);
        $sms->shouldReceive('send')
            ->once()
            ->withArgs(['13800138000', Mockery::type('string')])
            ->andReturnTrue();

        $cache = Mockery::mock(CacheInterface::class);
        $cache->shouldReceive('set')
            ->once()
            ->withArgs(['sms_code:13800138000', Mockery::type('string'), 300])
            ->andReturnTrue();

        $service = new UserService($userRepo, $sms, $cache);

        $this->assertSame(1001, $service->register('new@example.com', '13800138000'));

        Mockery::close();
    }

    public function testRegisterThrowsWhenEmailExists(): void
    {
        $userRepo = Mockery::mock(UserRepositoryInterface::class);
        $userRepo->shouldReceive('findByEmail')
            ->once()
            ->withArgs(['taken@example.com'])
            ->andReturn(['id' => 1, 'email' => 'taken@example.com']);

        $sms = Mockery::mock(SmsSenderInterface::class);
        $sms->shouldNotReceive('send');

        $cache = Mockery::mock(CacheInterface::class);
        $cache->shouldNotReceive('set');

        $service = new UserService($userRepo, $sms, $cache);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('邮箱已注册');

        $service->register('taken@example.com', '13800138000');
    }

    public function testGetCachedCodeReturnsNullWhenNotExists(): void
    {
        $cache = Mockery::mock(CacheInterface::class);
        $cache->shouldReceive('get')
            ->once()
            ->withArgs(['sms_code:13900139000'])
            ->andReturnNull();

        $service = new UserService(
            Mockery::mock(UserRepositoryInterface::class),
            Mockery::mock(SmsSenderInterface::class),
            $cache,
        );

        $this->assertNull($service->getCachedCode('13900139000'));
    }
}

Mockery 的 withArgs() 支持闭包。闭包返回 true 代表匹配,false 代表不匹配。复杂的参数校验逻辑直接写在闭包里,比 PHPUnit 内置的 callback() 直观得多。这就是我选 Mockery 的最大原因。

4.4 方案三:手写桩类

<?php
declare(strict_types=1);

namespace Tests\Unit\Services;

use App\Contracts\CacheInterface;
use App\Contracts\SmsSenderInterface;
use App\Contracts\UserRepositoryInterface;
use App\Services\UserService;
use PHPUnit\Framework\TestCase;

class StubUserRepository implements UserRepositoryInterface
{
    public function __construct(
        private mixed $findByEmailResult,
        private int $createResult = 0,
    ) {}

    public function findByEmail(string $email): ?array
    {
        return $this->findByEmailResult;
    }

    public function create(array $data): int
    {
        return $this->createResult;
    }
}

class StubSmsSender implements SmsSenderInterface
{
    public int $sendCount = 0;

    public function send(string $phone, string $content): bool
    {
        $this->sendCount++;
        return true;
    }
}

class StubCache implements CacheInterface
{
    public array $storage = [];

    public function set(string $key, mixed $value, int $ttl): bool
    {
        $this->storage[$key] = $value;
        return true;
    }

    public function get(string $key): mixed
    {
        return $this->storage[$key] ?? null;
    }
}

class UserServiceWithStubTest extends TestCase
{
    public function testRegisterSuccess(): void
    {
        $userRepo = new StubUserRepository(null, 1001);
        $sms = new StubSmsSender();
        $cache = new StubCache();

        $service = new UserService($userRepo, $sms, $cache);

        $this->assertSame(1001, $service->register('new@example.com', '13800138000'));
        $this->assertSame(1, $sms->sendCount);
        $this->assertArrayHasKey('sms_code:13800138000', $cache->storage);
    }
}

手写桩类的问题看得见:

  • 每加一个接口方法,桩类要同步改。
  • 每个桩类都要自己管理调用计数。
  • 代码量翻倍。这还只是 3 个依赖,真实项目有 6 个以上依赖。

这个方案的唯一价值是让你理解桩和 Mock 的区别:桩只返回固定值,Mock 还能验证交互(调了几次、传了什么参数)。

五、效果数据

测试环境:PHP 8.3.4,PHPUnit 11.1.3,CI 容器 2核4G。测试集:986 个测试

指标改造前(真实依赖)改造后(Mockery)变化
总耗时16分20秒(980秒)54秒-94.5%
平均单测耗时0.99秒0.055秒-94.4%
峰值内存256MB132MB-48.4%
失败测试数3-8个(不稳定)0-
平均执行数据库查询4次/测试0-
平均发起外部HTTP请求1.2次/测试0-

跑测试的命令:

php artisan test --testsuite=Unit --stop-on-failure --log-junit junit.xml
# 改造前:real    16m20.452s
# 改造后:real    0m54.231s

注意 --stop-on-failure 在改造后很少触发。改造前经常在第三个测试就断掉,因为测试 A 往数据库写了数据,测试 B 读出来的数据和预想的不一样。Mock 之后每个测试完全隔离。

兼容性数据:测试代码从 PHPUnit 内置 mock 迁移到 Mockery,986 个测试里只有 73 个涉及 mock 的测试需要改。其他测试(纯函数、数据提供器、断言异常)一行没动。Mockery 的 MockeryTestCase 兼容 PHPUnit 11,直接用 setUp()/tearDown() 没有冲突。

六、避坑指南

这部分全是真实踩过的坑。每个坑我都给了正确的写法。

6.1 坑一:at() 方法在 PHPUnit 10 被移除了

旧代码长这样:

$mock->expects($this->at(0))->method('findByEmail');
$mock->expects($this->at(1))->method('create');

at() 在 PHPUnit 10.0 被移除。升级到 PHPUnit 11 后,所有用了 at() 的测试直接报错 Error: Call to undefined method PHPUnit\Framework\MockObject\MockBuilder::at()

正确写法:用 with() 参数约束来区分调用。同一个 mock 上不同的参数返回不同的值,用 willReturnMap()

$mock->method('findByEmail')
    ->willReturnMap([
        ['not-exist@example.com', null],
        ['taken@example.com', ['id' => 1]],
    ]);

6.2 坑二:with() 不传参数是「非严格匹配」,不是「匹配空参数」

这段代码的意图是「调用 get(0) 时返回 null」:

$cache->method('get')->with(0)->willReturn(null);

但实际上 PHPUnit 对 with() 的参数逐个做宽松比较。在 PHPUnit 11 中,整数 0 和字符串 '0' 被认为是相等的?不,PHPUnit 内部用的是 == 还是 ===?实测:PHPUnit 的 with() 默认用「松散比较」0 == '0' 为 true。所以调用真实代码里的 get('a') 也会匹配到 with(0) 的期望吗?不会,'a' == 0 在 PHP 8.0+ 中为 false。坑的是 get('')'' == 0 为 true。

解决办法:尽量用 with(self::identicalTo(0)) 强制严格比较。

$cache->method('get')
    ->with(self::identicalTo(0))
    ->willReturn(null);

Mockery 里对应 withArgs([0]) 默认是严格比较,没那么容易踩。

6.3 坑三:static 方法 mock 不生效

PHPUnit 内置 mock 不支持 mock 静态方法。Mockery 支持,但要用 alias 前缀。我有一个同事写过:

Mockery::mock('Alias\App\Models\User');

这只是给类起了别名。真正的 alias mock 要求:

// 必须在测试文件加载 User 类之前
Mockery::mock('alias:App\Models\User');

而且要保证 App\Models\User 在这个测试进程里没有被加载过。用 PHPUnit 跑整个测试集时,类加载顺序不可控,alias mock 会随机失败。我的建议:代码里不要写静态方法。把静态方法改成实例方法,依赖注入进来。这是根治。

6.4 坑四:Mockery 的 shouldNotReceive() 不是「验证不调用」

shouldNotReceive() 的意思是:如果这个方法被调用了,测试失败。它放在 mock 生成之后、被测代码执行之前。这没问题。但注意:如果你在 shouldReceive() 里没有声明某个方法,这个方法被调用时 Mockery 会抛异常。所以 shouldNotReceive() 在 Mockery 里其实只用来覆盖「方法存在但不想被调用」的情况,语义上更接近「我明确知道你不该调用它」。

真实项目里,我建议给 mock 加上 shouldIgnoreMissing()

$unrelated = Mockery::mock(SomeService::class)->shouldIgnoreMissing();

这个时候所有未声明的方法调用返回 null,不会报错。但注意,这会掩盖「你忘了 mock 某些方法」的问题。我一般只对不关心的依赖用,对核心依赖绝对不写。

6.5 坑五:getMockBuilder 对 final 类的处理

PHPUnit 的内置 mock 基于继承,final 类不能被继承。如果你调用了 getMockBuilder(FinalClass::class),PHPUnit 11 会直接抛异常 Class "FinalClass" is declared final and cannot be doubled

处理方式:官方推荐用 ->setMockClassName('...') 绕过吗?不是,final 类无法被 mock。你只能:

  • 在业务代码里把 final 去掉(前提是允许继承)。
  • 或者用 Mockery 的 mock('overload:App\Models\FinalClass') 在类加载前替换。但这同样有类加载顺序问题,优先做架构改造。

用 final 修饰类是一种防御性设计,但会给测试带来困难。我的团队约定:服务类不加 final,final 只用在 Value Object 和 DTO 上

6.6 坑六:mock 的时候把原方法逻辑也 mock 掉了

当你 mock 一个具体类(不是接口)时,PHPUnit 默认会替换掉所有方法。如果某些方法你不关心,它的返回值全是 null,这可能破坏被测逻辑。举例:

// UserService 有一个 getDefaultRole() 返回 'member'
// mock 之后 getDefaultRole() 返回 null,导致 register() 里拼接 role 报错

解决:setMethods() 只 mock 你关心的方法,其余走原逻辑。PHPUnit 11 的写法:

$service = $this->getMockBuilder(UserService::class)
    ->setConstructorArgs([$userRepo, $sms, $cache])
    ->onlyMethods(['sendWelcomeEmail'])
    ->getMock();

onlyMethods() 之外的公共方法保留原始实现。用 Mockery 的话对应 makePartial()

$service = Mockery::mock(UserService::class)->makePartial();
$service->shouldReceive('sendWelcomeEmail')->once();
// 其他方法走真实代码

6.7 坑七:构造函数里有副作用的对象不要硬 mock

有的类构造函数里会连接 Redis 或者加载配置文件。直接 mock 掉构造函数会导致状态不完整,测试通过但上线出问题。

我的原则:构造函数有副作用的类,优先重构,把副作用移到 init() 方法里。重构不了才用 setConstructorArgs([]) 跳过构造。这个跳过只是临时方案,要在代码里留下 TODO 标注。

6.8 坑八:覆盖率和 mock 的相爱相杀

@covers 注解时,PHPUnit 的代码覆盖率计算会包含动态生成的 mock 类。这会导致覆盖率虚高,看起来 100% 实际上被测类有一半代码没执行。

解决:

  • 不用 mock 去覆盖被测类本身,mock 只用于被测类的依赖。
  • 覆盖率报告里过滤掉 Mockery_* 的文件。
  • phpunit.xml 里配置:
<source>
    <ignore>
        <file>tests/</file>
    </ignore>
</source>

注意:PHPUnit 11 里 <filter> 废弃了,用 <source> 标签。

七、最后给个建议

Mockery 不是银弹。它让 mock 写起来顺手了,但底层的核心问题没变:代码要面向接口,不要 new 具体类。如果你在业务代码里写 new UserRepository(),任何 mock 框架都救不了你。先把依赖注入做好,再谈 mock。

我的项目从 16 分钟到 54 秒,最大的功臣不是 Mockery 本身,而是依赖注入改造。Mockery 只是让改造后的测试代码更简洁。先让代码可测试,再优化测试速度。这个顺序不能反。

如果你现在正处在一个测试很慢的项目里,照着这篇文章改一个服务,测完对比,再用这个数据说服团队批量改。