「這支測試已經綠燈很久了,應該沒問題吧?」
這句話最危險的地方在於,它把「測試綠燈」直接等同於「這段邏輯真的被驗證過」。今天要拆一個真實發生過的案例:一支存在了一段時間、每次跑都綠燈的 unit test,其實從來沒有真的驗證過它宣稱在測的東西。找出這件事之後,怎麼把它換成一支真的會保護你的測試。
config() 切換套件行為,在測試裡重現「舊資料」的真實起始狀態這個系統用 spatie/laravel-media-library 管理檔案上傳,早期檔案路徑是套件預設的 {media_id}/檔名,後來改成自訂的 {model類型}/{model_id}/{media_id}/檔名(方便直接從路徑看出這個檔案屬於哪個 Model、哪一筆資料)。因為既有正式環境的檔案還停留在舊路徑,需要一支遷移指令把它們搬到新格式。
第一版的測試長這樣(節錄核心邏輯):
function createMediaRecord(string $modelType, int $modelId, int $mediaId): Media
{
return Media::forceCreate([
'id' => $mediaId,
'model_type' => $modelType,
'model_id' => $modelId,
'collection_name' => 'default',
'file_name' => 'test.jpg',
// ...其餘欄位手動填齊
]);
}
test('dry-run shows plan without moving files', function () {
$media = createMediaRecord('App\\Models\\Banner', 1, 100);
Storage::disk('public')->put('100/test.jpg', 'content');
$this->artisan('media:migrate-paths', ['--dry-run' => true])
->expectsOutputToContain('Would move')
->assertSuccessful();
});
這支測試會通過。但它從頭到尾沒有真的呼叫過 addMedia(),沒有真的觸發 Media Library 套件的檔案上傳流程,資料庫裡那筆 Media 紀錄是用 forceCreate() 手動塞出來的假資料。它驗證的只有「遷移指令看到資料庫裡有一筆 model_type/model_id/id 符合條件的紀錄時,會不會印出對的訊息」——這件事本身沒錯,但完全沒有驗證過「套件真的用舊版 PathGenerator 產生檔案時,路徑格式是不是真的長這樣」。
Media Library 套件的路徑產生邏輯是套件內部實作,不是這個專案自己寫的字串組合。手動塞一筆 model_type/model_id/id 符合條件的資料庫紀錄,只能驗證「遷移指令的查詢條件寫對了」,沒辦法驗證「套件真的用舊版 PathGenerator 產生檔案時,路徑格式是不是真的長這樣」。如果套件本身的路徑格式改了,或者這個專案對套件配置的理解本來就有落差,這支測試完全不會發現。
測試通過,不等於它驗證了你以為它在驗證的事——尤其當測試資料是手動塞出來的,而不是真的走過一次完整流程。
function createMediaRecord(string $modelType, int $modelId, int $mediaId): Media
{
return Media::forceCreate([
'id' => $mediaId,
'model_type' => $modelType,
'model_id' => $modelId,
// 手動填齊套件要求的每一個欄位
'collection_name' => 'default',
'file_name' => 'test.jpg',
'mime_type' => 'image/jpeg',
'disk' => 'public',
// ...
]);
}
問題:這支輔助函式假設「套件的資料庫紀錄長什麼樣」,卻從來沒有驗證過這個假設是不是真的正確——它繞過了套件本身的邏輯,直接偽造了套件本該產生的結果。
function uploadWithDefaultGenerator(callable $fn): void
{
config(['media-library.path_generator' => DefaultPathGenerator::class]);
app()->forgetInstance('path_generator');
$fn();
config(['media-library.path_generator' => ModelPathGenerator::class]);
}
test('dry-run shows plan without moving files', function () {
uploadWithDefaultGenerator(function () {
$banner = Banner::factory()->create();
$banner->addMedia(UploadedFile::fake()->image('hero.jpg'))
->toMediaCollection('default');
$this->test_media = $banner->getFirstMedia();
});
$oldPath = "{$this->test_media->id}/hero.jpg";
Storage::disk('public')->assertExists($oldPath);
$this->artisan('media:migrate-paths', ['--dry-run' => true])
->expectsOutputToContain('Would move')
->assertSuccessful();
// dry-run 不該真的搬動檔案:舊路徑還在,新路徑還沒出現
Storage::disk('public')->assertExists($oldPath);
});
這個版本先把套件的路徑產生規則暫時切回原本的舊版設定,真的呼叫 addMedia()->toMediaCollection() 觸發套件本身的上傳邏輯,讓套件自己產生一筆「舊路徑」的真實檔案跟資料庫紀錄——這才是一個「真的需要被遷移」的起始狀態。驗證方式也從「指令有沒有印出對的字」,換成「磁碟上的檔案有沒有真的還在舊路徑、還沒被搬到新路徑」,直接檢查系統的實際狀態,而不是只看指令的輸出文字。
有讀者可能會問:為什麼不能一開始就用新版 ModelPathGenerator 上傳,測「已經是新格式的檔案不會被誤搬」就好?因為這支指令要解決的問題,本質是「處理歷史遺留的舊格式資料」——如果測試資料從一開始就是新格式,等於跳過了這支指令存在的理由。uploadWithDefaultGenerator() 這個輔助函式,就是為了在測試裡精確重現「正式環境裡那些還沒被遷移的舊檔案」長什麼樣,上傳完再把設定切回來,確保後續測試不會被污染。
回想你維護的專案裡,有沒有測試是用「手動塞一筆資料庫紀錄」的方式,去模擬某個套件或某段邏輯本該產生的結果?如果那個套件的行為改變了,你的測試會不會完全沒感覺?
config() 切換套件行為、真的走一次上傳流程,才能重現「正式環境的舊格式資料」這個起始狀態明天要看跟今天很像、但層次不同的測試:不牽扯遷移指令本身,只驗證「這個 Model、這一筆資料,上傳檔案後該產生什麼路徑」——把一個小單位的行為先確定下來,再去測包住它的整個流程。