引言
测试常被当作「写完代码之后的加分项」,但真正健康的 PHP 项目里,测试是代码质量的护栏:它让重构不心惊胆战,让新人不因改坏旧功能被骂,让 CI 在合并前挡住一半以上的回归。
本文覆盖 PHP 测试的完整工具链:PHPUnit(事实标准)的基础用法、数据提供器与测试组织;Mock 与 Test Double 如何在单元测试里隔离外部依赖;集成测试怎么连真实数据库和 HTTP;现代选择 Pest 如何用更优雅的语法让测试像自然语言;最后落在一张「测试金字塔」与 CI 门禁实践上。无论你维护 Laravel 还是裸 PHP 库,都能直接套用。
关联阅读:https://plumephp.com/php8-modern-features/ 的强类型与只读类是写出可测试领域模型的前提;https://plumephp.com/php-composer-package-development/ 演示了包的测试结构。
目录
- 1. 测试金字塔与测试类型全景
- 2. PHPUnit 基础:断言、注解与测试组织
- 3. 数据提供器与参数化测试
- 4. Test Double:Mock、Stub、Spy 与虚拟对象
- 5. 单元测试实战:隔离外部依赖
- 6. 集成测试:真实数据库与 HTTP
- 7. Pest:现代语法的测试体验
- 8. CI 门禁与覆盖率策略
- 9. 测试反模式与改进路径
- 延伸阅读
1. 测试金字塔与测试类型全景
1.1 测试金字塔
/\
/ \ E2E / 浏览器测试(少而精)
/----\ 集成测试(适量)
/------\ 单元测试(最多,最快)
/--------\
| 层级 | 速度 | 稳定性 | 覆盖面 | 数量 |
|---|---|---|---|---|
| 单元测试 | 毫秒级 | 高 | 单函数/类 | 最多 |
| 集成测试 | 秒级 | 中 | 模块与 DB/IO | 适量 |
| E2E 测试 | 秒-分钟 | 低 | 完整链路 | 最少 |
1.2 按「成本-收益」分配
- 单元测试:核心领域逻辑,纯函数最易测。
- 集成测试:仓储、HTTP 控制器、迁移。
- E2E 测试:关键用户旅程(下单、支付)。
2. PHPUnit 基础:断言、注解与测试组织
2.1 最小测试用例
use PHPUnit\Framework\TestCase;
final class DiscountCalculatorTest extends TestCase
{
public function test_applies_percent_discount(): void
{
$calc = new DiscountCalculator();
$result = $calc->apply(100.0, 20);
$this->assertSame(80.0, $result);
}
}
2.2 常用断言
| 断言 | 检查 |
|---|---|
assertSame | 严格相等(类型+值) |
assertEquals | 宽松相等(1.0 == ‘1’) |
assertContains | 数组包含元素 |
assertInstanceOf | 类型实例 |
assertThrows | 抛异常 |
assertMatchesRegularExpression | 正则匹配 |
2.3 测试命名与组织
tests/
├── Unit/
│ ├── DiscountCalculatorTest.php
│ └── OrderTest.php
├── Feature/
│ └── OrderApiTest.php
└── TestCase.php
- 一个类对应一个测试文件,
ClassNameTest。 test_前缀或#[Test]属性(PHPUnit 10+)定义用例。
2.4 setup 与 teardown
final class OrderRepositoryTest extends TestCase
{
private OrderRepository $repo;
protected function setUp(): void
{
$this->repo = new OrderRepository($this->fakeDb());
}
}
3. 数据提供器与参数化测试
3.1 数据提供器
同一逻辑用多组输入验证:
final class MoneyFormatterTest extends TestCase
{
#[DataProvider('centsProvider')]
public function test_formats_cents(int $cents, string $expected): void
{
$this->assertSame($expected, MoneyFormatter::format($cents));
}
public static function centsProvider(): array
{
return [
'zero' => [0, '0.00'],
'simple' => [1234, '12.34'],
'with negative' => [-500, '-5.00'],
];
}
}
3.2 数据集命名的好处
命名让失败信息可读:assertSame('0.00','0.0') 会带上「zero」这个标签。
4. Test Double:Mock、Stub、Spy 与虚拟对象
4.1 Test Double 分类
| 类型 | 用途 |
|---|---|
| Stub | 返回预设数据,验证返回值 |
| Mock | 验证「是否以特定方式被调用」 |
| Spy | 记录调用供事后断言 |
| Fake | 真实行为的轻量替代(内存仓储) |
| Dummy | 只占位,从不真正使用 |
4.2 用 Mock 验证协作
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
final class OrderServiceTest extends TestCase
{
public function test_payment_gateway_charged_once(): void
{
/** @var PaymentGateway&MockObject $gateway */
$gateway = $this->createMock(PaymentGateway::class);
$gateway->expects($this->once())
->method('charge')
->with($this->callback(fn ($amount) => $amount > 0))
->willReturn(true);
(new OrderService($gateway))->place(250.0);
// expects(...)->once() 若未被调用,测试即失败
}
}
4.3 Stub 返回预设
$gateway = $this->createStub(PaymentGateway::class);
$gateway->method('charge')->willReturn(true);
// 只关心返回值,不验证调用方式
4.4 用 Fake 替换真实 IO
final class InMemoryOrderRepository implements OrderRepository
{
private array $orders = [];
public function save(Order $o): void { $this->orders[$o->id] = $o; }
public function find(int $id): ?Order { return $this->orders[$id] ?? null; }
}
// 测试用它,零 IO、零网络
4.5 Mock 的纪律
- 只 mock 外部边界(网关、仓储、HTTP),不 mock 内部逻辑。
- 过度 mock 会让测试与实现耦合,重构就废。
5. 单元测试实战:隔离外部依赖
5.1 目标:测「纯逻辑」
把副作用隔离到边界对象,核心逻辑保持无 IO、可预测:
final class PriceCalculator
{
public function __construct(
private TaxResolver $tax,
) {}
public function total(Item $item): Money
{
return $item->price()->add(
$this->tax->rateFor($item->country())->apply($item->price())
);
}
}
5.2 测试这个核心逻辑
final class PriceCalculatorTest extends TestCase
{
public function test_total_includes_tax(): void
{
$tax = $this->createStub(TaxResolver::class);
$tax->method('rateFor')->willReturn(new TaxRate(0.1));
$calc = new PriceCalculator($tax);
$total = $calc->total(new Item(new Money(100), 'CN'));
$this->assertEquals(110.0, $total->amount());
}
}
5.3 测试优先写法的收益
- 被迫把 IO 边界抽象出来 → 代码解耦。
- 测试运行毫秒级 → 频繁运行 → 及时反馈。
- 核心逻辑变更 → 测试立刻标红。
6. 集成测试:真实数据库与 HTTP
6.1 数据库测试(Laravel 风格)
use Illuminate\Foundation\Testing\RefreshDatabase;
final class OrderApiTest extends TestCase
{
use RefreshDatabase; // 每个用例重建库结构
public function test_orders_list_returns_json(): void
{
Order::factory()->count(3)->create();
$response = $this->getJson('/api/orders');
$response->assertOk()
->assertJsonCount(3, 'data')
->assertJsonPath('data.0.status', 'pending');
}
}
6.2 事务测试(回滚而非重建)
use Illuminate\Foundation\Testing\DatabaseTransactions;
// 用例间用事务包裹,结束回滚,比重建快
6.3 HTTP 与外部服务
// Mock HTTP 客户端,模拟外部 API
use Illuminate\Support\Facades\Http;
public function test_payment_webhook_signature(): void
{
Http::fake([
'payments.example.com/*' => Http::response(['ok' => true], 200),
]);
$this->postJson('/webhooks/payment', ['txn' => 'T-001']);
}
6.4 集成测试的取舍
- 优点:验证「接线」是否正确(路由→控制器→仓储→DB)。
- 成本:慢、依赖环境(DB、Redis)。
- 策略:关键链路做集成,其余用单元测试。
7. Pest:现代语法的测试体验
7.1 Pest 让测试像句子
// Pest 语法(PHPUnit 之上的 DSL)
use function Pest\it;
it('applies percent discount', function () {
$calc = new DiscountCalculator();
expect($calc->apply(100, 20))->toBe(80.0);
});
7.2 更丰富的 expect API
expect($array)->toHaveCount(3)
->and($result)->toBeGreaterThan(0)
->and($callback)->toThrow(InvalidArgumentException::class);
7.3 Pest 与 PHPUnit 的对比
| 维度 | PHPUnit | Pest |
|---|---|---|
| 语法 | 类 + 方法 + 断言 | 函数式 it/expect |
| 学习曲线 | 略陡 | 更友好 |
| 生态 | 最广 | 兼容 PHPUnit 断言 |
| 适用 | 大型传统项目 | 新项目、注重可读性 |
两者可共存:Pest 底层就是 PHPUnit,phpunit.xml 同用。
8. CI 门禁与覆盖率策略
8.1 CI 中的测试流程
# GitHub Actions
- run: composer install --prefer-dist
- run: php artisan test --parallel
- run: vendor/bin/phpunit --coverage-text --coverage-clover build/clover.xml
- uses: codecov/codecov-action@v4
8.2 覆盖率策略
- 行覆盖率不是唯一目标:60-80% 覆盖核心逻辑即可,别为凑数测 getter。
- 关键路径必测:支付、鉴权、数据一致性。
- 用突变测试校验有效性:
infection能发现「没断言的测试」。
composer require --dev infection/infection
vendor/bin/infection --min-msi=80
8.3 门禁设置
| 门槛 | 建议 |
|---|---|
| 单元+集成全绿 | 合并前必过 |
| 覆盖率下降 | PR 对比,禁止明显下滑 |
| 关键路径无测试 | 人工 review 把关 |
9. 测试反模式与改进路径
9.1 常见反模式
| 反模式 | 问题 | 改进 |
|---|---|---|
| 测实现细节 | 重构必红 | 测行为契约 |
| 过度 mock | 测试成摆设 | 只 mock 边界 |
| 长测试用例 | 难读难定位 | 拆成多个小用例 |
| 只测快乐路径 | 漏异常 | 补边界与异常用例 |
| 断言过少 | 假绿 | 覆盖关键行为 |
9.2 改进路径(渐进)
- 新代码先写测试(TDD 或至少「红-绿-重构」)。
- 给最痛的核心逻辑补单元测试(支付、价格、状态机)。
- 关键链路补集成测试,最终形成完整金字塔。
- 定期跑 infection 揪出无效测试。
9.3 一句话心法
测试不是为了「证明代码没问题」,而是为了让「改代码不再可怕」。 维护成本高的不是测试本身,而是测试与实现过度耦合。
延伸阅读
- https://plumephp.com/php8-modern-features/ — 枚举、只读类让领域模型更可测
- https://plumephp.com/php-composer-package-development/ — 包的测试与 CI 结构
- PHPUnit 官方文档 与 Pest 文档
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。