在應用程式的開發裡,程式碼的模組化與可測試性至關重要。當我開始把一個龐大複雜的舊系統,重構成以 PHP 8.4 為基礎的模組化套件時,我以為建立測試環境只是一個簡單的步驟。然而這個看似平凡的任務吃掉了我一整天,也讓我學到不少關於 Laravel 套件測試的寶貴經驗。
動機很單純:
- 把一個過度複雜的舊系統,重構成以 PHP 8.4 為基礎的模組化套件
- 逐步把功能搬進獨立的套件,讓系統更容易維護
- 為每個元件補上完整的測試,確保系統穩定
- 讓這些模組能在既有專案與未來的專案之間重複使用
因為需要與既有系統相容,測試環境必須使用跟正式環境相同的資料庫結構。最直覺的做法,就是用 Laravel 的 schema dump 功能把既有的資料庫結構匯出,再到測試環境裡重建。
第一次嘗試:載入 Schema Dump 的挑戰
我選擇了 Orchestra Testbench 作為測試工具。它能模擬出一個 Laravel 應用程式環境,非常適合套件開發。
我最初的計畫很簡單:
- 用 Laravel 的
php artisan schema:dump匯出資料庫結構 - 把
mysql-schema.sql檔案存到database/schema目錄 - 測試啟動時載入這個 schema 檔案
但我很快發現,事情沒有想像中那麼簡單。
環境變數之謎
第一個問題出現在讀取環境變數的時候:
// In TestCase.php
protected function defineEnvironment($app): void
{
tap($app['config'], function (Repository $config) {
$config->set('database.default', 'testing');
$config->set('database.connections.testing', [
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', 3306),
'database' => env('DB_DATABASE', 'testbench'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''), // Couldn't read password from .env!
'prefix' => '',
]);
});
}env() 函式讀不到我在 .env 檔裡設定的資料庫密碼,讓我一頭霧水。後來才知道,這其實是 Laravel 套件開發工具的預期行為。官方文件是這樣說的:
The env environment variables are only applied when using the CLI and will not be used when running tests.
Laravel Package Development 官方文件的這段說明,清楚解釋了為什麼我的環境變數不起作用 — 執行測試時它們根本不會被套用!
這代表 Orchestra Testbench 不會自動載入套件根目錄的 .env 檔,我得另想辦法。試了幾種做法之後,我最後直接用 Dotenv 來載入:
protected function setUp(): void
{
\Dotenv\Dotenv::createImmutable(__DIR__.'/../')->load();
parent::setUp();
// Other initialization...
}載入 Schema 的困境
解決環境變數問題之後,我遇到一個更棘手的問題:在 TestCase 裡載入 schema 檔案。我天真地以為這應該跟在一般的 Laravel 應用程式裡一樣簡單。事實並非如此。
我試著直接在 setUp 方法裡載入 schema:
protected function loadDBSchema()
{
$schemaPath = __DIR__.'/../database/schema/mysql-schema.sql';
if (file_exists($schemaPath)) {
\Illuminate\Support\Facades\DB::unprepared(file_get_contents($schemaPath));
}
}但這帶來一個令人困惑的錯誤:
RuntimeException: A facade root has not been set.這表示我使用 DB Facade 的時候,它還沒有被正確初始化。
更麻煩的是,解決了 Facade 問題之後,又撞上外鍵約束的問題:
QueryException: SQLSTATE[HY000]: General error: 1824 Failed to open the referenced table 'tenants'原因是 schema 檔案裡資料表的建立順序,跟外鍵約束的相依關係對不上。
RefreshDatabase 的真相
試了好幾輪之後,我開始懷疑是 RefreshDatabase trait 跟我的 schema 載入方式互相衝突。沒錯,這正是問題的核心。
// pest.php
pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('*');RefreshDatabase trait 會在測試過程中重設資料庫結構,這代表:
- 就算我成功載入了 schema,
RefreshDatabase也可能在之後把它重設掉 - 或者,
RefreshDatabase可能在我的 schema 載入之前就先初始化了資料庫
最終解法:用 Migrations 取代 Schema Dumps
Orchestra Testbench 其實提供了一條更好的路 — 使用標準的 Laravel migrations,而不是直接執行 schema dump 檔案。
關鍵是 Testbench 的 defineDatabaseMigrations 方法和 workbench_path 輔助函式:
/**
* Define database migrations.
*
* @return void
*/
protected function defineDatabaseMigrations()
{
$this->loadMigrationsFrom(
workbench_path('database/migrations')
);
}這個方法讓我可以把測試專用的 migrations 跟套件本身的 migrations 分開,放在 workbench/database/migrations 目錄裡。
具體來說,我建立了一個名為 0000_00_00_000000_import_schema.php 的 migration 檔案,確保它會第一個執行:
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
$schemaPath = __DIR__.'/../schema/mysql-schema.sql';
if (file_exists($schemaPath)) {
DB::unprepared(file_get_contents($schemaPath));
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};這個 migration 檔案簡單卻有效:它在 up() 方法裡讀取並執行 schema dump 檔案,down() 方法則什麼都不用做。包含:
- 在
workbench/database/migrations目錄建立 migration 檔案,重建與正式環境相同的資料庫結構 - 在
TestCase.php裡用defineDatabaseMigrations方法載入這些 migrations - 在
phpunit.xml.dist設定環境變數,本機開發或 CI/CD 環境則複製一份成phpunit.xml
<!-- phpunit.xml.dist -->
<php>
<env name="DB_CONNECTION" value="mysql"/>
<env name="DB_HOST" value="127.0.0.1"/>
<env name="DB_PORT" value="3306"/>
<env name="DB_DATABASE" value="testbench"/>
<env name="DB_USERNAME" value="root"/>
<env name="DB_PASSWORD" value=""/>
</php>這個做法的優點是:
- 完全遵循 Laravel 的 migration 系統,避開直接執行 SQL 的種種困難
- 與
RefreshDatabasetrait 無縫整合 - 測試專用的 migrations 與套件本身的 migrations 分離,結構清楚
- 環境變數透過
phpunit.xml設定,避免把敏感資訊寫死在程式裡
學到的經驗與最佳實務
這一天雖然令人沮喪,卻讓我對 Laravel 套件測試的最佳實務有了深刻的理解:
- 用 Migrations 取代 Schema Dumps: 直接使用 schema dump 看似比較直接,但改用標準的 Laravel migrations 可以避開許多隱藏的問題。
- 妥善分離測試環境: 用
workbench_path把測試專用的 migrations 跟套件本身的功能分開。 - 正確處理環境變數: 環境變數透過
phpunit.xml設定,不要依賴.env檔。 - 理解 Testbench 的生命週期: Orchestra Testbench 與標準 Laravel 應用程式之間,存在細微但關鍵的差異。
- 開機流程的差異: Testbench 建立的是精簡版的 Laravel 應用程式,不會執行標準 Laravel 應用程式的所有啟動流程。
- Facade 初始化的時機: 在 Testbench 裡,Facade 的初始化發生在特定時間點。太早使用(例如在
setUp方法裡)會導致「A facade root has not been set」錯誤。 - RefreshDatabase 的執行順序: 這個 trait 會在每個測試方法執行前重設資料庫,但實際的重設發生在第一個測試之前(初始 migration),以及之後每個測試之前(回復初始狀態並重跑 migrations)。
- 環境變數的處理: 如前所述,
env()函式在測試裡的行為與一般 Laravel 應用程式不同。 - Service Provider 的載入時機: 套件 service provider 的註冊與啟動也跟標準 Laravel 應用程式不同,必須透過
getPackageProviders方法明確指定。
理解這些差異,能幫你避開許多難以診斷的問題,設計出更穩定可靠的測試。
2026-08 後記: 一年後重新檢視這個問題,我發現 Laravel 其實內建了 schema dump 的載入機制,缺的只是一行路徑轉向。完整的討論寫在〈從 Migrations 走回 Schema Dumps:Laravel 內建的那條路〉。