iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Software Development

同一套 Laravel 系統,測試怎麼寫才不會說謊系列 第 25 篇

Day 25:一套測試模式,套用在 6 支不同的 SOAP 同步 Job 上

  • 分享至 

  • xImage
  •  

前言

「這 6 支 Job 的測試看起來幾乎一模一樣,是不是在偷懶複製貼上?」

第一次看到這種情況,很容易直覺懷疑是不是該抽一個共用的測試基底類別。但今天想討論一個不同的角度:當好幾支測試「看起來很像」,重點不是急著去重複,而是先搞清楚——它們像,是因為測的東西本質上就是同一種模式,還是只是巧合地長得像?

今日目標

  • 看一套測試模式,怎麼被套用在 6 支結構不同、但驗證方式相同的 Job 上
  • 理解「同一套測試模式」跟「互相隔離的測試」是兩個不同的概念,不要搞混
  • 比較單筆同步 Job 跟批次分頁同步 Job,測試寫法上到底差在哪裡
  • 培養判斷力:什麼時候該為了 DRY 去抽共用邏輯,什麼時候該接受「看起來像」是合理的

這套系統裡的 SOAP 同步家族

這個系統要跟外部校務系統交換資料,具體工作分散在好幾支 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() 綁定測試替身——差別只在驗證方式從「這一筆資料對不對」換成「總共寫入了幾筆資料」,因為批次同步關心的是「這一頁有沒有全部抓完」,不是某一筆特定資料。

為什麼這 6 支測試「看起來一樣」是合理的

把這兩支測試放在一起看,會發現它們的骨架完全相同:鎖定時間 → 用對應的 cassette 建立測試替身 → 綁定容器 → 執行 Job → 驗證資料庫最終狀態。這不是巧合,而是因為這 6 支 Job 本質上都在解決同一種問題——跟同一個外部 SOAP 系統交換資料,寫進資料庫。它們真正的差異,藏在 app 邏輯本身:單筆 Job 的建構子吃一個帳號代碼,批次 Job 的建構子吃分頁參數,內部還多包了一層迭代邏輯處理分頁遊標。測試寫法會反映出來的相似度,是被測邏輯本身相似度的忠實映照——如果測試看起來很像,先確認是不是因為它們真的在測同一種模式,不是憑空長得像。

那該不該抽一個共用的測試基底?

看到 6 支測試共用同一套骨架,很直覺會想抽一個共用的 Trait 或基底類別,把「鎖定時間」「綁定測試替身」這些重複步驟收斂掉。這個系統目前沒有這樣做——沒有一支獨立的重構 commit 是在做這件事,這 6 支測試是各自寫好的,測試寫法上完全沒有互相依賴。

這其實是一個合理的取捨:這幾支測試步驟少(通常 3-5 行),每一支都直接看得懂在測什麼,抽出共用邏輯換來的重複程式碼減少,可能比不上「每支測試檔案要跳去看另一個共用基底才知道發生什麼事」付出的代價。測試裡的重複程式碼,不是天生就該被消滅的東西——當重複的部分很短、很直觀,抽出來的共用邏輯反而增加了理解成本時,保留重複往往是更誠實的選擇。

今日思考題

回想你維護的專案,有沒有幾支測試「看起來很像」,你直覺想把它們合併成一個共用的測試基底?合併之前,有沒有先確認過它們是不是真的在測同一種模式,還是只是外觀相似?

今日重點回顧

  • 6 支不同的 SOAP 同步 Job,測試骨架高度相似,這反映的是被測邏輯本身的相似度,不是巧合
  • 單筆同步 Job 驗證單一結果,批次分頁同步 Job 驗證總筆數,差異對應到 app 邏輯本身的差異
  • 測試看起來像,先確認是不是真的在測同一種模式,再決定要不要抽共用邏輯
  • 這個系統選擇保留 6 支各自獨立的測試,因為重複的部分短且直觀,抽共用邏輯反而增加理解成本

明日預告

明天要往回拆昨天跟今天都提到的測試替身——FakeHttpClient 跟 FakeCaller 的原始碼本身,看它們怎麼被設計出來,特別是「假資料播完了怎麼辦」這個容易被忽略的細節。


上一篇
Day 24:時間相依的 Job 怎麼測——用 Carbon::setTestNow() 鎖定測試當下時間
下一篇
Day 26:Fake 測試替身的實作細節——FakeHttpClient/FakeCaller 原始碼拆解
系列文
同一套 Laravel 系統,測試怎麼寫才不會說謊 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言