在不先重构的情况下测试遗留 Laravel 代码

发布: (2025年12月16日 GMT+8 03:00)
6 min read
原文: Dev.to

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);
}

此测试在不修改原始代码的前提下验证了六件事:

  1. 订单已创建。
  2. 订单归属已认证的用户。
  3. 库存被正确扣减。
  4. 应用返回了正确的 HTTP 状态码。
  5. 返回的 JSON 包含预期字段。
  6. 邮件已发送。

它以最小摩擦提供了完整的集成覆盖。

假冒外部 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 代码的可重复流程

  1. 编写一个简单的成功路径集成测试。
  2. 假冒所有副作用: HTTP、邮件、存储、事件等。
  3. 断言数据库状态 而非内部细节。
  4. 再添加至少一个边缘情况测试。
  5. 只有在拥有覆盖率后,才考虑重构。
  6. 逐步稳定系统的每个部分。

不需要“修复”代码才能测试它。

结束语

测试遗留 Laravel 代码并不是追求优雅,而是为了降低风险、提升安全性,并在尚未完全信任的代码上建立信心。上面展示的控制器虽然混乱,但只要遵循以下做法就能变得可测:

  • 通过 HTTP 进行测试。
  • 使用假冒(fakes)隔离副作用。
  • 控制全局变量。
  • 必要时使用部分 Mock。
  • 对复杂响应使用快照。

这些技术让你能够在稳固昨天的代码的同时,为明天的重构奠定坚实基础。

Back to Blog

相关文章

阅读更多 »