Wu-Hsien Yu/ 文章
文章

從 Schema Dumps 到 Migrations:Laravel 套件測試

在應用程式的開發裡,程式碼的模組化與可測試性至關重要。當我開始把一個龐大複雜的舊系統,重構成以 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 檔案

但我很快發現,事情沒有想像中那麼簡單。

環境變數之謎

第一個問題出現在讀取環境變數的時候:

php
// 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 來載入:

php
protected function setUp(): void
{
    \Dotenv\Dotenv::createImmutable(__DIR__.'/../')->load();

    parent::setUp();

    // Other initialization...
}

載入 Schema 的困境

解決環境變數問題之後,我遇到一個更棘手的問題:在 TestCase 裡載入 schema 檔案。我天真地以為這應該跟在一般的 Laravel 應用程式裡一樣簡單。事實並非如此。

我試著直接在 setUp 方法裡載入 schema:

php
protected function loadDBSchema()
{
    $schemaPath = __DIR__.'/../database/schema/mysql-schema.sql';

    if (file_exists($schemaPath)) {
        \Illuminate\Support\Facades\DB::unprepared(file_get_contents($schemaPath));
    }
}

但這帶來一個令人困惑的錯誤:

shell
RuntimeException: A facade root has not been set.

這表示我使用 DB Facade 的時候,它還沒有被正確初始化。

更麻煩的是,解決了 Facade 問題之後,又撞上外鍵約束的問題:

shell
QueryException: SQLSTATE[HY000]: General error: 1824 Failed to open the referenced table 'tenants'

原因是 schema 檔案裡資料表的建立順序,跟外鍵約束的相依關係對不上。

RefreshDatabase 的真相

試了好幾輪之後,我開始懷疑是 RefreshDatabase trait 跟我的 schema 載入方式互相衝突。沒錯,這正是問題的核心。

php
// pest.php
pest()->extend(Tests\TestCase::class)
    ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
    ->in('*');

RefreshDatabase trait 會在測試過程中重設資料庫結構,這代表:

  1. 就算我成功載入了 schema,RefreshDatabase 也可能在之後把它重設掉
  2. 或者,RefreshDatabase 可能在我的 schema 載入之前就先初始化了資料庫

最終解法:用 Migrations 取代 Schema Dumps

Orchestra Testbench 其實提供了一條更好的路 — 使用標準的 Laravel migrations,而不是直接執行 schema dump 檔案。

關鍵是 Testbench 的 defineDatabaseMigrations 方法和 workbench_path 輔助函式:

php
/**
 * 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
<?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
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 的種種困難
  • RefreshDatabase trait 無縫整合
  • 測試專用的 migrations 與套件本身的 migrations 分離,結構清楚
  • 環境變數透過 phpunit.xml 設定,避免把敏感資訊寫死在程式裡

學到的經驗與最佳實務

這一天雖然令人沮喪,卻讓我對 Laravel 套件測試的最佳實務有了深刻的理解:

  1. 用 Migrations 取代 Schema Dumps: 直接使用 schema dump 看似比較直接,但改用標準的 Laravel migrations 可以避開許多隱藏的問題。
  2. 妥善分離測試環境:workbench_path 把測試專用的 migrations 跟套件本身的功能分開。
  3. 正確處理環境變數: 環境變數透過 phpunit.xml 設定,不要依賴 .env 檔。
  4. 理解 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 內建的那條路〉。

相關文章

2026.08.04從 Migrations 走回 Schema Dumps:Laravel 內建的那條路2026.04.20Kindie - 開發日誌 012026.01.21自建物流標籤列印系統:從 Loftware 到 GoDex