前言
写单元测试时最典型的几种症状:测试跑一次绿、跑两次红,因为造的数据里带随机值;一个"单元测试"启动了半天,因为它偷偷连了数据库和第三方支付接口;以及 mock(模拟对象)写了一大堆,方法一被调用就返回null,断言全挂。
前两种症状的根因是一样的——测试里混进了"外部世界":时间、随机数、数据库、HTTP 接口。单元测试的原则是这些都要被替身(Test Double)换掉。第三种症状的根因是没分清 stub 和 mock:stub 只提供"返回什么",mock 还要校验"有没有被按预期调用",用错了就会得到一堆意料之外的null。
本文按"替身类型 → PHPUnit 替身 API → 测试数据生成 → 完整可运行示例 → PHP 8 带来的新坑"的顺序讲清。示例以 PHP 8.5 为运行环境,用 PHPUnit 10 及以上版本(属性写法),顺带说清readonly、enum、final这些 PHP 8 新语法在 mock 上的限制。
一、先分清五种替身
很多人把"mock"当成了所有替身的统称,这是混乱的开始。业界通用的分类是五种,日常用得最多的是前两种:
| 替身类型 | 英文 | 作用 | PHPUnit 里怎么造 |
|---|---|---|---|
| 桩件 | Stub | 只提供预设返回值,不校验调用 | createStub() |
| 模拟对象 | Mock | 预设返回值 + 校验调用次数/参数 | createMock() |
| 假对象 | Fake | 有真实逻辑的简化实现(如内存仓库) | 手写类 |
| 间谍 | Spy | 记录调用,事后断言 | 手写类或用回调记录 |
| 哑元 | Dummy | 只为了填参数,不会被用到 | createStub() |
判断标准很简单:你关心"它被怎么调用了"吗?关心就用createMock(),不关心就用createStub()。大量项目的问题在于所有地方都用createMock(),然后在断言里写一堆->expects($this->once()),测试变得又脆又长。
另一类值得推荐的是手写假对象(Fake)。比如一个仓库接口,与其 mock 它的每个方法,不如写一个基于数组的InMemoryUserRepository:
<?php declare(strict_types=1); // 需要 PHP 8.1+ interface UserRepository { /** 保存并返回带自增 ID 的实体 */ public function save(User $user): User; public function findById(int $id): ?User; public function findByEmail(string $email): ?User; } final class InMemoryUserRepository implements UserRepository { /** @var array<int, User> */ private array $rows = []; private int $autoId = 0; public function save(User $user): User { $id = $user->id > 0 ? $user->id : ++$this->autoId; $saved = new User($id, $user->email, $user->name); $this->rows[$id] = $saved; return $saved; } public function findById(int $id): ?User { return $this->rows[$id] ?? null; } public function findByEmail(string $email): ?User { foreach ($this->rows as $row) { if ($row->email === $email) { return $row; } } return null; } } final class User { public function __construct( public readonly int $id, public readonly string $email, public readonly string $name, ) {} }假对象的优势是:它实现了接口,所以永远不会因为"mock 忘记 stub 某个方法"而返回null。当接口方法超过 3 个、或者被调用次数很多时,假对象几乎总是比 mock 更好维护。
二、PHPUnit 的替身 API
PHPUnit 10 起把"注释驱动"全部换成了"属性驱动",所以@dataProvider要改成#[DataProvider],@test要改成#[Test]。在 PHP 8.5 上请使用 PHPUnit 10 及以上版本,PHPUnit 9 及更早版本没有属性写法。
生成器 API 的对应关系:
| 需求 | 写法 | 备注 |
|---|---|---|
| 造一个不校验调用的替身 | $this->createStub(Service::class) | 默认所有方法返回null/0/[] |
| 造一个要校验调用的替身 | $this->createMock(Service::class) | 不设期望时行为等同 stub |
| 只替换部分方法 | getMockBuilder(X::class)->onlyMethods(['a'])->getMock() | 其余方法走真实实现 |
| 指定构造函数参数 | getMockBuilder(X::class)->setConstructorArgs([...]) | 默认不调用原构造函数 |
| 只允许列出的方法 | ->onlyMethods([...]) | 比已废弃的setMethods()更明确 |
设置返回值的关键 API:
$stub->method('find')->willReturn($user); // 固定返回值 $stub->method('find')->willReturnCallback(fn(int $id) => ...); // 由回调决定 $stub->method('find')->willThrowException(new \RuntimeException('boom')); $stub->method('find')->willReturnOnConsecutiveCalls($a, $b); // 依次返回三、测试数据怎么生成才可复现
造数据(fixture)有三个层次,从差到好:
- 硬编码字面量:可读性好,但字段一多就变成一堆重复的
'test@example.com'。 - 随机生成(Faker):字段丰富,但必须固定随机种子,否则用例随机红。
- 对象工厂 + 命名构造器:可读性和可维护性最好,推荐配合 Faker 使用。
先装依赖:
composer require --dev phpunit/phpunit fakerphp/fakerFaker 的使用和"固定种子"的写法:
<?php declare(strict_types=1); require __DIR__ . '/vendor/autoload.php'; use Faker\Factory; // Faker 默认基于 mt_rand(),固定随机种子后每次运行结果完全一致 mt_srand(20260929); $faker = Factory::create('zh_CN'); // unique() 保证同一批数据里不重复,适合做邮箱、用户名 echo $faker->unique()->safeEmail(), PHP_EOL; echo $faker->name(), PHP_EOL; echo $faker->randomFloat(2, 10, 999), PHP_EOL;固定种子这一步经常被忽略。只要实例化 Faker 之前调用mt_srand(),同一次运行里生成的数据序列就是确定的,用例失败时你能复现出完全一样的数据,这是可调试性的前提。
更进一步,把 Faker 包进一个"对象工厂",让测试代码读起来像业务语言:
<?php declare(strict_types=1); use Faker\Factory; use Faker\Generator; final class UserFactory { private Generator $faker; public function __construct(?int $seed = 20260929) { mt_srand($seed); $this->faker = Factory::create('zh_CN'); } /** @param array<string, mixed> $override */ public function make(array $override = []): User { static $seq = 0; $seq++; return new User( id: (int) ($override['id'] ?? $seq), email: (string) ($override['email'] ?? $this->faker->unique()->safeEmail()), name: (string) ($override['name'] ?? $this->faker->name()), ); } /** @return list<User> */ public function makeMany(int $count): array { return array_map(fn(): User => $this->make(), range(1, $count)); } }make(['name' => '张三'])这种"默认值 + 局部覆盖"的写法,比每次写全所有字段可读得多,也不会因为实体新增字段就把所有用例改一遍。
四、完整可运行示例
目录结构如下:
project/ composer.json phpunit.xml src/User.php src/UserRepository.php src/RegisterService.php tests/RegisterServiceTest.php tests/UserFactory.phpsrc/RegisterService.php:
<?php declare(strict_types=1); // 需要 PHP 8.1+ final class RegisterService { public function __construct( private readonly UserRepository $repository, private readonly Mailer $mailer, private readonly Clock $clock, ) {} public function register(string $email, string $name): User { if (!str_contains($email, '@')) { throw new InvalidArgumentException('invalid email'); } if ($this->repository->findByEmail($email) !== null) { throw new RuntimeException('email already registered'); } $user = $this->repository->save(new User(0, $email, $name)); $this->mailer->send($email, '欢迎注册', $this->clock->now()); return $user; } } interface Mailer { /** @return int 本次投递的邮件 ID */ public function send(string $to, string $subject, DateTimeImmutable $at): int; } interface Clock { public function now(): DateTimeImmutable; }tests/RegisterServiceTest.php:
<?php declare(strict_types=1); use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; final class RegisterServiceTest extends TestCase { #[Test] public function register_persists_user_and_sends_welcome_mail(): void { $clock = $this->createStub(Clock::class); $clock->method('now')->willReturn(new DateTimeImmutable('2026-09-29 10:00:00')); // 只关心"被调用了一次、主题正确",用 mock $mailer = $this->createMock(Mailer::class); $mailer->expects($this->once()) ->method('send') ->with( $this->equalTo('tom@example.com'), $this->equalTo('欢迎注册'), $this->isInstanceOf(DateTimeImmutable::class), ) ->willReturn(1001); // 仓库行为复杂,用假对象而不是 mock $repository = new InMemoryUserRepository(); $service = new RegisterService($repository, $mailer, $clock); $user = $service->register('tom@example.com', 'Tom'); $this->assertSame(1, $user->id); $this->assertNotNull($repository->findById(1)); } #[Test] public function duplicate_email_is_rejected(): void { $clock = $this->createStub(Clock::class); $clock->method('now')->willReturn(new DateTimeImmutable()); // 直接用 createStub 造一个替身即可:调用次数不重要,反正不会走到 $mailer = $this->createStub(Mailer::class); $repository = new InMemoryUserRepository(); $repository->save(new User(1, 'tom@example.com', 'Tom')); $service = new RegisterService($repository, $mailer, $clock); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('email already registered'); $service->register('tom@example.com', 'Tom'); } #[Test] #[DataProvider('badEmails')] public function malformed_email_is_rejected(string $email): void { $service = new RegisterService( new InMemoryUserRepository(), $this->createStub(Mailer::class), $this->createStub(Clock::class), ); $this->expectException(InvalidArgumentException::class); $service->register($email, 'Tom'); } public static function badEmails(): array { return [['no-at-sign'], ['@no-local'], ['']]; } }phpunit.xml:
<?xml version="1.0" encoding="UTF-8"?> <phpunit bootstrap="vendor/autoload.php" colors="true"> <testsuites> <testsuite name="unit"> <directory>tests</directory> </testsuite> </testsuites> </phpunit>跑起来:
vendor/bin/phpunit --testdox这段测试里没有任何网络、数据库和真实时间,所以无论何时何地运行结果都一样:Clock被固定,Mailer被替身接管,UserRepository用内存假对象。
五、PHP 8 新语法带来的三个 mock 障碍
单元测试里最容易"卡住"的不是 API 用法,而是 PHP 8 引入的几个语言特性让替身根本造不出来:
| PHP 特性 | 版本 | 能否 mock | 替代方案 |
|---|---|---|---|
final class/final method | 老版本即有 | final class不能;final method不能被覆盖 | 抽出接口,依赖接口 |
readonly class | 8.2 | 替身需要继承它,而"非 readonly 类不能继承 readonly 类",会直接失败 | 依赖接口而非具体类 |
enum | 8.1 | 枚举是 final 的,不能被继承,无法生成替身 | 枚举无需 mock,直接传真实 case |
含readonly属性的普通类 | 8.1 | 可以 mock,但替身里这些属性仍是只读 | 用接口隔离 |
结论其实只有一句:要可测,就依赖接口(或抽象类),不要依赖final/readonly的具体类。这也是"面向接口编程"在测试维度上的直接回报。
常见坑点
1. 用createMock()但不设任何期望
❌ 每个依赖都createMock(),测试里expects()堆成一片,改一处实现就挂三个用例 ✅ 不关心调用方式就用createStub(),只在真正要断言"必须被调用"时用 mock
2. Faker 不固定随机种子
❌ 用例一个月绿、偶尔红,fail 时还复现不出来 ✅ 实例化 Faker 前mt_srand(固定值),或把 Faker 封装进带默认种子的工厂类
3. 对返回值是void的方法调willReturn()
❌$stub->method('save')->willReturn(true);——save()声明为void时报错 ✅void方法只 stub 行为或用假对象;需要返回值就把签名改掉
4. 以为替身会调用原构造函数
❌ 依赖的构造函数里做了初始化,mock 之后发现内部属性是空的 ✅ 记住 mock 默认不调用原构造函数;确实需要就用setConstructorArgs()或先把逻辑挪出构造函数
5. 拿 mock 当"真实实现"用
❌ 给InMemoryUserRepository之外的场景 mock 出"查得到/查不到"两种行为,结果真实 SQL 的约束(唯一键冲突)永远测不到 ✅ 这类"数据约束"应放在集成测试里用真实数据库验证,单元测试只测分支逻辑
6. mock 一个final或readonly的类
❌$this->createMock(ReadonlyConfig::class)直接报错 ✅ 为它定义接口,业务代码依赖接口;不要为了"能 mock"去删掉readonly
7. 依赖真实时间导致用例在凌晨或跨年时挂掉
❌ 断言里直接new DateTimeImmutable()或date('Y-m-d')✅ 注入Clock接口(上面的写法),或用能冻结时间的测试库
8. 用例之间共享可变状态
❌ 工厂类里的自增 ID 用static累计,跑单个用例和跑全量得到不同 ID ✅ 每个用例在setUp()里重建工厂/仓库,保证用例独立