PHP 单元测试进阶:PHPUnit + Mockery 实战指南,写出让你自己都放心的测试
摘要:你写的 PHP 代码真的可靠吗?本文带你从零掌握 PHPUnit 进阶技巧与 Mockery 模拟框架的黄金组合,通过真实业务场景演示数据提供者、边界测试、Mock/Stub/Spy 核心用法,手把手教你写出覆盖率 95%+ 的高质量单元测试,告别上线心跳加速的恐惧。
一、为什么你的单元测试总是「聊胜于无」?
相信你不陌生这种场景:
项目测试覆盖率:12%
PM:"这个功能测好了吗?"
你:"测了,没问题!"(心里:刚手动点了一下,应该没事吧……)
大多数 PHP 项目的测试问题不是"没写",而是写了也没用:
- ✗ 只测最 happy path,边界条件全漏
- ✗ 测试依赖真实数据库/外部接口,跑一次要 30 秒
- ✗ 测试代码比业务代码还难懂
- ✗ 不知道怎么 Mock 掉外部依赖
- ✗ 测试通过了,但上线还是出 bug
本文的目标:用 PHPUnit + Mockery 的黄金组合,让你的测试从「摆设」变成「护城河」。
二、环境准备
# 安装 PHPUnit 10.x(需要 PHP 8.1+)
composer require --dev phpunit/phpunit ^10.0
# 安装 Mockery(最好用的 PHP Mock 框架)
composer require --dev mockery/mockery
# 验证安装
./vendor/bin/phpunit --version
# PHPUnit 10.5.20 by Sebastian Bergmann and contributors.
phpunit.xml 配置参考(放项目根目录):
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<coverage>
<report>
<html outputDirectory="coverage-report"/>
</report>
</coverage>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
三、PHPUnit 进阶技巧
3.1 数据提供者(Data Provider):一个测试覆盖 N 种场景
新手经常的写法:
// ❌ 低效写法:三个方法做同一件事
public function testEmailValid(): void
{
$this->assertTrue(validateEmail('user@example.com'));
}
public function testEmailInvalidMissingAt(): void
{
$this->assertFalse(validateEmail('userexample.com'));
}
public function testEmailInvalidEmpty(): void
{
$this->assertFalse(validateEmail(''));
}
用 Data Provider 重写,优雅且全面:
<?php
// tests/Unit/ValidatorTest.php
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
class ValidatorTest extends TestCase
{
/**
* 邮箱格式验证测试
* 使用 Data Provider 覆盖所有边界场景
*/
#[DataProvider('emailProvider')]
public function testEmailValidation(string $email, bool $expected): void
{
$validator = new EmailValidator();
$this->assertSame($expected, $validator->isValid($email));
}
/**
* 提供测试数据:[邮箱, 期望结果]
*/
public static function emailProvider(): array
{
return [
// 正常场景
'有效邮箱-标准格式' => ['user@example.com', true],
'有效邮箱-含子域名' => ['user@mail.example.com', true],
'有效邮箱-含加号' => ['user+tag@example.com', true],
// 边界场景
'无效邮箱-缺少@符号' => ['userexample.com', false],
'无效邮箱-缺少域名' => ['user@', false],
'无效邮箱-缺少用户名' => ['@example.com', false],
'无效邮箱-空字符串' => ['', false],
'无效邮箱-含空格' => ['user @example.com', false],
'无效邮箱-特殊字符域名' => ['user@exam_ple.com', false],
// 极端场景
'超长邮箱-254字符以内' => [str_repeat('a', 243) . '@example.com', true],
'超长邮箱-超过254字符' => [str_repeat('a', 244) . '@example.com', false],
];
}
}
效果对比:原来需要 10 个测试方法,现在 1 个方法覆盖 11 个场景,可读性反而更好。
3.2 异常测试:别只测成功,更要测失败
<?php
// src/OrderService.php
class OrderService
{
/**
* 创建订单
*
* @throws InvalidArgumentException 金额不合法时
* @throws OutOfStockException 库存不足时
*/
public function createOrder(int $productId, int $quantity, float $amount): Order
{
if ($amount <= 0) {
throw new InvalidArgumentException("订单金额必须大于0,当前值:{$amount}");
}
if ($quantity <= 0) {
throw new InvalidArgumentException("购买数量必须大于0,当前值:{$quantity}");
}
// ... 业务逻辑
}
}
<?php
// tests/Unit/OrderServiceTest.php
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
class OrderServiceTest extends TestCase
{
private OrderService $service;
protected function setUp(): void
{
// 每个测试方法执行前自动调用
$this->service = new OrderService();
}
/**
* 测试非法参数时抛出正确异常
*/
#[DataProvider('invalidOrderProvider')]
public function testCreateOrderThrowsOnInvalidInput(
int $productId,
int $quantity,
float $amount,
string $expectedException,
string $expectedMessage
): void {
$this->expectException($expectedException);
$this->expectExceptionMessageMatches($expectedMessage);
$this->service->createOrder($productId, $quantity, $amount);
}
public static function invalidOrderProvider(): array
{
return [
'金额为零' => [1, 1, 0.0, InvalidArgumentException::class, '/金额必须大于0/'],
'金额为负数' => [1, 1, -10.0, InvalidArgumentException::class, '/金额必须大于0/'],
'数量为零' => [1, 0, 100.0, InvalidArgumentException::class, '/数量必须大于0/'],
'数量为负数' => [1, -5, 100.0, InvalidArgumentException::class, '/数量必须大于0/'],
];
}
}
3.3 测试夹具(Fixtures):setUp 与 tearDown 的正确姿势
<?php
class DatabaseTest extends TestCase
{
private PDO $pdo;
/**
* 在整个测试类执行前调用一次(静态方法)
* 适合:创建数据库表、初始化连接池
*/
public static function setUpBeforeClass(): void
{
// 创建测试用内存数据库(不污染正式库)
self::$pdo = new PDO('sqlite::memory:');
self::$pdo->exec('CREATE TABLE users (id INT, name TEXT, email TEXT)');
}
/**
* 每个测试方法执行前调用
* 适合:重置状态、插入基准测试数据
*/
protected function setUp(): void
{
$this->pdo->exec("DELETE FROM users"); // 清空旧数据
$this->pdo->exec(
"INSERT INTO users VALUES (1, '张三', 'zhangsan@test.com')"
);
}
/**
* 每个测试方法执行后调用
* 适合:清理临时文件、回滚事务
*/
protected function tearDown(): void
{
$this->pdo->exec("DELETE FROM users WHERE id > 1"); // 只保留基准数据
}
public function testFindUserById(): void
{
$repo = new UserRepository($this->pdo);
$user = $repo->findById(1);
$this->assertSame('张三', $user->name);
$this->assertSame('zhangsan@test.com', $user->email);
}
}
四、Mockery 深度实战
这才是重头戏。真实项目里,你的类总是依赖其他服务:数据库、Redis、第三方 API、邮件服务……单元测试要快、独立、不副作用,就必须 Mock 掉这些依赖。
4.1 三种 Test Double 搞清楚
| 类型 | 定义 | 使用场景 |
|---|---|---|
| Stub | 返回预设值,不关心是否被调用 | 模拟数据库查询结果 |
| Mock | 预设期望调用次数和参数,不满足则测试失败 | 验证某方法必须被调用 |
| Spy | 真实执行但记录调用情况,事后验证 | 验证方法被调用过,但不限制调用时机 |
4.2 实战:用户注册服务测试
先看被测代码:
<?php
// src/UserRegistrationService.php
class UserRegistrationService
{
public function __construct(
private readonly UserRepositoryInterface $userRepo, // 数据库操作
private readonly EmailServiceInterface $emailService, // 发送邮件
private readonly CacheInterface $cache, // Redis 缓存
private readonly EventDispatcher $events // 事件系统
) {}
/**
* 注册新用户
*
* @throws DuplicateEmailException 邮箱已被注册
*/
public function register(string $email, string $password): User
{
// 1. 检查邮箱是否重复
if ($this->userRepo->existsByEmail($email)) {
throw new DuplicateEmailException("邮箱 {$email} 已被注册");
}
// 2. 创建用户
$user = new User(
email: $email,
passwordHash: password_hash($password, PASSWORD_ARGON2ID)
);
$this->userRepo->save($user);
// 3. 发送欢迎邮件(异步,失败不影响注册)
try {
$this->emailService->sendWelcome($user);
} catch (\Exception $e) {
// 记录日志但不抛出异常
logger()->warning('欢迎邮件发送失败', ['email' => $email, 'error' => $e->getMessage()]);
}
// 4. 清除用户列表缓存
$this->cache->delete('users:list');
// 5. 触发事件
$this->events->dispatch(new UserRegistered($user));
return $user;
}
}
测试代码(重点看 Mockery 用法):
<?php
// tests/Unit/UserRegistrationServiceTest.php
use Mockery;
use Mockery\MockInterface;
use PHPUnit\Framework\TestCase;
class UserRegistrationServiceTest extends TestCase
{
private MockInterface $userRepo;
private MockInterface $emailService;
private MockInterface $cache;
private MockInterface $events;
private UserRegistrationService $service;
protected function setUp(): void
{
// 创建 Mock 对象(实现对应接口)
$this->userRepo = Mockery::mock(UserRepositoryInterface::class);
$this->emailService = Mockery::mock(EmailServiceInterface::class);
$this->cache = Mockery::mock(CacheInterface::class);
$this->events = Mockery::mock(EventDispatcher::class);
$this->service = new UserRegistrationService(
$this->userRepo,
$this->emailService,
$this->cache,
$this->events
);
}
/**
* 测试场景1:正常注册流程
* 验证:save/sendWelcome/cache.delete/events.dispatch 都被正确调用
*/
public function testSuccessfulRegistration(): void
{
$email = 'newuser@example.com';
$password = 'SecurePassword123!';
// ===== 设置期望(Mock)=====
// 邮箱不存在(Stub:返回 false)
$this->userRepo
->shouldReceive('existsByEmail')
->once()
->with($email)
->andReturn(false);
// 必须调用 save,参数是 User 类型(Mock:验证调用)
$this->userRepo
->shouldReceive('save')
->once()
->with(Mockery::type(User::class));
// 欢迎邮件必须发送一次(Mock)
$this->emailService
->shouldReceive('sendWelcome')
->once()
->with(Mockery::type(User::class));
// 缓存必须清除(Mock)
$this->cache
->shouldReceive('delete')
->once()
->with('users:list');
// 事件必须触发(Mock)
$this->events
->shouldReceive('dispatch')
->once()
->with(Mockery::type(UserRegistered::class));
// ===== 执行 =====
$user = $this->service->register($email, $password);
// ===== 断言 =====
$this->assertInstanceOf(User::class, $user);
$this->assertSame($email, $user->email);
$this->assertTrue(password_verify($password, $user->passwordHash));
}
/**
* 测试场景2:邮箱已存在时抛出异常
*/
public function testRegistrationFailsWhenEmailExists(): void
{
$email = 'existing@example.com';
// 邮箱已存在
$this->userRepo
->shouldReceive('existsByEmail')
->once()
->with($email)
->andReturn(true);
// !! 关键:以下方法不应被调用
$this->userRepo->shouldNotReceive('save');
$this->emailService->shouldNotReceive('sendWelcome');
$this->cache->shouldNotReceive('delete');
$this->events->shouldNotReceive('dispatch');
$this->expectException(DuplicateEmailException::class);
$this->expectExceptionMessageMatches('/已被注册/');
$this->service->register($email, 'password123');
}
/**
* 测试场景3:发送邮件失败不影响注册成功
* 演示:异常不向上传播的测试
*/
public function testRegistrationSucceedsEvenIfEmailFails(): void
{
$email = 'user@example.com';
$this->userRepo->shouldReceive('existsByEmail')->andReturn(false);
$this->userRepo->shouldReceive('save')->once();
// 邮件服务抛出异常
$this->emailService
->shouldReceive('sendWelcome')
->once()
->andThrow(new \RuntimeException('SMTP 连接超时'));
// 缓存和事件仍然应该执行
$this->cache->shouldReceive('delete')->once()->with('users:list');
$this->events->shouldReceive('dispatch')->once();
// 不应该抛出异常(邮件失败被吞掉了)
$user = $this->service->register($email, 'password');
$this->assertInstanceOf(User::class, $user);
}
/**
* 每个测试方法结束后清理 Mockery(必须!)
*/
protected function tearDown(): void
{
Mockery::close();
}
}
4.3 高阶技巧:Spy、部分 Mock、自定义匹配器
<?php
class AdvancedMockeryTest extends TestCase
{
/**
* Spy:允许真实执行,事后验证是否调用过
*/
public function testWithSpy(): void
{
$emailService = Mockery::spy(EmailServiceInterface::class);
$service = new NotificationService($emailService);
$service->notifyAdmins('系统报警');
// 事后验证(不预设期望,而是事后检查)
$emailService->shouldHaveReceived('send')
->with(Mockery::on(function ($to) {
return str_contains($to, '@admin.');
}));
}
/**
* 部分 Mock(Partial Mock):真实类 + 部分方法替换
* 适合:只想 Mock 某个有副作用的方法
*/
public function testPartialMock(): void
{
// 创建部分 Mock:只 Mock getCurrentTime 方法
$service = Mockery::mock(OrderService::class)->makePartial();
$service->shouldReceive('getCurrentTime')
->andReturn(new \DateTimeImmutable('2026-01-01 12:00:00'));
// 其他方法走真实逻辑
$order = $service->createTimedOrder(1, 1, 100.0);
$this->assertSame('2026-01-01', $order->createdAt->format('Y-m-d'));
}
/**
* 自定义参数匹配器
* 适合:复杂对象的参数验证
*/
public function testCustomMatcher(): void
{
$repo = Mockery::mock(OrderRepositoryInterface::class);
$repo->shouldReceive('save')
->once()
->with(Mockery::on(function (Order $order) {
// 自定义验证逻辑
return $order->getAmount() > 0
&& $order->getStatus() === Order::STATUS_PENDING
&& $order->getUserId() > 0;
}));
$service = new OrderService($repo);
$service->placeOrder(userId: 42, amount: 199.0);
}
/**
* 模拟连续调用返回不同值
* 适合:测试重试逻辑
*/
public function testConsecutiveCalls(): void
{
$apiClient = Mockery::mock(ApiClientInterface::class);
// 前两次失败,第三次成功
$apiClient->shouldReceive('request')
->times(3)
->andReturn(
['status' => 500, 'body' => 'Server Error'], // 第1次
['status' => 500, 'body' => 'Server Error'], // 第2次
['status' => 200, 'body' => '{"ok": true}'] // 第3次(成功)
);
$retryService = new RetryableApiService($apiClient, maxRetries: 3);
$result = $retryService->callWithRetry('/api/endpoint');
$this->assertSame(200, $result['status']);
}
protected function tearDown(): void
{
Mockery::close();
}
}
五、性能对比:Mock 与真实依赖的速度差异
用一个真实项目的测试套件对比:
测试套件:UserRegistrationService(含完整依赖链)
方案A:使用真实数据库 + 真实邮件服务
运行 1 个测试:约 1200ms
运行 100 个测试:约 120,000ms(2分钟)
问题:依赖环境、有副作用、不能并行
方案B:使用 Mockery 隔离所有依赖
运行 1 个测试:约 8ms
运行 100 个测试:约 800ms(不到1秒)
优势:纯内存、无副作用、可并行、CI友好
速度提升:150x(150倍!)
# 生成覆盖率报告(需要 xdebug 或 pcov)
./vendor/bin/phpunit --coverage-html=coverage-report
# 并行运行测试(PHPUnit 10 支持)
./vendor/bin/phpunit --processes=4
# 只跑指定测试套件
./vendor/bin/phpunit --testsuite=Unit
# 只跑匹配名字的测试
./vendor/bin/phpunit --filter=testSuccessfulRegistration
六、测试质量清单
在 Push 代码前,对照这 6 项检查:
✅ 1. 覆盖率达标
./vendor/bin/phpunit --coverage-text | grep "Lines:"
# 目标:核心业务类 ≥ 80%,关键算法 ≥ 95%
✅ 2. 边界条件已覆盖
- 空字符串、空数组、null 值
- 数字边界(0、负数、最大值)
- 异常路径(方法抛出异常时)
✅ 3. Mock 验证到位
shouldReceive()->once()而不是懒写shouldReceive()->andReturn()shouldNotReceive()明确表达"不该被调用"Mockery::close()在 tearDown 中清理
✅ 4. 测试名称表达意图
// ❌ 不知所云
public function test1(): void {}
// ✅ 一目了然(方法名即文档)
public function testRegistrationFailsWhenEmailAlreadyExists(): void {}
public function testEmailValidationAcceptsSubdomainAddresses(): void {}
✅ 5. 测试相互独立
- 每个测试不依赖其他测试的执行顺序
- setUp() 保证每次都从干净状态开始
✅ 6. 测试够快
# 单元测试整套跑完应该 < 5 秒
time ./vendor/bin/phpunit --testsuite=Unit
七、总结
PHPUnit + Mockery 的黄金组合,给你带来的不只是测试覆盖率数字:
| 能力 | 效果 |
|---|---|
| Data Provider | 1 个方法覆盖 N 个场景,可读性更强 |
| Mock/Stub | 测试速度 150x,完全隔离外部依赖 |
| shouldNotReceive | 验证"不该发生"的行为 |
| Partial Mock | 只替换有副作用的方法 |
| Spy | 事后验证,更灵活的断言 |
写好测试的本质是:把你对代码的「期望」变成可执行的规范。当你能自信地跑通 100 个测试后按下 merge,那种安全感,值得。