一年前寫了〈從 Schema Dumps 到 Migrations〉,記錄一個看似簡單的任務如何吃掉一整天:把正式環境的 schema dump 載入 Orchestra Testbench 的測試環境。當時的結論是:不要直接執行 dump 檔,改把它包進一個 migration 裡。
最近在替另一個套件建立測試環境時,我又走了一次同樣的路 — 這次沒有急著繞過去,而是把 migrate 內部實際發生的事一路追完。當年的診斷依然成立,當年的解法也依然有效。但如果今天重做一次,我會改用一個 Laravel 從頭到尾都內建、而且明文寫在官方文件裡的機制。這篇後記記錄的就是這個轉變。
當初說對的部分
當年找到的根因是對的:schema dump 不能在 migration 生命週期之外執行,它必須進入生命週期之內。在 setUp() 裡呼叫 DB::unprepared() 之所以失敗,追到底全是時機問題:
- 它會在每一個測試執行一次,而不是每個 process 一次。
- 它跟
RefreshDatabase打架:dump 裡的 DDL 語句會觸發 MySQL 的 implicit commit,把 trait 包住測試的 transaction 提前結束,測試之間開始互相汙染。 PDO::exec()收到多語句字串時只回報第一句的錯誤 — dump 中段壞掉會無聲死去,留下半套匯入的 schema。
把 dump 包進 0000_00_00_000000_import_schema.php 之所以有效,正是因為它解掉了兩個時機問題:migrations 每個 process 只跑一次,而且跑在 RefreshDatabase 替每個測試開 transaction 之前。而事後看來,這也正是 Laravel 自己的機制在做的事。
當年漏掉的:Laravel 已經內建
Laravel 的 migration 官方文件在 Squashing Migrations 一節,記錄著:
When you attempt to migrate your database and no other migrations have been executed, Laravel will first execute the SQL statements in the schema file of the database connection you are using. After executing the schema file's SQL statements, Laravel will execute any remaining migrations that were not part of the schema dump.
文件甚至明確提到測試情境 —「so that your tests are able to build your database」— 也註明這個功能使用資料庫的 command-line client 執行。
當初沒有意識到的事:
- 載入是靠慣例路徑觸發的:
database_path('schema/{連線名}-schema.sql')。 - 它在 migrations table 為空時觸發 — 在
migrate:fresh之下,等於每個 process 恰好一次,時間點在RefreshDatabase開始用 transaction 包測試之前。 - 檔案是由 mysql CLI client 執行的,不是 PDO。
換句話說:當初打造的那個方式,其實是內建功能,只是執行通道不同。
為什麼在 Testbench 裡看起來不可用
這個機制在套件測試裡看起來派不上用場,是有原因的。Testbench 執行時的「應用程式」是 vendor/orchestra/testbench-core/laravel/ 裡的骨架,所以 database_path() 解析到的是一個你不能放檔案進去的目錄 — Composer 隨時會把它砍掉重建。
缺的設定只有一行。
Illuminate\Foundation\Application 有一個 useDatabasePath() 方法,可以把 database_path()(以及 container 裡的 path.database 綁定)改指到任何地方:
<?php
declare(strict_types=1);
namespace Vendor\Package\Tests;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Orchestra\Testbench\TestCase as Orchestra;
use Vendor\Package\PackageServiceProvider;
abstract class TestCase extends Orchestra
{
use RefreshDatabase;
protected function getPackageProviders($app): array
{
return [
PackageServiceProvider::class,
];
}
protected function defineEnvironment($app): void
{
$app->useDatabasePath(__DIR__.'/database');
}
}tests/
├── TestCase.php
└── database/
└── schema/
└── mysql-schema.sql資料庫設定照舊在 phpunit.xml.dist,跟之前完全一樣。useDatabasePath() 沒有出現在官方文件的紀錄裡,但它是框架自己也依賴的穩定公開 API。其餘的一切 — 載入條件、執行順序、與 RefreshDatabase 的互動 — 都是 Laravel 有文件說明的,可以在 Testbench 開機的那個真正的 Laravel 應用程式裡照常運作。
差異
當初的 migration 包裝法和原生路徑,都會在生命週期的正確時間點把 dump 載入一次。差別在於由誰執行這些 SQL:我的方式會把整個檔案交給 PDO::exec();Laravel 的 MySqlSchemaState::load() 則是交給真正的 client — mysql ... < schema.sql。這個差別可能導致:
錯誤回報。 PDO::exec() 會吞掉多語句字串裡第一句之後的錯誤,所以第 500 句失敗時,留下的是半套 schema 和一堆指向錯誤方向的測試失敗。CLI 則是逐句執行、大聲失敗,並以非零的 exit code 結束。
檔案大小。 file_get_contents() 把整份 dump 讀進 PHP 記憶體,再當成單一巨型封包送出 — 一邊受 memory_limit 限制,另一邊受伺服器的 max_allowed_packet 限制。CLI 是 streaming。
它跟 Workbench 也能疊加
如果測試加上了 WithWorkbench,執行順序是 testbench-core 保證的:
- workbench 的 migration 路徑和 seeders 先註冊,
- 然後
RefreshDatabase執行migrate:fresh— 先載入 dump,再跑剩餘的 migrations(包括 workbench 的) - 最後在
DatabaseRefreshed事件上跑 seeders。Workbench 的素材乾淨地疊在 dump 之上。
一個需要注意的是:schema 檔必須帶著 migrations table 的狀態 — schema:dump 產出的檔案有。否則每個 migration 看起來都沒跑過,會全部重跑一遍,會跟已經建好的資料表衝突。
一年後的教訓
當初看起來像缺少的功能,其實只是路徑指錯了方向。