iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
Software Development

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

Day 06:Model 的兩種純邏輯單元測試——accessor 跟 Query Scope

  • 分享至 

  • xImage
  •  

前言

「這支測試要不要用 RefreshDatabase?」

這是一個看起來很小、但每次寫 Unit 測試都會遇到的判斷。今天要用這個系統裡兩支真正的純邏輯 Unit 測試,把這條界線畫清楚:同樣是測 Model 的邏輯,有的完全不需要碰資料庫,有的非碰不可,差別在於這段邏輯本身依不依賴「已經存在資料庫裡的東西」。

今日目標

  • 看一支完全不碰資料庫的 Unit 測試,理解它為什麼可以這樣寫
  • 看一支需要真的查資料庫才能驗證的 Unit 測試,理解它跟前者的本質差異
  • 學到判斷「這段邏輯需不需要 RefreshDatabase」的具體標準
  • 認識這種界線劃分對測試執行速度的實際影響

第一種:不用碰資料庫也能測的 accessor

這個系統裡有一個 Model 帶了一個 accessor,負責把使用者填入的一段連結,解析出對應的 YouTube 影片 ID——不管使用者貼的是完整網址、短網址,還是根本不是 YouTube 連結,這個 accessor 都要回傳一致、可預期的結果。

測試這種邏輯時,關鍵的觀察是:這個 accessor 要做的事情,從頭到尾只跟「這個 Model 實例的某個欄位值」有關,不需要知道這筆資料有沒有真的存在資料庫裡、有沒有關聯到別的資料表。換句話說,你完全可以不呼叫任何資料庫寫入操作,直接在記憶體裡 new 一個 Model 實例、塞進要測的欄位值,就能驗證這個 accessor 的行為對不對:

test('resolves youtube id from full url', function () {
    $link = new QuickLink(['url' => 'https://www.youtube.com/watch?v=abc123']);

    expect($link->youtube_id)->toBe('abc123');
});

test('resolves youtube id from short url', function () {
    $link = new QuickLink(['url' => 'https://youtu.be/abc123']);

    expect($link->youtube_id)->toBe('abc123');
});

test('returns null for non-youtube url', function () {
    $link = new QuickLink(['url' => 'https://example.com']);

    expect($link->youtube_id)->toBeNull();
});

四種情境(完整網址、短網址、非 YouTube 網址、空值)分別驗證,每一個測試案例執行時間都是毫秒級——因為根本沒有任何資料庫互動發生。這正是「純邏輯」測試的標準樣貌:輸入決定輸出,跟外部狀態無關。

第二種:需要真的查資料庫的 Query Scope

同一個系統裡,另一個 Model 定義了一個 Query Scope,負責依類型篩選資料——例如只挑出特定類型的分類資料。

跟前面的 accessor 不同,這種邏輯天生就是為了跟資料庫查詢綁在一起而存在的。Query Scope 的本質是「幫你組出一段特定條件的 SQL 查詢」,你沒辦法在不真的查一次資料庫的情況下,驗證這段查詢邏輯到底對不對——就算你在記憶體裡 new 出十筆 Model 實例,Query Scope 也不會對它們產生任何作用,因為它操作的對象是資料庫查詢建構器,不是記憶體裡的物件集合。

驗證這種邏輯,測試必須先真的把資料寫進資料庫,再用 Scope 查一次,確認查出來的結果符合預期:

test('filters categories by type', function () {
    Category::factory()->create(['type' => 'link']);
    Category::factory()->create(['type' => 'video']);

    $results = Category::query()->byType('link')->get();

    expect($results)->toHaveCount(1);
});

test('returns empty collection for non-existent type', function () {
    Category::factory()->create(['type' => 'link']);

    $results = Category::query()->byType('nonexistent')->get();

    expect($results)->toBeEmpty();
});

這兩個測試案例都需要 RefreshDatabase——每次執行前資料庫要先清空,確保這次寫入的測試資料是唯一存在的,查詢結果才不會被上一個測試案例留下的髒資料干擾。

判斷標準:這段邏輯依不依賴「已經存在的資料庫狀態」

把這兩支測試放在一起看,可以整理出一條清楚的判斷線:

  • 如果這段邏輯只是根據「傳進來的值」計算出一個結果,不需要知道資料庫裡有什麼——不管是字串解析、格式轉換、單純的條件判斷——這種邏輯適合寫成不碰資料庫的純 Unit 測試,直接 new 一個 Model 實例塞值進去就夠了。
  • 如果這段邏輯的本質是「組出一段查詢條件」或「依賴資料庫裡已經存在的關聯資料」——例如 Query Scope、Eloquent 關聯方法、任何最終要靠 SQL 查詢才能得到結果的邏輯——這種邏輯必須真的寫入資料庫再查一次,RefreshDatabase 是必要的。

❌ 不需要資料庫的邏輯,卻硬要用 RefreshDatabase

uses(RefreshDatabase::class); // 這支測試檔案其實完全用不到

test('resolves youtube id from full url', function () {
    $link = QuickLink::factory()->create(['url' => 'https://www.youtube.com/watch?v=abc123']);
    // 明明只是測字串解析,卻透過 factory 真的寫進資料庫

    expect($link->youtube_id)->toBe('abc123');
});

這樣寫不會讓測試結果錯誤,但會讓每個測試案例都多付出一次不必要的資料庫寫入成本——當這種寫法散布在整個測試套件裡,累積起來會讓測試套件跑得比它應該要的速度慢很多。

✅ 依照邏輯本質決定要不要碰資料庫

// 不需要 RefreshDatabase:純邏輯計算
test('resolves youtube id from full url', function () {
    $link = new QuickLink(['url' => 'https://www.youtube.com/watch?v=abc123']);
    expect($link->youtube_id)->toBe('abc123');
});

// 需要 RefreshDatabase:依賴真實的查詢結果
test('filters categories by type', function () {
    Category::factory()->create(['type' => 'link']);
    expect(Category::query()->byType('link')->get())->toHaveCount(1);
});

同一個 tests/Unit 目錄底下,兩支測試檔案可以各自宣告要不要套用 RefreshDatabase,不需要整個 Unit 測試套件統一同一種設定——因為 Unit 測試本來就該按照被測邏輯的本質,各自做出最適合的選擇。

這條界線對測試套件速度的實際影響

Unit 測試存在的核心價值之一,就是「跑得快」——快到你可以在每次改動程式碼後立刻執行一次,得到即時回饋。如果 Unit 測試套件裡混雜了大量不必要的資料庫寫入,這個「跑得快」的優勢會被慢慢侵蝕掉,測試套件跑起來的體感速度會越來越接近 Feature 測試,卻沒有 Feature 測試該有的完整驗證範圍。

把不需要資料庫的邏輯,堅持寫成完全不碰資料庫的測試,是維持 Unit 測試套件「快」這個特性最基本、也最容易被忽略的紀律。

今日思考題

回想你手上維護的 Unit 測試套件,有沒有一支測試其實根本不需要碰資料庫,卻因為圖方便用了 factory 建立資料?把它改回純記憶體物件建構,執行時間會差多少?

今日重點回顧

  • 純邏輯 accessor(例如字串解析、格式轉換)可以直接 new 一個 Model 實例塞值測試,完全不需要碰資料庫
  • Query Scope 這類本質上是「組出查詢條件」的邏輯,必須真的寫入資料庫再查一次才能驗證,RefreshDatabase 是必要的
  • 判斷標準是「這段邏輯依不依賴已經存在的資料庫狀態」,不是「這是不是 Unit 測試」這種表面分類
  • 不必要的資料庫寫入會侵蝕 Unit 測試套件「跑得快」這個核心價值,是容易被忽略的效能陷阱

明日預告

明天要講一次真實發生過的回歸:一支排序邏輯的測試補上之後,才發現舊邏輯早就被改壞了——測試不是為了通過而寫,是為了抓出早就存在、卻沒人發現的問題。


上一篇
Day 05:測試資料建置的取捨——migration 內建資料 vs factory 建立
下一篇
Day 07:一次排序邏輯的回歸——Feature 測試補上後才抓到的舊 bug
系列文
同一套 Laravel 系統,測試怎麼寫才不會說謊14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言