在不先重构的情况下测试遗留 Laravel 代码
Source: Dev.to
实际的 PHP 项目策略
遗留代码库是生活的常态。我们大多数人并不是加入全新项目,而是接手那些经过多年有机增长、逐功能添加、测试覆盖率低且缺乏架构纪律的应用。但我们仍然需要测试:防止回归、安心重构、以及自信地交付新功能。
挑战显而易见:如何开始测试从未被设计为可测试的代码?本文将逐步介绍在不先重构的前提下,对遗留 Laravel(以及 PHP)代码进行测试的实用、经战斗检验的策略。这些技巧直接适用于混乱的真实代码库。为使内容具体,我们将使用一个真实的遗留示例。
一个真实的遗留示例
下面是一段简化但真实的控制器方法,可能出现在一个已有 6 年历史的 Laravel 代码库中:
public function processOrder(Request $request)
{
$order = new Order();
$order->product_id = $request->product_id;
$order->quantity = $request->quantity;
$order->user_id = auth()->id();
$order->status = 'pending';
$order->save();
// business rules mixed directly inside controller
if ($order->quantity > 10) {
$order->priority = 'high';
}
if ($order->product_id === 999) {
// fetch external pricing
$response = Http::post('https://external-api.com/prices', [
'product_id' => $order->product_id,
]);
if ($response->ok()) {
$order->external_price = $response->json('price');
}
}
// logging inside business logic
Log::info('Order processed', [
'order_id' => $order->id,
'user' => auth()->id(),
]);
// send email
Mail::to(auth()->user())->send(new OrderCreatedMail($order));
// update stock
$product = Product::find($order->product_id);
$product->stock -= $order->quantity;
$product->save();
return response()->json([
'id' => $order->id,
'status' => $order->status,
'priority' => $order->priority ?? 'normal',
], 201);
}
为什么这个方法难以测试
- 多重副作用(HTTP 请求、邮件、日志)
- 关注点混杂(库存更新、业务规则、外部 API 调用)
- 全局状态(
auth()) - 没有依赖注入
- 数据库操作与控制流紧耦合
- 控制器职责过多
在不修改代码的情况下进行测试
把控制器方法当作黑盒;不需要测试其内部结构。
示例测试
public function test_it_processes_a_basic_order()
{
Mail::fake();
Http::fake();
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create(['stock' => 50]);
$response = $this->postJson('/api/orders/process', [
'product_id' => $product->id,
'quantity' => 3,
]);
$response->assertStatus(201);
$this->assertDatabaseHas('orders', [
'product_id' => $product->id,
'quantity' => 3,
'user_id' => $user->id,
]);
$this->assertDatabaseHas('products', [
'id' => $product->id,
'stock' => 47,
]);
Mail::assertSent(OrderCreatedMail::class);
}
此测试在不修改原始代码的前提下验证了六件事:
- 订单已创建。
- 订单归属已认证的用户。
- 库存被正确扣减。
- 应用返回了正确的 HTTP 状态码。
- 返回的 JSON 包含预期字段。
- 邮件已发送。
它以最小摩擦提供了完整的集成覆盖。
假冒外部 HTTP 调用
当 product_id === 999 时,遗留控制器会调用外部接口。Laravel 的 Http::fake() 可以让这一步安全可控:
Http::fake([
'external-api.com/*' => Http::response([
'price' => 123.45,
], 200),
]);
现在可以测试计算 external_price 的逻辑:
$response = $this->postJson('/api/orders/process', [
'product_id' => 999,
'quantity' => 1,
]);
$this->assertDatabaseHas('orders', [
'external_price' => 123.45,
]);
无需重构。
覆盖全局变量
该方法直接使用 auth()。Laravel 允许在测试中覆盖已认证用户:
$this->actingAs($user);
如果代码依赖当前时间,可以冻结时间:
Date::setTestNow('2025-01-01 10:00:00');
对顽固副作用使用部分 Mock
假设方法中有一段代码在测试中无法触达:
$this->syncInventoryWithWarehouseApi($order);
可以仅对这部分进行部分 Mock:
$controller = Mockery::mock(OrderController::class)->makePartial();
$controller->shouldReceive('syncInventoryWithWarehouseApi')
->andReturn(true);
这样既保留了真实的控制器逻辑,又伪造了有问题的部分。
对复杂响应使用快照测试
当 JSON 响应体庞大或包含动态键时,快照测试可以简化断言:
$response = $this->postJson('/api/orders/process', [...]);
$this->assertMatchesJsonSnapshot($response->json());
若结构意外变化,测试会立即失败。
对混乱 Laravel 代码的可重复流程
- 编写一个简单的成功路径集成测试。
- 假冒所有副作用: HTTP、邮件、存储、事件等。
- 断言数据库状态 而非内部细节。
- 再添加至少一个边缘情况测试。
- 只有在拥有覆盖率后,才考虑重构。
- 逐步稳定系统的每个部分。
不需要“修复”代码才能测试它。
结束语
测试遗留 Laravel 代码并不是追求优雅,而是为了降低风险、提升安全性,并在尚未完全信任的代码上建立信心。上面展示的控制器虽然混乱,但只要遵循以下做法就能变得可测:
- 通过 HTTP 进行测试。
- 使用假冒(fakes)隔离副作用。
- 控制全局变量。
- 必要时使用部分 Mock。
- 对复杂响应使用快照。
这些技术让你能够在稳固昨天的代码的同时,为明天的重构奠定坚实基础。