从一个写不下去的单元测试说起
2024年3月,我给公司一个订单查询接口补测试。接口逻辑很简单:接收订单号,查数据库,调第三方物流API,拼装返回。
代码写完了,测试跑不动。原因很直白:测试里要连真实MySQL(里面有测试数据),第三方物流API需要真实外网请求,CI上这两个都没有。跑一次测试,等数据库连接超时就要等30秒。
后来我做了个决定:把所有外部依赖全部Mock掉。结果测试从268秒缩短到3.2秒,CI跑完还能顺手多跑几百个断言。这篇把当时踩坑的过程和最终方案记录下来。
问题:单元测试为什么难写
先看这个最简版业务代码(PHP 8.3 + Laravel 11 + PHPUnit 11):
//app/Services/OrderService.php
<?php
namespace App\Services;
use App\Models\Order;
use App\External\LogisticsApi;
class OrderService
{
public function __construct(
private Order $orderModel,
private LogisticsApi $logisticsApi
) {}
/**
* 查询订单物流轨迹
*/
public function getOrderTracking(string $orderNo): array
{
// 1. 查本地数据库
$order = $this->orderModel->where('order_no', $orderNo)->first();
if (!$order) {
return ['found' => false];
}
// 2. 调第三方物流接口
$tracking = $this->logisticsApi->query($order->tracking_code);
// 3. 组装
return [
'found' => true,
'order_no' => $order->order_no,
'tracking_code' => $order->tracking_code,
'tracking' => $tracking
];
}
}
这个类有两个外部依赖:Order模型要连MySQL,LogisticsApi要发HTTP请求。直接new真实对象去测,跑一次要几秒钟,CI上还可能失败在环境问题上。
方案对比:两种常见做法
方案A:用测试数据库 + 沙箱环境(传统做法)
- 配置独立的MySQL测试库,每个测试前用
RefreshDatabase迁移 - 第三方接口用沙箱地址或VCR录制回放
- 优点:更接近真实环境
- 缺点:慢。跑一个复杂模块的200个用例,整体耗时5分钟以上;本地和CI环境不一致,动不动出现"本地能过CI挂了"
方案B:Mock外部依赖(本文章方案)
- 用Mockery或PHPUnit原生createMock替换所有外部依赖
- 单测只验证当前类的逻辑,不访问网络、不查数据库
- 优点:秒级测试、CI稳定
- 缺点:可能测不出真实环境问题(比如SQL语法错)。所以还需要少量集成测试兜底,后面避坑部分细说
核心结论:单测目标不是为了证明"能连上MySQL",是为了验证你的逻辑在外部依赖符合预期时是否正确。如果你觉得"必须连真实库才算测过",那是集成测试的事。
PHPUnit 11 + Mockery 完整实现
1. 安装依赖
PHP 8.3 环境,用的是 PHPUnit 11.0、Mockery 1.6.7。composer.json加一行:
composer require --dev phpunit/phpunit:^11.0 mockery/mockery:^1.6
Laravel 11自带的基础TestCast已经加了Mockery集成,不需要额外注册。
2. 手写一个测试用例:最基础的Mock写法
// tests/Feature/OrderServiceTest.php
<?php
namespace Tests\Feature;
use App\Models\Order;
use App\Services\OrderService;
use App\External\LogisticsApi;
use Mockery;
use Tests\TestCase;
class OrderServiceTest extends TestCase
{
public function test_order_not_found_returns_found_false(): void
{
// 1. mock Order模型:永远返回null(查不到订单)
$orderModel = Mockery::mock(Order::class);
$orderModel->shouldReceive('where')->andReturnSelf();
$orderModel->shouldReceive('first')->andReturn(null);
// 2. mock 物流API:这个用例不该被调用
$logisticsApi = Mockery::mock(LogisticsApi::class);
$logisticsApi->shouldNotReceive('query');
// 3. 直接new服务,注入mock依赖
$service = new OrderService($orderModel, $logisticsApi);
$result = $service->getOrderTracking('DNE-ORDER-001');
$this->assertSame(['found' => false], $result);
}
public function test_order_found_calls_api_and_returns_tracking(): void
{
// mock出完整的订单对象
$order = new \stdClass();
$order->order_no = 'ORD-2024001';
$order->tracking_code = 'SF123456789';
// mock Order模型链式调用:where()->first()返回订单
$orderModel = Mockery::mock(Order::class);
$orderModel->shouldReceive('where')->andReturnSelf();
$orderModel->shouldReceive('first')->andReturn($order);
// mock 物流API:传入快递单号,返回轨迹数组
$logisticsApi = Mockery::mock(LogisticsApi::class);
$logisticsApi->shouldReceive('query')
->once()
->with('SF123456789')
->andReturn([['time' => '2024-03-21 10:00', 'desc' => '已签收']]);
$service = new OrderService($orderModel, $logisticsApi);
$result = $service->getOrderTracking('ORD-2024001');
$this->assertTrue($result['found']);
$this->assertSame('SF123456789', $result['tracking_code']);
$this->assertCount(1, $result['tracking']);
}
}
跑一下:
./vendor/bin/phpunit tests/Feature/OrderServiceTest.php --testdox
输出:
PHPUnit 11.0.3 by Sebastian Bergmann.
✔ Order not found returns found false
✔ Order found calls api and returns tracking
Time: 00:00.024, Memory: 12.00 MB
OK (2 tests, 6 assertions)
3. 复杂场景:依赖注入容器+Mockery的另一种替代
如果业务代码是直接通过服务容器解析的(比如控制器里 app(OrderService::class)),mock对象需要绑定到容器。用Laravel的绑定语法:
// 在测试方法里
$this->instance(LogisticsApi::class, Mockery::mock(LogisticsApi::class, function ($mock) {
$mock->shouldReceive('query')
->with('SF123456789')
->andReturn(['time' => '2024-03-21 10:00', 'desc' => '已签收']);
}));
绑定之后,所有通过容器解析OrderService的地方都会自动获取这个mock实例。
4. 用PHPUnit原生createMock(不引Mockery也能写)
Mockery在PHPUnit里是增强版,但原生也能做基本功能。看这个例子:
use PHPUnit\Framework\TestCase;
class OrderServiceTest extends TestCase
{
public function test_uses_dependency_injection(): void
{
// 原生createMock
$logisticsApi = $this->createMock(LogisticsApi::class);
// 配置方法返回值
$logisticsApi->method('query')
->with('SF123456789')
->willReturn([['time' => '2024-03-21 10:00']]);
// 验证方法被调用了一次
$logisticsApi->expects($this->once())
->method('query');
$orderModel = $this->createMock(Order::class);
$orderModel->method('where')->willReturnSelf();
$orderModel->method('first')->willReturn(null);
$service = new OrderService($orderModel, $logisticsApi);
$result = $service->getOrderTracking('NOPE');
$this->assertSame(['found' => false], $result);
}
}
5. 更真实的数据验证:用匿名类代替mock
有些场景mock太"虚"。比如你想确保模型返回的字段,服务里都用上了。可以造一个真实但极简的对象,PHP的匿名类直接模拟:
$orderModel = new class extends Order {
public function where($column, $operator = null, $value = null)
{
return $this;
}
public function first()
{
$order = new \stdClass();
$order->order_no = 'ORD-2024001';
$order->tracking_code = 'SF123456789';
return $order;
}
};
这种写法适合不想引入Mockery、又希望类型约束可见的场景。但缺点是不能做调用次数校验。我个人推荐Mockery为主、匿名类为辅。
效果数据
同一套测试用例(32个用例,覆盖5个业务方法),三种方式对比:
| 方案 | 总耗时 | 平均单个用例 | 网络依赖 | 数据库依赖 |
|---|---|---|---|---|
| 真实MySQL + 沙箱物流API | 268 秒 | 8.4 秒 | 是(DNS解析+TLS握手) | 是 |
| 基础缓存 + 本地数据库开关 | 89 秒 | 2.8 秒 | 否(部分降级) | 是 |
| Mockery全mock(本文) | 3.2 秒 | 0.1 秒 | 否 | 否 |
3.2秒里还有2秒是Laravel框架初始化,纯测试逻辑1秒多一点。
原理:Mock到底在做什么
Mockery的shouldReceive('query')核心机制是:在mock对象内部,把query方法替换成一个"期望检查器"。你调用时,它不会执行真实代码,而是记录调用参数、调用次数,然后返回预设值。测试结束时Mockery会比对调用次数是否符合预期(比如once()要求恰好一次)。
这个机制成立的前提是:业务类之间通过构造函数或容器接口交互,而不是new硬编码。这提示我们——面向接口编程不只是架构洁癖,更是可测试性的硬需求。
PHPUnit的createMock原理类似,只是它自动生成一个继承自原类的mock子类(通过PHP的匿名类反射机制),在不覆盖方法时才保留真实逻辑。它能mock的范围取决于方法可见性,private方法不能直接mock,需要setAccessible或者重构。
避坑指南
以下坑都是实际踩过的。
坑1:Mockery的shouldReceive方法名大小写敏感
接口方法名是 getUserInfo(),你在mock里写shouldReceive('getuserinfo'),Mockery不会报错,但预期永远不会命中。结果测试永远拿不到返回值。排查方法是:用Mockery::close()时如果出现"method not expected",说明方法名不匹配。建议写mock前先grep原类方法名。
grep -n "function query" app/External/LogisticsApi.php
坑2:接口返回类型与mock返回值类型不一致
PHP 8.3有严格类型,LogisticsApi声明了return array,mock返回了string,直接TypeError。Mockery默认不做类型校验,到用的时候才崩。写法上用andReturn(['time'=>'xxx'])而不是andReturn('xxx')。
坑3:final类是mock不了的
你在同事的类上加final修饰,然后Mockery直接抛异常。PHPUnit原生createMock在PHPUnit 10以后能mock final类,但Mockery不行。遇到这种情况,要么去final,要么改用匿名类方案。
坑4:过度mock导致测试废掉
如果什么内容都mock,最后测试就是"0和1的自我验证"。比如连一个简单的字符串拼接都通过mock对象返回,那测试没意义。我的标准是:断言逻辑分支(if/else/foreach)时就mock外部输入,但不要mock内部方法的输出。
坑5:CI上Mockery的静态方法泄漏
如果A测试里mock了一个静态方法,B测试用到同一个类时静态方法不会自动还原。Mockery官方说Mockery::close()会清理,但你在tearDown()必须显示调用。Laravel的TestCase基类默认做了,但如果你用的是纯PHPUnit类(不继承TestCase),一定要手动加:
protected function tearDown(): void
{
\Mockery::close();
parent::tearDown();
}
尤其注意:一个测试文件里多个测试方法共享同一个mock类的静态属性,第二个测试会莫名其妙失败,甚至报错"Call to a member function on null"。
总结
用Mockery做单元测试隔离后,我维护的订单模块测试从268秒降到3.2秒,代码覆盖率从68%升到91%。主要工作是重新梳理了依赖注入边界,把new出来的第三方客户端都换成了构造器注入。整个改动没用任何黑科技,就是把外部依赖"挡在门外"。
不同场景的选型参考:
- 单测(本地毫秒级验证逻辑):优先Mockery,无网络无数据库
- 集成测试(验证SQL/事务/真实SDK):用真实MySQL沙箱,数量控制在10个以内,只为关键路径写
- 契约测试(验证第三方接口字段变更):用录制回放或VCR,不依赖真网