目次
- はじめに
- この記事で扱う範囲
- 環境
- 3つのデータベースを分離する
- E2E専用MySQLユーザーを作る
- 最小権限にする
- 権限と拒否を確認する
E2E_DB_*を通常のDB_*と分ける- ComposeでE2E用変数を必須にする
E2E_DB_*を通常のDB_*と分ける- 通常の
mysqlconnectionもE2E側で接続不能にする - MySQLがHealthyになってからE2E Laravelを起動する
e2e:resetを作る- 破壊処理の前に3レイヤーを確認する
migrate:freshを使わなかった理由db:wipe → migrate → db:seedに分離する- FactoryとSeederの役割を分ける
- E2E fixtureを固定する
- なぜ11件なのか
- timestampも決定的にする
- resetの再現性を確認する
e2e:reset自体もfail-closedでテストする- reset途中で失敗した場合
- 今回理解できたこと
- まとめ
- 公式一次情報
- 関連記事
はじめに
第3回では、Playwrightから接続するE2E専用Laravel serviceとしてlaravel.e2eを用意しました。
しかし、その時点ではまだE2E専用データベースを作っていません。
あえてDB接続を失敗させ、
E2E専用DBがない
↓
DB依存画面は失敗してよい
↓
通常開発DBへ誤接続するより安全
というfail-closed構成にしていました。
今回は、その続きとしてPlaywright専用のデータベース・MySQLユーザー・Laravel connection・reset CLI・Factory / Seederによるfixture基盤を構築します。
この回で特に重視したのは、E2Eテストのための破壊的なDBリセットが、通常開発DBやPHPUnit用DBへ絶対に向かないよう多層防御することです。
この記事で扱う範囲
この記事は、Issue #162で実装したE2Eデータベース・fixture基盤を振り返ります。
主な対象は次のとおりです。
E2E専用DB
E2E専用MySQLユーザー
最小権限
E2E_DB_* 環境変数
e2e_mysql connection
e2e:reset
Factory / Seeder
固定fixture
fail-closed自動テスト
Playwrightから実際にモーダルやページネーションを操作するブラウザテストは次回扱います。
環境
実装時点の主な環境です。
PHP 8.4.24
Laravel 13.26.1
Laravel Sail 1.58.0
MySQL 8.4
Node.js 24.19.0
npm 11.17.0
Playwright Test 1.62.1
TypeScript 7.0.2
WSL Ubuntu 24.04
Laravel / MySQLはLaravel Sail、Playwright / Node.js / ChromiumはWSL側で管理しています。
3つのデータベースを分離する
今回のプロジェクトには、すでに通常開発用DBとPHPUnit用DBがありました。
そこへPlaywright用DBを追加します。
通常開発DB
→ laravel
PHPUnit用DB
→ testing
Playwright E2E用DB
→ e2e_testing
E2Eテストは、画面操作の結果としてINSERT / UPDATE / DELETEを実行します。
また、テスト開始前にはfixtureを毎回同じ状態へ戻すため、テーブルを削除してmigrationをやり直します。
そのため、
「設定を間違えても通常DBへ届かない」
ことを最優先にしました。
E2E専用MySQLユーザーを作る
E2E DBだけを操作するMySQLユーザーを用意します。
以下は一般化した例です。
CREATE DATABASE `e2e_testing`
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
CREATE USER 'review_app_e2e'@'<allowed-host>'
IDENTIFIED BY '<set-secure-password-outside-documentation>';
<allowed-host>は、Docker Composeから接続できる範囲で必要最小限にします。
注意
実パスワードは記事・Git・Issue・PR・ログへ記録しません。
また、'%'を一般的な推奨値として固定せず、実際の接続要件に応じてhost範囲を決めます。
今回のローカル環境では、E2E専用ユーザーがE2E DBだけへ接続できる状態を作りました。
最小権限にする
E2Eユーザーへは、migrationとfixture投入に必要な権限だけを付与します。
GRANT SELECT, INSERT, UPDATE, DELETE,
CREATE, DROP, REFERENCES, INDEX, ALTER
ON `e2e_testing`.*
TO 'review_app_e2e'@'<allowed-host>';
MySQL公式では、これらの権限をdatabase単位で付与できます。
一方、次のような管理・global権限は付与しません。
CREATE USER
GRANT OPTION
FILE
PROCESS
ON *.*
ALL PRIVILEGESも使用しません。
MySQL公式も、アカウントには必要な権限だけを与えることを推奨しており、特にFILEや管理権限には注意するよう説明しています。
権限と拒否を確認する
付与後は、権限を確認します。
SHOW GRANTS FOR 'review_app_e2e'@'<allowed-host>';
確認するポイントは次のとおりです。
e2e_testing.*
→ 必要な権限だけある
WITH GRANT OPTION
→ ない
意図しないglobal権限
→ ない
さらにE2E用credentialで、
USE e2e_testing
→ 成功
USE laravel
→ Access denied
USE testing
→ Access denied
となることを確認しました。
これはLaravel側の設定ミスに対する二重防御になります。
仮にアプリケーションコード側で誤ったconnectionを選んでも、MySQLユーザー自身に通常DBへアクセスする権限がなければ書き込みできません。
SHOW GRANTSでUSAGE ON *.*が表示される場合がありますが、MySQLにおけるUSAGEは「権限なし」を意味し、それ自体が追加のglobal権限を与えるものではありません。
E2E_DB_* を通常の DB_* と分ける
Laravelの通常DB設定と同じDB_*をE2Eでも使い回すと、設定不足時に通常DBへfallbackする余地が生まれます。
そこでE2E専用の名前空間を作りました。
.env.exampleには秘密情報ではない例だけを記載します。
E2E_DB_HOST=mysql
E2E_DB_PORT=3306
E2E_DB_DATABASE=e2e_testing
E2E_DB_USERNAME=review_app_e2e
E2E_DB_PASSWORD=e2e-password-not-configured
E2E_DB_PASSWORDは実credentialではなく、意図的に認証失敗するplaceholderです。
実値はGit管理しないローカル設定側で管理します。
今回の流れは次のとおりです。
E2E_DB_*
↓
compose.yaml
↓
laravel.e2e
↓
config/database.php
↓
e2e_mysql
↓
e2e_testing
ComposeでE2E用変数を必須にする
laravel.e2eへE2E専用変数を明示的に渡します。
DB_CONNECTION: e2e_mysql
DATABASE_URL: ''
E2E_DB_HOST: '${E2E_DB_HOST:?E2E_DB_HOST is required}'
E2E_DB_PORT: '${E2E_DB_PORT:?E2E_DB_PORT is required}'
E2E_DB_DATABASE: '${E2E_DB_DATABASE:?E2E_DB_DATABASE is required}'
E2E_DB_USERNAME: '${E2E_DB_USERNAME:?E2E_DB_USERNAME is required}'
E2E_DB_PASSWORD: '${E2E_DB_PASSWORD:?E2E_DB_PASSWORD is required}'
Docker Composeの${VAR:?error}は、変数が未設定または空の場合にエラーで停止するrequired interpolationです。
今回これを使った理由は、
E2E credentialがない
↓
通常DBのcredentialへfallback
という動きをさせないためです。
未設定なら早い段階で止めます。
運用上の注意
既存cloneのローカル設定に新しい
E2E_DB_*がない場合、Composeの解析自体が失敗することがあります。
E2E基盤を追加した既存環境では、.env.exampleを参考に必要なE2E用変数を追加します。
実credentialはドキュメントへコピーしません。
E2E専用Laravel connectionを作る
config/database.phpへ通常のmysqlとは別にe2e_mysqlを追加しました。
概略は次のとおりです。
'e2e_mysql' => [
'driver' => 'mysql',
'url' => '',
'host' => env('E2E_DB_HOST'),
'port' => env('E2E_DB_PORT'),
'database' => env('E2E_DB_DATABASE'),
'username' => env('E2E_DB_USERNAME'),
'password' => env('E2E_DB_PASSWORD'),
'unix_socket' => env('E2E_DB_SOCKET', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
],
重要なのは、
e2e_mysql
→ E2E_DB_*だけを見る
通常DB_*
→ fallbackとして使わない
ことです。
さらに、
'url' => '',
として、DATABASE_URLによる接続先上書きも使わない構成にしました。
Compose側でも、
DATABASE_URL: ''
としています。
通常の mysql connectionもE2E側で接続不能にする
e2e_mysqlを安全に作っても、E2Eコンテナ内でコードが明示的に、
DB::connection('mysql')
を呼べば通常connectionを選べます。
そこでlaravel.e2eでは、通常mysql側に使われる変数も意図的な無効値へ固定しました。
DB_HOST: e2e-database-not-configured
DB_USERNAME: e2e_not_configured
DB_PASSWORD: e2e_not_configured
つまり、
正規ルート
→ e2e_mysql
→ e2e_testing
誤ってmysqlを指定
→ 接続失敗
という構成です。
Laravel側とMySQL側の両方で通常DBへの経路を狭めています。
MySQLがHealthyになってからE2E Laravelを起動する
E2E LaravelはMySQLへ接続するため、起動順も調整しました。
depends_on:
mysql:
condition: service_healthy
Docker Composeでは、service_healthyを使うことで依存serviceのhealthcheck成功を待ってから起動できます。
今回の実測では、
mysql
→ Healthy
laravel.e2e
→ Started
という順序を確認しました。
e2e:reset を作る
E2Eテストでは、毎回同じDB状態から開始したいので専用Artisan commandを作りました。
php artisan e2e:reset
実装ファイルは、
app/Console/Commands/ResetE2eDatabase.php
です。
目的は単にDBを初期化することではありません。
e2e_testingだけを初期化する
通常DBを絶対に対象にしない
外部入力で対象connectionやDBを変えられない
途中失敗なら次へ進まない
ことが重要です。
そのためcommandのsignatureも、
protected $signature = 'e2e:reset';
だけにし、--databaseのような外部指定を受け付けません。
connectionとDB名はコード側で固定します。
private const CONNECTION = 'e2e_mysql';
private const DB_DATABASE = 'e2e_testing';
破壊処理の前に3レイヤーを確認する
db:wipeの前に、複数レイヤーで接続先を確認します。
1. Laravel実行環境
2. Laravel実効設定
3. MySQL実接続先
具体的には、
app()->environment('e2e')
でLaravelの実効環境を確認します。
次に、
config('database.connections.e2e_mysql.database')
でLaravelが現在認識しているDB名を確認します。
最後に、
SELECT DATABASE()
を実行し、MySQLが実際に接続しているDB名を確認します。
整理すると、
app()->environment('e2e')
→ 今のLaravelはE2E環境か
config(...)
→ Laravelの実効設定はe2e_testingか
SELECT DATABASE()
→ 本当にMySQLのe2e_testingへ接続しているか
です。
どれか1つでも不一致なら、破壊処理へ進まず異常終了します。
設定ファイルだけを信用せず、最後は実際の接続先まで照合する構成にしました。
migrate:fresh を使わなかった理由
最初は、
migrate:fresh
↓
db:seed
でresetする案でした。
しかしLaravel 13のFreshCommand実装を確認すると、内部でmigrateを呼び出したあと、その戻り値をFreshCommand自身の終了コードとして返していません。
Laravel 13の公式ソースでは概ね、
$this->call('migrate', [
// ...
]);
// ...
return self::SUCCESS;
という流れです。
つまり、E2E専用reset command側で、
migrationが成功したことを終了コードで明示的に確認してからseedへ進む
という制御をしたい今回の用途には、そのままmigrate:freshを包むより各処理を分けた方が明確でした。
同じ理由で、破壊処理を1つの大きなcommandへ任せるのではなく、各段階をe2e:reset自身が監督します。
db:wipe → migrate → db:seed に分離する
最終的に次の順番にしました。
db:wipe
↓
終了コード確認
↓ 成功時のみ
migrate
↓
終了コード確認
↓ 成功時のみ
db:seed
↓
終了コード確認
各サブコマンドには固定connectionを渡します。
$this->call('db:wipe', [
'--database' => 'e2e_mysql',
'--force' => true,
]);
$this->call('migrate', [
'--database' => 'e2e_mysql',
'--force' => true,
]);
$this->call('db:seed', [
'--class' => E2eSeeder::class,
'--database' => 'e2e_mysql',
'--force' => true,
]);
実際の実装では、それぞれの戻り値を個別に確認します。
wipe失敗
→ migrateしない
migrate失敗
→ seedしない
seed失敗
→ e2e:resetも失敗
全成功
→ exit code 0
Laravel公式ではdb:seed --class=...で特定Seederを実行でき、--forceによって確認プロンプトを回避できます。
今回はE2E専用command内で対象connectionとSeederを固定しているため、この仕組みを利用しています。
FactoryとSeederの役割を分ける
fixtureはNode.js / TypeScriptから直接SQLを実行して作りません。
Laravel側のFactory / Seederを使います。
役割は次のように分けました。
Factory
→ 1モデルをどう作るか
Seeder
→ E2Eテストで必要なデータセット全体をどう組み立てるか
今回の場合、
UserFactory
→ User 1件
ItemFactory
→ Item 1件
ReviewFactory
→ Review 1件
E2eSeeder
→ E2Eシナリオ用のユーザー・作品・レビュー一式
です。
Laravel公式でもSeederからEloquent model factoriesを利用できます。
この構成にすると、Laravel側のModel / Factoryで定義している生成ルールを再利用できます。
E2E fixtureを固定する
最終的なfixtureは次の構成にしました。
users : 4
categories : 1
items : 11
reviews : 11
ユーザー状態も用途別に固定します。
E2E Reviewer
→ メール認証済み
→ レビュー11件の投稿者
E2E Unverified User
→ メール未認証
E2E No Review User
→ 認証済み
→ レビュー0件
E2E Verified User
→ 汎用の認証済みユーザー
作品名とレビュー本文もFakerへ任せず固定しました。
E2E Movie 01
...
E2E Movie 11
E2E Review 01
...
E2E Review 11
ratingも全件5に固定しています。
E2E fixtureでは、ランダムデータの幅広さより、
resetする
↓
毎回同じ画面になる
↓
同じlocator / assertionが使える
ことを優先しました。
rating自体の境界値やvalidationはPHPUnit側の責務としています。
なぜ11件なのか
一覧画面はpaginate(10)を使っています。
そのため、2ページ目を発生させる最小件数は、
10 + 1
の11件です。
11件
↓
1ページ目 10件
2ページ目 1件
これにより後続のPlaywrightテストで、
1ページ目
→ 最新側のデータを確認
次のページ
→ 11件目を確認
というページネーションテストができます。
大量のfixtureを作らず、境界値だけを作る構成です。
timestampも決定的にする
本文や作品名だけ固定しても、並び順が不定だとE2Eテストは不安定になります。
本人レビュー一覧ではlatest()、つまりcreated_at DESCで並びます。
複数レビューのcreated_atが同じ場合、同値行同士の順番までは保証されません。
そこで各レビューのtimestampを1分ずつずらしました。
概念的には、
E2E Review 11
→ 最新
E2E Review 10
→ 1分前
...
E2E Review 01
→ 最古
となるようにしています。
その結果、
/reviews/mine?page=1
→ E2E Review 11 ~ 02
/reviews/mine?page=2
→ E2E Review 01
というページ境界を毎回再現できます。
重要なのは、時刻の絶対値を固定することではありません。
reset時のnow()を基準にしながら、各レコード間の相対的な順序を決定的にしています。
resetの再現性を確認する
e2e:resetを複数回実行し、毎回同じ件数へ戻ることを確認しました。
users = 4
items = 11
reviews = 11
verified_users = 3
unverified_users = 1
reviewer_reviews = 11
主要な表示値も同じです。
first_item
→ E2E Movie 01
last_item
→ E2E Movie 11
first_review_body
→ E2E Review 01
first_review_rating
→ 5
timestamp調整後は、本人レビュー一覧と同じ並び条件で2ページ目が、
E2E Review 01
になることも実DB上で確認しました。
e2e:reset 自体もfail-closedでテストする
破壊的commandなので、「正常系だけ動く」では不十分です。
むしろ、
危険な条件で何もしない
ことを自動テストします。
tests/Feature/ResetE2eDatabaseTest.phpでは8ケースを用意しました。
1. E2E環境ではない
2. config上のDB名が違う
3. SELECT DATABASE()がnull
4. 実接続先DB名が違う
5. db:wipe失敗
6. migrate失敗
7. db:seed失敗
8. 正常系
テストでは実DBを破壊しません。
DB接続
→ Facade mock
db:wipe / migrate / db:seed
→ Command::call()を上書きしたtest double
としつつ、本物のResetE2eDatabase::handle()の制御フローは通します。
最終的にはSymfonyのCommandTesterを利用し、
CommandTester
↓
Laravel Command::run()
↓
本物のhandle()
というLaravel標準のConsole実行経路を通しました。
置き換えるのは、実際に破壊的サブコマンドを実行する境界だけです。
実装時の結果は、
8 tests passed
38 assertions
でした。
reset途中で失敗した場合
e2e:resetはfail-closedですが、rollback commandではありません。
たとえば、
db:wipe
→ 成功
migrate
→ 途中で失敗
した場合、e2e_testingは途中状態になり得ます。
この状態でPlaywrightテストを続けません。
原因を修正
↓
e2e:resetを最初から再実行
↓
正常終了を確認
↓
Playwrightを実行
という運用にします。
安全ガードの目的は、
通常DBを壊さない
誤った状態のままテストを続けない
ことであり、途中失敗したE2E DBを自動的に元へ戻すことではありません。
今回理解できたこと
今回のE2E DB基盤では、1つの設定だけで安全性を担保していません。
MySQLユーザー
→ e2e_testing.*だけに権限
E2E_DB_*
→ 通常DB設定と名前空間を分離
e2e_mysql
→ 通常DB_*へfallbackしない
通常mysql connection
→ E2E containerでは接続不能
e2e:reset
→ connection / DB名をコード側で固定
Laravel環境
→ e2eか確認
Laravel config
→ e2e_testingか確認
MySQL実接続
→ SELECT DATABASE()で確認
db:wipe / migrate / db:seed
→ 各終了コードを個別確認
fixture
→ 主要表示値と並び順を決定的にする
と、複数の層で守っています。
今回特に印象に残ったのは、
設定上そうなっている
≠
実際の接続先もそうなっている
という点です。
破壊的なDB操作の直前では、設定値だけでなく実接続先まで確認するようにしました。
まとめ
今回は、Playwright用E2Eデータベースと再現可能なfixture基盤を構築しました。
通常開発DB
→ laravel
PHPUnit DB
→ testing
Playwright E2E DB
→ e2e_testing
に分離し、
専用MySQLユーザー
最小権限
E2E_DB_*
e2e_mysql
通常connectionのfail-closed
e2e:reset
3レイヤーの安全ガード
db:wipe → migrate → db:seed
E2eSeeder
固定fixture
timestampによる順序固定
fail-closed自動テスト
まで整備しました。
これで、PlaywrightからDBを使う画面をテストしても、毎回同じE2Eデータから開始できるようになりました。
次回は、このfixtureを使って実際に、
モーダル
ARIA
初期フォーカス
Tab / Shift+Tab
Escape
フォーカス復帰
ページネーション
をChromiumで操作するブラウザテストを実装します。
公式一次情報
この記事の内容は、次の公式ドキュメント・公式ソースを確認しながら整理しています。
- Laravel 13.x - Database: Getting Started
- Laravel 13.x - Database: Migrations
- Laravel 13.x - Database: Seeding
- Laravel 13.x - Eloquent: Factories
- Symfony Docs - Console Commands / Testing Commands
- Laravel Framework 13.x - FreshCommand.php
- MySQL 8.4 Reference Manual - GRANT Statement
- MySQL 8.4 Reference Manual - Privileges Provided by MySQL
- Docker Docs - Variable interpolation
- Docker Docs - Control startup and shutdown order in Compose
参照日: 2026-09-06
Laravel、MySQL、Dockerの仕様は更新されるため、実際に構築する際は最新版の公式一次情報も確認してください。