一、测试套件慢到等不起
接手订单服务第一周,我在 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% |
| 峰值内存 | 256MB | 132MB | -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 只是让改造后的测试代码更简洁。先让代码可测试,再优化测试速度。这个顺序不能反。
如果你现在正处在一个测试很慢的项目里,照着这篇文章改一个服务,测完对比,再用这个数据说服团队批量改。