iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
Software Development

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

Day 27:雙版本 API 測試——複製貼上再各自修改,該不該抽共用邏輯?

  • 分享至 

  • xImage
  •  

前言

「這兩支測試檔案根本是複製貼上的,程式碼重複這麼多,不用重構嗎?」

寫測試的人多半都被教育過要遵守 DRY 原則,看到重複的程式碼會反射性地想抽共用邏輯。但今天要用一個真實案例,討論一個常被跳過的前置問題:在動手重構之前,先搞清楚這些重複是「意外造成的」,還是「兩邊本來就該長不一樣,只是剛好用了相似的骨架」。

今日目標

  • 看兩套介面版本(V1/V2)各自的 sitemap 測試,理解它們哪裡像、哪裡不像
  • 學會分辨「結構重複」跟「內容重複」是兩件不同的事
  • 練習在「抽共用邏輯」跟「保留獨立測試」之間做出有依據的判斷,而不是憑直覺
  • 認識這個系統目前的選擇,以及為什麼這是一個合理但沒有標準答案的取捨

兩套 sitemap 測試,看起來很像

這個系統的前台同時維護兩個介面版本,兩邊都有自己的 sitemap(網站導覽)頁面測試。V1 版本的測試長這樣:

describe('V1 Sitemap', function () {
    test('page loads successfully', function () {
        Page::factory()->published()->childOf(1)->count(5)->create();

        $response = get('/sitemap');

        expect($response->status())->toBe(200)->toMatchRoute('sitemap');
    });

    test('displays page hierarchy', function () {
        $page1 = Page::factory()->published()->childOf(1)
            ->create(['name' => '關於我們']);
        $page2 = Page::factory()->published()->childOf($page1->id)
            ->create(['name' => '我們的團隊']);

        Page::fixTree();

        $response = get('/sitemap');

        $response->assertSee('關於我們');
        $response->assertSee('我們的團隊');
    });

    test('uses v1 view', function () {
        $response = get('/sitemap');
        $response->assertViewIs('sitemap');
    });
});

V2 版本則是這樣:

describe('V2 Sitemap', function () {
    test('page loads successfully', function () {
        $parent = Page::factory()->published()->childOf(1)
            ->create(['name' => '關於我們', 'slug' => 'about']);
        Page::factory()->published()->childOf($parent->id)
            ->create(['name' => '網站簡介', 'slug' => 'intro']);

        Page::fixTree();

        $response = get('/sitemap');

        $response->assertOk();
        $response->assertSee('網站導覽');
    });

    test('displays page tree', function () {
        $parent = Page::factory()->published()->childOf(1)
            ->create(['name' => '關於我們', 'slug' => 'about']);
        Page::factory()->published()->childOf($parent->id)
            ->create(['name' => '網站簡介', 'slug' => 'intro']);

        Page::fixTree();

        $response = get('/sitemap');

        $response->assertSee('關於我們');
        $response->assertSee('網站簡介');
    });
});

骨架一樣,內容完全不同

乍看之下兩支測試檔案結構幾乎一致:都用 describe() 包住幾個 test(),都建立頁面資料、都呼叫同一個 /sitemap 路由。但仔細比對斷言內容,會發現差異其實不小:

  • V1 用 toMatchRoute('sitemap') 驗證路由,V2 直接用 assertOk()
  • V1 有一支獨立測試驗證 assertViewIs('sitemap')(確認用的是哪個 view 檔案),V2 完全沒有這個測試
  • V1 的頁面樹只建兩層,V2 額外驗證了「巢狀分類」的顯示效果
  • 兩邊建立測試資料時用的頁面名稱、資料結構也不完全一樣

兩支測試檔案「看起來很像」是因為它們測的是同一個功能(sitemap 頁面),但「像」的地方停留在骨架層級——真正的斷言內容,是各自根據 V1、V2 介面版本的實際行為差異寫出來的,不是無腦複製貼上就結束。

那到底該不該抽共用邏輯

這個系統目前的選擇是:不抽。兩支測試檔案完全獨立,沒有共用的 helper 或 shared example。這不是疏忽,而是可以理解的取捨——V1、V2 兩套介面版型本來就不一樣(連拿到的 view、驗證的細節都不同),如果硬要抽一層共用邏輯,很可能只能抽出「建立頁面資料」這種最外層、最不重要的部分,真正核心的斷言邏輯——因為兩邊行為本來就不同——還是得各自寫。過度抽象化「表面上相似」的測試,反而可能讓每支測試都要多繞一層去理解共用邏輯在幹嘛,讀起來比兩支各自獨立、一眼看穿在測什麼的版本更難懂。

這不代表「永遠不該抽共用邏輯」——如果兩套版本的核心驗證邏輯本身完全一致,只是套用在不同路由上,抽出共用的 shared example 就會是合理的選擇。判斷的關鍵不在「程式碼看起來像不像」,而在「兩邊在驗證的『行為』本身,是不是真的同一件事」。

一個判斷框架

面對「要不要抽共用測試邏輯」這個問題,可以先問自己兩個問題:

  1. 重複的部分,是測試資料建置的樣板(例如 factory 呼叫),還是核心的驗證邏輯本身? 前者抽出來的效益通常有限;後者如果真的重複,才值得認真考慮共用。
  2. 如果其中一邊未來改變了驗證邏輯,另一邊會不會也該跟著改? 如果答案是「不會,兩邊本來就該獨立演化」,那麼保留重複、讓每支測試自己完整可讀,通常是更誠實的選擇。

今日思考題

回想你維護的專案裡,有沒有兩支測試檔案因為「看起來很像」被你(或別人)強行抽成共用邏輯,結果讀起來反而比兩支各自獨立的版本更難懂?

今日重點回顧

  • V1、V2 兩套 sitemap 測試骨架相似,但核心斷言內容因應介面版本差異而各自不同
  • 「結構重複」不等於「內容重複」,判斷該不該抽共用邏輯,要看重複的是樣板還是核心驗證邏輯
  • 這個系統選擇讓兩套版本的測試各自獨立,因為過度抽象化反而增加理解成本
  • 判斷框架:問自己「重複的是樣板還是核心邏輯」以及「兩邊未來會不會各自獨立演化」

明日預告

明天要講一個更值得注意的測試覆蓋盲點:一個曾經真的出過 Content-Type bug、線上事故等級的路由,反而找不到對應的 Feature 測試直接驗證它。


上一篇
Day 26:Fake 測試替身的實作細節——FakeHttpClient/FakeCaller 原始碼拆解
下一篇
Day 28:一個曾經出過 Content-Type bug 的路由,測試覆蓋盲點在哪裡
系列文
同一套 Laravel 系統,測試怎麼寫才不會說謊 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言