「這支測試要不要用 RefreshDatabase?」
這是一個看起來很小、但每次寫 Unit 測試都會遇到的判斷。今天要用這個系統裡兩支真正的純邏輯 Unit 測試,把這條界線畫清楚:同樣是測 Model 的邏輯,有的完全不需要碰資料庫,有的非碰不可,差別在於這段邏輯本身依不依賴「已經存在資料庫裡的東西」。
RefreshDatabase」的具體標準這個系統裡有一個 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 網址、空值)分別驗證,每一個測試案例執行時間都是毫秒級——因為根本沒有任何資料庫互動發生。這正是「純邏輯」測試的標準樣貌:輸入決定輸出,跟外部狀態無關。
同一個系統裡,另一個 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——每次執行前資料庫要先清空,確保這次寫入的測試資料是唯一存在的,查詢結果才不會被上一個測試案例留下的髒資料干擾。
把這兩支測試放在一起看,可以整理出一條清楚的判斷線:
new 一個 Model 實例塞值進去就夠了。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 建立資料?把它改回純記憶體物件建構,執行時間會差多少?
new 一個 Model 實例塞值測試,完全不需要碰資料庫RefreshDatabase 是必要的明天要講一次真實發生過的回歸:一支排序邏輯的測試補上之後,才發現舊邏輯早就被改壞了——測試不是為了通過而寫,是為了抓出早就存在、卻沒人發現的問題。