PHP Patchwork — 讓老舊專案也能好好寫測試

前言

記錄我在老舊 PHP 專案中導入測試時遇到的問題,以及怎麼用 Patchwork 解決它

在現代 PHP 開發中,寫單元測試幾乎是標配
但如果有開始接手一些「歷史悠久」的老舊專案,就會知道有多痛苦:

  • 到處都是 static 方法呼叫
  • 直接 new 依賴物件,沒有注入
  • 全域函式散落各處
  • 根本無法換掉依賴來做 mock

這時候一般的 Mock 工具(像是 PHPUnit 的 getMockBuilder)可能就不能夠處理這些情況了
因為它們只能 mock 物件,對 static call 和一般函式就束手無策了

Patchwork 是什麼?

Patchwork 是一個 PHP 的函式庫,可以讓你在執行時期「替換」任何函式的實作,包含:

  • 一般 PHP 函式(strlentime、自定義的 helper 等)
  • static 方法
  • 任何 class 的方法

簡單說,就是可以在測試的時候「偷換」函式行為,而不需要改動原本的程式碼

安裝方式

1
composer require --dev antecedent/patchwork

安裝後要注意,Patchwork 需要在 所有程式碼被 autoload 之前 就先載入

例如在測試的 bootstrap 檔案裡手動引入:

1
2
3
// bootstrap.php
// 在檔案的第一行加入
require __DIR__ . '/vendor/antecedent/patchwork/Patchwork.php';

基本用法

替換一個普通函式

老舊專案裡也很常看到這種全域 helper function
例如建立訂單時,直接產生一個訂單編號:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function generateOrderNumber(): string
{
return 'ORD-' . date('YmdHis');
}

class OrderService
{
public function create(): array
{
return [
'order_no' => generateOrderNumber(),
];
}
}

如果測試時不想依賴實際時間,就可以直接把這個函式換成固定值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use function Patchwork\{redefine, restore};

public function testCreateOrderUsesPatchedOrderNumber(): void
{
$patch = redefine('generateOrderNumber', function () {
return 'ORD-TEST-001';
});

$service = new OrderService();
$result = $service->create();

$this->assertSame('ORD-TEST-001', $result['order_no']);

restore($patch);
}

替換 Static 方法

這是老舊專案最常見的問題了,因為 static 方法無法被 mock
假設你有這種程式碼:

1
2
3
4
5
6
7
8
9
class OrderService
{
public function createOrder(array $data): bool
{
// 直接呼叫 static,無法 mock
$userId = Auth::getCurrentUserId();
// ... 其他邏輯
}
}

可以用 Patchwork 這樣替換:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use function Patchwork\{redefine, restore};

public function testCreateOrder(): void
{
$patch = redefine('Auth::getCurrentUserId', function () {
return 42; // 回傳假的 user ID
});

$service = new OrderService();
$result = $service->createOrder(['item' => 'foo']);

$this->assertTrue($result);

restore($patch);
}

搭配 tearDown 自動還原

每次手動 restore 容易忘記,建議在 tearDown 裡統一處理:

1
2
3
4
5
protected function tearDown(): void
{
\Patchwork\restoreAll(); // 還原所有被替換的函式
parent::tearDown();
}

使用時的注意事項

  • Patchwork 要比 autoload 更早載入,不然無法攔截到相關函式
  • 測試結束後記得 restorerestoreAll(),避免影響其他測試
  • 這個工具主要適合過渡期使用,長遠來說還是要慢慢重構程式碼

結論

如果專案裡已經大量依賴全域函式,短期內又沒辦法重構,Patchwork 至少能先把測試補起來,讓老舊程式碼的調整有一些簡單的保護,之後再分階段再慢慢處理掉那些難測試的部分

參考資料