最近嘗試將目前的 ERP 系統重構,並改為一個獨立的 package,這樣就不受限於 Laravel 改版時可能發生的一些狀況,也趁勢整理目前模組間混亂的情形。
結論先說,這個計畫失敗了,我像是 Uncle Bob 提到的那樣,建立了 Tiger Team 企圖重寫系統,而且重點是 Team 還只有我一個人,可以預測到的,我失敗了。但這篇 Blog 不是紀錄這次失敗,而是紀錄這次遷移到 package 過程中,值得記下來的一些經驗。
當初為了讓重構可以順利進行,我首先嘗試建立測試環境,預期讓 package 的測試可以與目前既有系統的測試互相比對驗證。因此,測試用的資料庫環境必須與目前的系統一致。但我並沒有保留這幾年來開發過程中的 migrations,更多是使用 Laravel 提供的 Squashing Migrations 方式移除 migrations 後,產生資料庫的 Schema。
因此,想法很簡單:
- 使用 Orchestra Testbench 作為 package 開發測試工具
- 將 schema 放到 package 裡,並在每次測試驗證時,使用它重建資料庫環境
但這個想法,卻沒有預期般的順利。
問題:讀不到 .env
在 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', ''),
'prefix' => '',
]);
});
}DB_PASSWORD 回傳的一直都是空字串。逐行追下去才發現,在 Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables 裡:
protected function createDotenv($app)
{
return Dotenv::create(
Env::getRepository(),
$app->environmentPath(),
$app->environmentFile()
);
}environmentPath() 預設就是 basePath(),而在 Testbench 底下,basePath() 是:
public static function applicationBasePath()
{
return static::applicationBasePathUsingWorkbench() ?? default_skeleton_path();
}且 default_skeleton_path() 指的是 vendor/orchestra/testbench-core/laravel,就是那個 Testbench 裡用來模擬的 Laravel 骨架。bootstrapper 去找的是 vendor/orchestra/testbench-core/laravel/.env,而不是 package 目錄下的 .env。而 Testbench 裡用來模擬的 Laravel 骨架的目錄裡不會有 .env,甚至它的 .gitignore 都已經排除了 .env 與 .env.*,Testbench 由 Composer 管理,隨時都可能會被重建。所以裡面無法存放 .env。
Testbench 自己覆寫了 createDotenv():
final class LoadEnvironmentVariables extends \Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables
{
protected function createDotenv($app)
{
if (! is_file(join_paths($app->environmentPath(), $app->environmentFile()))) {
return Dotenv::create(
Env::getRepository(),
(string) realpath(join_paths(__DIR__, 'stubs')),
'.env.testbench'
);
}
return parent::createDotenv($app);
}
}stubs/.env.testbench 是一個空檔案,讓 Dotenv 永遠有合法路徑可以指,不會因為找不到檔案而丟例外。
所以 .env 其實有被載入,只是它載入的不是我的 package 裡的 .env,它最終載入的是 Testbench 裡的一個空檔案。
怎麼取得 .env
在 applicationBasePath() 裡,可以發現:
return static::applicationBasePathUsingWorkbench() ?? default_skeleton_path();換句話說,如果有 base path,就不會執行 default_skeleton_path(),而且 .env 才有地方放。有幾種方式:
testbench.yaml
在 testbench.yaml 設定 laravel:
laravel: ./skeleton這會指向一個由自己維護的骨架目錄,./skeleton/.env 就會被正常載入。並且在 TestCase 裡使用 WithWorkbench trait,否則 testbench.yaml 的 laravel 設定在測試裡不會生效,它只對 vendor/bin/testbench CLI 有效。
phpunit.xml
或者,是直接在 phpunit.xml 裡設定環境變數,它執行順序優先於前者,也不受 WithWorkbench trait 限制;因為讀的是 $_ENV:
<env name="APP_BASE_PATH" value="./skeleton"/>我最後選擇了這個方式,因為它讓我不需要維護額外的目錄。
Dotenv
我發現,如果就是想用 package 下的 .env 也可以,使用 Dotenv 自己載入就好。
protected function setUp(): void
{
\Dotenv\Dotenv::createImmutable(__DIR__.'/../')->safeLoad();
parent::setUp();
// Other initialization...
}這裡也發現一個狀況:在 setUp() 裡呼叫 $app->useEnvironmentPath() 是沒有用的,因為 bootstrapper 那時候已經跑完了。
問題:A facade root has not been set
處理完環境變數,接下來就是在 TestCase 裡載入 schema:
protected function setUp()
{
$schemaPath = __DIR__.'/../database/schema/mysql-schema.sql';
if (file_exists($schemaPath)) {
\Illuminate\Support\Facades\DB::unprepared(file_get_contents($schemaPath));
}
parent::setUp();
}寫在 setUp() 裡,是希望在執行 migrations 之前,先將既有的資料庫結構建立完成,但卻發生了錯誤:
RuntimeException: A facade root has not been set.為什麼 Facade 會是空的? 只有當容器還沒有完成初始化才可能發生。但是我已經有將 \PHPUnit\Framework\TestCase 替換為 Tests\TestCase 了,不應該發生這種事? 後來才發現寫顛倒了。我應該先呼叫 parent::setUp(),讓 createApplication() 完成,我才能使用 Facade:
protected function setUp()
{
parent::setUp();
$schemaPath = __DIR__.'/../database/schema/mysql-schema.sql';
if (file_exists($schemaPath)) {
\Illuminate\Support\Facades\DB::unprepared(file_get_contents($schemaPath));
}
}問題:Schema 衝突
schema 檔案裡資料表的建立順序,跟外鍵的約束關係對不上。
QueryException: SQLSTATE[HY000]: General error: 1824 Failed to open the referenced table 'tenants'試了幾次之後,方向有可能 RefreshDatabase trait 與 schema 載入時機的衝突:
- schema 載入成功了,但
RefreshDatabase在之後把它重設掉 RefreshDatabase在 schema 載入之前就先初始化了資料庫
這實在很麻煩,我必須要手動控制他們的先後順序。我想到:那麼 Laravel 是怎麼做的呢?官方描述:
when you attempt to migrate your database and no other migrations have been executed, Laravel will first execute the schema file's SQL statements 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
出處為:Database: Migrations (Squashing Migrations)
如果,我可以將載入 schema 的步驟,放到 Laravel 原生的流程裡呢?
解法
Testbench 提供了對應的機制,defineDatabaseMigrations 方法與 workbench_path 輔助函式:
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
{
//
}
};如前面提到的,資料庫連線設定則寫進 phpunit.xml.dist,本機與 CI 各自複製一份成 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="PASSWORD_HERE"/>
</php>這個做法的效果:
- 走 Laravel 的 migration 流程,避開直接執行 SQL 的時機問題
- 與 RefreshDatabase 相容
- 測試專用的 migrations 與套件本身的 migrations 分離
- 連線設定集中在 phpunit.xml,不必寫死在程式裡
結語
回頭看,這一整天真正的收穫,是把 Testbench 與標準 Laravel 應用程式之間的差異弄清楚。以為套件的測試環境是一個縮小版的 Laravel 應用程式。它不是,它是一個由 Composer 管理、隨時會被重建的骨架,任何依賴檔案放在專案根目錄的機制在這裡都不成立。