「這筆測試需要的資料,migration 裡本來就有塞了,直接拿來用不就好了?」
這句話聽起來很省事——反正資料已經存在,何必再多寫一段 Factory 呼叫?但這個系統的 commit 歷史裡,有一次明確的重構,把原本這樣寫的測試全部改掉。今天要講的就是這次重構背後的判斷:測試資料的來源,看起來只是「從哪裡拿」的技術細節,其實牽涉到你的測試到底在驗證什麼。
在資料庫遷移檔案(migration)裡,除了定義表格結構,技術上也可以順手塞一些預設資料——例如系統一啟動就該存在的分類、預設角色、初始設定值。這種寫法的動機通常很直接:反正這些資料每個環境都需要,寫在 migration 裡,資料庫建好的同時資料也一起有了,省得每個環境都要另外手動初始化。
問題出在,如果你的測試剛好也需要類似的資料,順手就直接拿 migration 塞好的那筆來用,會產生一個不容易第一時間發現的耦合:這支測試能不能通過,變成間接依賴「這個 migration 有沒有真的執行過、執行的內容有沒有被改過」。
這件事平常不會出問題,因為測試環境跑測試前,理所當然會先跑過所有 migration。但當這種依賴悄悄變多、變得沒人記得哪些測試依賴了哪些 migration 塞的資料時,麻煩就會在你最想不到的時候出現——例如:
這個系統的 commit 歷史裡,有一次明確標注「移除 migration 中的資料建立邏輯,測試改用 Factory 建立資料」的重構。這次改動做的事情,表面上是把幾行程式碼從 migration 檔案搬到測試檔案,但本質上是在做一件更重要的事:把「系統初始化需要的資料」跟「這支測試需要驗證的資料」,徹底切成兩件不相關的事。
改完之後:
這個切分帶來的好處,在測試失敗的當下最明顯:當一支測試失敗,你能百分之百確定失敗原因跟這支測試自己建的資料、自己驗證的邏輯有關,不需要往上追查「是不是哪支 migration 被改過」。測試的獨立性,直接反映在除錯效率上。
// migration 裡順手塞了一筆預設分類
Schema::create('categories', function (Blueprint $table) {
// ...
});
DB::table('categories')->insert(['name' => '一般公告']);
// 測試直接假設這筆資料存在
test('displays announcement under default category', function () {
$category = Category::where('name', '一般公告')->first(); // 隱性依賴 migration 內容
$announcement = Announcement::factory()->create(['category_id' => $category->id]);
get('/announcements')->assertSee($announcement->title);
});
這支測試能不能通過,取決於 migration 裡那筆資料是否還在、名稱是否還沒被改過——測試檔案裡完全看不出這層依賴,直到有一天 migration 被調整、這支測試莫名其妙紅了,才會發現問題出在別的地方。
// migration 只定義結構,不塞資料
Schema::create('categories', function (Blueprint $table) {
// ...
});
// 測試自己建立需要的資料,不假設任何預設資料存在
test('displays announcement under its category', function () {
$category = Category::factory()->create(['name' => '一般公告']);
$announcement = Announcement::factory()->create(['category_id' => $category->id]);
get('/announcements')->assertSee($announcement->title);
});
這支測試需要的每一筆資料,來源都寫在測試檔案裡,一目瞭然。就算日後有人動了 migration、或是有人在別的地方新增了同名分類,都不會影響這支測試的結果——它只依賴自己建立的那一筆。
這裡要澄清一件事:migration 裡完全不能塞資料嗎?也不是絕對。有些系統確實需要「一啟動就必須存在」的資料(例如權限系統的基礎角色、狀態機的固定狀態值),這類資料本質上是系統結構的一部分,不是業務資料,塞在 migration 裡(或專門的 seeder)合情合理。
真正的判斷標準是:這筆資料,測試該不該依賴它?如果答案是「不該」,那不管它塞在哪裡,測試都應該自己建立需要的版本,不要假設環境裡已經有了。這個系統的重構做的正是這件事——把測試跟這類初始化資料的依賴關係徹底切開,而不是禁止 migration 塞資料本身。
回想你手上維護的測試套件,有沒有哪支測試隱性依賴著 migration 或 seeder 塞好的資料,卻沒有在測試檔案裡明確寫出來?如果現在把那筆 migration 資料改個名字,你有多大把握能立刻猜到會有哪些測試因此失敗?
明天要看這個系統裡兩支真正的純邏輯 Unit 測試,看它們怎麼在「完全不碰資料庫」跟「需要真的查一次資料庫」之間,畫出一條清楚的界線。