iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0
Software Development

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

Day 05:測試資料建置的取捨——migration 內建資料 vs factory 建立

  • 分享至 

  • xImage
  •  

前言

「這筆測試需要的資料,migration 裡本來就有塞了,直接拿來用不就好了?」

這句話聽起來很省事——反正資料已經存在,何必再多寫一段 Factory 呼叫?但這個系統的 commit 歷史裡,有一次明確的重構,把原本這樣寫的測試全部改掉。今天要講的就是這次重構背後的判斷:測試資料的來源,看起來只是「從哪裡拿」的技術細節,其實牽涉到你的測試到底在驗證什麼

今日目標

  • 理解「測試依賴 migration 塞的預設資料」這種寫法,實際上耦合了什麼
  • 看一次真實的重構 commit,理解「移除 migration 裡的資料建立邏輯,改用 Factory」解決了什麼問題
  • 學到一個判斷原則:測試資料的建立方式,要跟它驗證的對象保持獨立
  • 認識這個取捨在真實維護場景裡會怎麼咬人

Migration 塞資料,看起來方便,其實是隱性耦合

在資料庫遷移檔案(migration)裡,除了定義表格結構,技術上也可以順手塞一些預設資料——例如系統一啟動就該存在的分類、預設角色、初始設定值。這種寫法的動機通常很直接:反正這些資料每個環境都需要,寫在 migration 裡,資料庫建好的同時資料也一起有了,省得每個環境都要另外手動初始化。

問題出在,如果你的測試剛好也需要類似的資料,順手就直接拿 migration 塞好的那筆來用,會產生一個不容易第一時間發現的耦合:這支測試能不能通過,變成間接依賴「這個 migration 有沒有真的執行過、執行的內容有沒有被改過」

這件事平常不會出問題,因為測試環境跑測試前,理所當然會先跑過所有 migration。但當這種依賴悄悄變多、變得沒人記得哪些測試依賴了哪些 migration 塞的資料時,麻煩就會在你最想不到的時候出現——例如:

  • 有人調整了 migration 裡塞的預設資料內容(改了名稱、改了數量),一批原本無關的測試突然開始失敗,而失敗訊息完全看不出跟這次改動有關係
  • 你想單獨對某支 migration 做 rollback 測試,或想確認「如果拿掉這筆預設資料,系統會不會壞」,卻發現有一堆測試會連帶爆炸,你分不清哪些是真的依賴這筆資料、哪些只是意外撞上

這次重構真正做的事:切斷「測試資料」跟「初始化資料」的關係

這個系統的 commit 歷史裡,有一次明確標注「移除 migration 中的資料建立邏輯,測試改用 Factory 建立資料」的重構。這次改動做的事情,表面上是把幾行程式碼從 migration 檔案搬到測試檔案,但本質上是在做一件更重要的事:把「系統初始化需要的資料」跟「這支測試需要驗證的資料」,徹底切成兩件不相關的事

改完之後:

  • migration 檔案只負責它該負責的事——定義資料庫結構,不再夾帶業務資料
  • 每一支需要測試資料的測試,自己用 Factory 明確建立它需要的那一筆,不假設「反正 migration 應該已經有了」

這個切分帶來的好處,在測試失敗的當下最明顯:當一支測試失敗,你能百分之百確定失敗原因跟這支測試自己建的資料、自己驗證的邏輯有關,不需要往上追查「是不是哪支 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 被調整、這支測試莫名其妙紅了,才會發現問題出在別的地方。

✅ 測試自己用 Factory 建立需要的資料

// 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 裡完全不能塞資料嗎?也不是絕對。有些系統確實需要「一啟動就必須存在」的資料(例如權限系統的基礎角色、狀態機的固定狀態值),這類資料本質上是系統結構的一部分,不是業務資料,塞在 migration 裡(或專門的 seeder)合情合理。

真正的判斷標準是:這筆資料,測試該不該依賴它?如果答案是「不該」,那不管它塞在哪裡,測試都應該自己建立需要的版本,不要假設環境裡已經有了。這個系統的重構做的正是這件事——把測試跟這類初始化資料的依賴關係徹底切開,而不是禁止 migration 塞資料本身。

今日思考題

回想你手上維護的測試套件,有沒有哪支測試隱性依賴著 migration 或 seeder 塞好的資料,卻沒有在測試檔案裡明確寫出來?如果現在把那筆 migration 資料改個名字,你有多大把握能立刻猜到會有哪些測試因此失敗?

今日重點回顧

  • 測試依賴 migration 塞的預設資料,看起來省事,實際上讓測試結果隱性耦合到「migration 有沒有被改過」
  • 這個系統的重構把「系統初始化資料」跟「測試需要的資料」徹底切開,每支測試自己用 Factory 建立需要的資料
  • 這個切分讓測試失敗時的除錯範圍收斂到測試自己身上,不用往上追查是不是別的地方被動過
  • 判斷標準不是「migration 能不能塞資料」,而是「測試該不該依賴這筆資料」

明日預告

明天要看這個系統裡兩支真正的純邏輯 Unit 測試,看它們怎麼在「完全不碰資料庫」跟「需要真的查一次資料庫」之間,畫出一條清楚的界線。


上一篇
Day 04:Model Factory 語意化設計,測試資料怎麼建立才不會互相污染
下一篇
Day 06:Model 的兩種純邏輯單元測試——accessor 跟 Query Scope
系列文
同一套 Laravel 系統,測試怎麼寫才不會說謊14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言