「這 6 支 Job 的測試看起來幾乎一模一樣,是不是在偷懶複製貼上?」
第一次看到這種情況,很容易直覺懷疑是不是該抽一個共用的測試基底類別。但今天想討論一個不同的角度:當好幾支測試「看起來很像」,重點不是急著去重複,而是先搞清楚——它們像,是因為測的東西本質上就是同一種模式,還是只是巧合地長得像?
這個系統要跟外部校務系統交換資料,具體工作分散在好幾支 Job 裡:同步使用者身分資訊的、同步幕僚資料的(有單筆版本跟批次分頁版本兩種)、同步新聞負責分類的……全部走同一個 SOAP 通道,也都用同一套測試寫法。挑兩支最能看出差異的來對照。
beforeEach(function () {
Carbon::setTestNow(Carbon::parse('2023-09-03'));
seed(PermissionSeeder::class);
});
it('handle', function () {
$caller = new FakeCaller(fixture_path('vcr/jobs/sync_staff.yaml'));
swap(DataGatewayClient::class, new DataGatewayClient($caller));
SyncStaffJob::dispatchSync('net001');
assertDatabaseHas('users', ['username' => 'net001', 'name' => '測試帳號01']);
});
這支 Job 的建構子只吃一個帳號代碼,同步邏輯就是「查這一個人的資料,寫進資料庫」。測試也對應得很直接:呼叫一次,驗證一筆結果。
beforeEach(function () {
Carbon::setTestNow(Carbon::parse('2023-08-22'));
seed(PermissionSeeder::class);
});
it('handle', function () {
$caller = new FakeCaller(fixture_path('vcr/jobs/sync_staffs_offset_10800.yaml'));
swap(DataGatewayClient::class, new DataGatewayClient($caller));
SyncStaffsJob::dispatchSync(offset: 10800);
assertDatabaseCount('users', 62);
});
這支 Job(注意名字多了個 s)內部用分頁機制批次抓取所有人的資料,建構子吃的是分頁參數(每頁筆數、起始位移、排序方向)而不是單一帳號。測試的骨架跟上一支幾乎一模一樣——Carbon::setTestNow() 鎖定時間、FakeCaller 讀對應的 cassette、swap() 綁定測試替身——差別只在驗證方式從「這一筆資料對不對」換成「總共寫入了幾筆資料」,因為批次同步關心的是「這一頁有沒有全部抓完」,不是某一筆特定資料。
把這兩支測試放在一起看,會發現它們的骨架完全相同:鎖定時間 → 用對應的 cassette 建立測試替身 → 綁定容器 → 執行 Job → 驗證資料庫最終狀態。這不是巧合,而是因為這 6 支 Job 本質上都在解決同一種問題——跟同一個外部 SOAP 系統交換資料,寫進資料庫。它們真正的差異,藏在 app 邏輯本身:單筆 Job 的建構子吃一個帳號代碼,批次 Job 的建構子吃分頁參數,內部還多包了一層迭代邏輯處理分頁遊標。測試寫法會反映出來的相似度,是被測邏輯本身相似度的忠實映照——如果測試看起來很像,先確認是不是因為它們真的在測同一種模式,不是憑空長得像。
看到 6 支測試共用同一套骨架,很直覺會想抽一個共用的 Trait 或基底類別,把「鎖定時間」「綁定測試替身」這些重複步驟收斂掉。這個系統目前沒有這樣做——沒有一支獨立的重構 commit 是在做這件事,這 6 支測試是各自寫好的,測試寫法上完全沒有互相依賴。
這其實是一個合理的取捨:這幾支測試步驟少(通常 3-5 行),每一支都直接看得懂在測什麼,抽出共用邏輯換來的重複程式碼減少,可能比不上「每支測試檔案要跳去看另一個共用基底才知道發生什麼事」付出的代價。測試裡的重複程式碼,不是天生就該被消滅的東西——當重複的部分很短、很直觀,抽出來的共用邏輯反而增加了理解成本時,保留重複往往是更誠實的選擇。
回想你維護的專案,有沒有幾支測試「看起來很像」,你直覺想把它們合併成一個共用的測試基底?合併之前,有沒有先確認過它們是不是真的在測同一種模式,還是只是外觀相似?
明天要往回拆昨天跟今天都提到的測試替身——FakeHttpClient 跟 FakeCaller 的原始碼本身,看它們怎麼被設計出來,特別是「假資料播完了怎麼辦」這個容易被忽略的細節。