ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PHP8.5配置单元测试Mock数据怎么生成

PHP8.5配置单元测试Mock数据怎么生成 前言写单元测试时最典型的几种症状测试跑一次绿、跑两次红因为造的数据里带随机值一个单元测试启动了半天因为它偷偷连了数据库和第三方支付接口以及 mock模拟对象写了一大堆方法一被调用就返回null断言全挂。前两种症状的根因是一样的——测试里混进了外部世界时间、随机数、数据库、HTTP 接口。单元测试的原则是这些都要被替身Test Double换掉。第三种症状的根因是没分清 stub 和 mockstub 只提供返回什么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_types1); // 需要 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 arrayint, 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 的替身 APIPHPUnit 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有三个层次从差到好硬编码字面量可读性好但字段一多就变成一堆重复的testexample.com。随机生成Faker字段丰富但必须固定随机种子否则用例随机红。对象工厂 命名构造器可读性和可维护性最好推荐配合 Faker 使用。先装依赖composer require --dev phpunit/phpunit fakerphp/fakerFaker 的使用和固定种子的写法?php declare(strict_types1); 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_types1); 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 arraystring, 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 listUser */ 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_types1); // 需要 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_types1); 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(tomexample.com), $this-equalTo(欢迎注册), $this-isInstanceOf(DateTimeImmutable::class), ) -willReturn(1001); // 仓库行为复杂用假对象而不是 mock $repository new InMemoryUserRepository(); $service new RegisterService($repository, $mailer, $clock); $user $service-register(tomexample.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, tomexample.com, Tom)); $service new RegisterService($repository, $mailer, $clock); $this-expectException(RuntimeException::class); $this-expectExceptionMessage(email already registered); $service-register(tomexample.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 version1.0 encodingUTF-8? phpunit bootstrapvendor/autoload.php colorstrue testsuites testsuite nameunit directorytests/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 class8.2替身需要继承它而非 readonly 类不能继承 readonly 类会直接失败依赖接口而非具体类enum8.1枚举是 final 的不能被继承无法生成替身枚举无需 mock直接传真实 case含readonly属性的普通类8.1可以 mock但替身里这些属性仍是只读用接口隔离结论其实只有一句要可测就依赖接口或抽象类不要依赖final/readonly的具体类。这也是面向接口编程在测试维度上的直接回报。常见坑点1. 用createMock()但不设任何期望❌ 每个依赖都createMock()测试里expects()堆成一片改一处实现就挂三个用例 ✅ 不关心调用方式就用createStub()只在真正要断言必须被调用时用 mock2. 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去删掉readonly7. 依赖真实时间导致用例在凌晨或跨年时挂掉❌ 断言里直接new DateTimeImmutable()或date(Y-m-d)✅ 注入Clock接口上面的写法或用能冻结时间的测试库8. 用例之间共享可变状态❌ 工厂类里的自增 ID 用static累计跑单个用例和跑全量得到不同 ID ✅ 每个用例在setUp()里重建工厂/仓库保证用例独立总结
返回列表