技術資料

LaravelのFeature Testで開発用DBを初期化してしまった:Codex運用で学ぶテストDBの多層防御

作成日:2026.09.07

CodexにLaravelのFeature Test実行を依頼した際、テスト用DBではなくローカルの開発用DBへ接続した状態でRefreshDatabaseが実行され、データが失われる事故が発生しました。この記事では、PHPUnitのforce="true"による環境変数固定、DB_URLの無効化、config:clearのComposer Script化、Laravel起動後のDB接続先ガード、通常テストとIntegration Testの分離など、テストDBの誤接続を防ぐ多層防御を紹介します。

LaravelプロジェクトでCodexにFeature Testの実行を依頼したところ、テスト専用DBではなく、通常利用しているローカルの開発用DBへ接続した状態でDBのリセット処理が実行され、データが失われる事故が発生しました。幸い、定期的にバックアップを取っていたため、影響は最小限に抑えられましたが、今後のために講じた対応策などをまとめておきます。

Codexが直接migrate:freshやTRUNCATEを実行したわけではありません。テスト用のDB設定が正しく読み込まれているつもりでしたが、Laravelが実際に使っていた接続先は別のDBでした。その状態でRefreshDatabaseを使ったFeature Testが動きました。

この記事では、この事故をLaravelとPHPUnit、CodexなどのAIコーディングエージェントを組み合わせるときのDB誤接続対策として整理します。

対象環境は、PHP 8.3、Laravel 13、PHPUnit 12です。

何が起きたのか

通常のFeature Testでは、SQLiteのインメモリDBを使う想定でした。例えば、phpunit.xmlへ次のような設定を記述します。

<phpunit bootstrap="vendor/autoload.php">
    <php>
        <env name="APP_ENV" value="testing" />
        <env name="DB_CONNECTION" value="sqlite" />
        <env name="DB_DATABASE" value=":memory:" />
    </php>
</phpunit>

この設定があるため、php artisan testを実行すれば、SQLiteのインメモリDBへ接続する想定でした。

しかし実際には、Laravelが読み込んだ接続先は次のような状態でした。

APP_ENV: testing
DB_CONNECTION: mysql
DB_DATABASE: 通常利用しているDB

この処理の直前のセッションで実データ調査のためにCodexがシェルの環境変数を上書きしていたため、意図したテスト用DBではなく、通常利用しているDBへ接続する状態になっていたようです。

この状態でRefreshDatabaseを使ったFeature Testが実行されました。テスト用のDB初期化処理は、設定された接続先をテスト用DBだと判断して実行されます。そのため、テスト専用ではないDBに対してスキーマ初期化相当の処理が行われ、データが失われました。

Laravelから見ると、指定されたDBへテスト処理を実行しただけです。問題は、テスト開始時点で接続先が本当に安全なDBなのかを、アプリケーション側で保証していなかったことでした。

RefreshDatabase自体が悪いわけではない

RefreshDatabaseは、テスト間でDBの状態を分離するために使うLaravel標準の仕組みです。スキーマの状態によって、Migrationやトランザクションなどが使われます。

したがって、事故の後に「RefreshDatabaseは危険だから全部削除する」と考えるのは本質的ではありません。

問題になるのは、次の組み合わせです。

DBへ変更を加えるテスト処理
        ×
接続先の誤設定

同じ問題は、次のような処理でも起こります。

  • DatabaseMigrations
  • DatabaseTruncation
  • Seeder
  • テスト用のクリーンアップ処理
  • migrate:fresh
  • db:wipe
  • 独自に実装したTRUNCATEや全件削除

必要なのは、これらを一律に禁止することではなく、破壊的な処理が、許可したテストDB以外では動かない構造を作ることです。

phpunit.xmlだけでは接続先を保証できない

テスト用のDB設定は、複数の場所から影響を受けます。

OSやシェルの環境変数
        ↓
.env
        ↓
phpunit.xml
        ↓
Laravelの設定
        ↓
設定キャッシュ

PHPUnit 12では、<env>で環境変数を設定できます。ただし、同名の環境変数がすでに存在する場合、既定では上書きされません。既存値も上書きしたい場合は、force="true"を指定します。

そこで、通常テスト用のphpunit.xmlでは、DBに関係する値を次のように固定します。

<phpunit bootstrap="vendor/autoload.php">
    <php>
        <env name="APP_ENV" value="testing" force="true" />
        <env name="DB_CONNECTION" value="sqlite" force="true" />
        <env name="DB_DATABASE" value=":memory:" force="true" />
        <env name="DB_URL" value="" force="true" />
    </php>
</phpunit>

DB_URLも空にしているところがポイントです。LaravelのDB設定には、DB_CONNECTIONやDB_DATABASEとは別に、URL形式で接続情報を指定する経路があるためです。

設定ファイルを直した後は、Laravelの設定キャッシュも確認します。ローカル開発環境で通常用の設定をキャッシュしたままテストを実行すると、テスト用の環境変数を設定していても、意図した値にならない場合があります。

テストコマンドへconfig:clearを組み込む

config:clearを実行してからテストする、という手順を人やAIエージェントの注意力だけに任せるのは不安が残ります。

そこで、Composer Scriptへ設定クリアとテスト実行をまとめます。

{
    "scripts": {
        "test": [
            "@php artisan config:clear",
            "@php artisan test"
        ]
    }
}

以降の標準コマンドを次の一つにそろえます。

composer test

これで、config:clearを実行し忘れてテストを始める可能性を下げられます。ただし、これだけで接続先が安全になるわけではありません。別のコマンドやWorkerがLaravelを起動する場合もあるため、次のガードを追加します。

Laravelの実際の接続設定を検査する

設定ファイルの内容ではなく、Laravelをbootstrapした後の実際の設定を検査します。判定はブラックリストではなく、ホワイトリストにします。

例えば、通常テストではSQLiteのインメモリDB、Integration Testではローカルホスト上のテスト専用DBだけを許可する構成です。

<?php

namespace Tests\Support;

use Illuminate\Contracts\Foundation\Application;
use RuntimeException;

final class TestDatabaseSafetyGuard
{
    public static function assertSafe(Application $app): void
    {
        $config = $app->make('config');

        $environment = $app->environment();
        $driver = (string) $config->get('database.default');
        $database = (string) $config->get(
            "database.connections.{$driver}.database",
        );
        $host = (string) $config->get(
            "database.connections.{$driver}.host",
        );
        $url = (string) $config->get(
            "database.connections.{$driver}.url",
        );

        if ($environment !== 'testing') {
            throw new RuntimeException(
                'testing環境以外ではテストを実行できません。',
            );
        }

        if ($url !== '') {
            throw new RuntimeException(
                'テストではDB_URLを使用できません。',
            );
        }

        $isSqliteMemory =
            $driver === 'sqlite'
            && $database === ':memory:';

        $isLocalIntegrationDatabase =
            $driver === 'mysql'
            && $database === 'app_concurrency_test'
            && in_array($host, ['127.0.0.1', 'localhost'], true);

        if (! $isSqliteMemory && ! $isLocalIntegrationDatabase) {
            throw new RuntimeException(
                '許可されていないDB接続を検出しました。',
            );
        }
    }
}

実際のプロジェクトでは、専用DBの名前や使用するドライバーを環境に合わせて変更します。重要なのは、_testという文字列を含むDBを許可するような曖昧な判定にしないことです。

例えば、次の判定は安全対策として弱いものです。

if (str_contains($database, 'test')) {
    // テスト用として許可
}

より安全にするなら、ドライバー、DB名、ホストなどを個別に一致させ、それ以外をすべて拒否するのも良いかと思います。

DBガードをRefreshDatabaseより前に実行する

DBへ接続した後に安全確認を行っても、すでに遅い場合があります。処理の順番を次のようにします。

Laravelのbootstrap
        ↓
DB接続先の検査
        ↓
RefreshDatabase
        ↓
Feature Test

例えば、プロジェクトのTests\TestCaseでLaravelをbootstrapした直後にガードを呼び出します。Laravelのアプリケーション構成によってbootstrap方法が異なるため、次は配置場所を示すための例です。

<?php

namespace Tests;

use Illuminate\Contracts\Console\Kernel;
use Tests\Support\TestDatabaseSafetyGuard;

abstract class TestCase extends BaseTestCase
{
    use CreatesApplication;

    public function createApplication(): \Illuminate\Contracts\Foundation\Application
    {
        $app = require dirname(__DIR__) . '/bootstrap/app.php';

        $app->make(Kernel::class)->bootstrap();

        TestDatabaseSafetyGuard::assertSafe($app);

        return $app;
    }
}

この例では、Laravelのbootstrap後、テストクラスのDB初期化処理より前にガードを実行しています。実際のLaravelプロジェクトでTests\TestCaseの構成が異なる場合は、同じ順序になる場所へ組み込みます。

通常テストとIntegration Testを分離する

すべてのDBテストをSQLiteのインメモリDBで実行できるとは限りません。例えば、次のような処理はMySQLやMariaDB固有の挙動を確認したい場合があります。

  • SELECT ... FOR UPDATE
  • ロック待ちやデッドロック
  • ユニーク制約の競合
  • 同時更新
  • トランザクション分離レベル

このようなテストまでSQLiteへ置き換えると、確認したいDB固有の挙動を再現できません。通常テストとIntegration Testを分けます。

通常テスト
→ sqlite / :memory:

DB依存Integration Test
→ localhost上の専用MySQL・MariaDB

Integration Test用には、例えばphpunit.integration.xmlを用意します。

<phpunit bootstrap="vendor/autoload.php">
    <php>
        <env name="APP_ENV" value="testing" force="true" />
        <env name="DB_CONNECTION" value="mysql" force="true" />
        <env name="DB_HOST" value="127.0.0.1" force="true" />
        <env name="DB_PORT" value="3306" force="true" />
        <env name="DB_DATABASE" value="app_concurrency_test" force="true" />
        <env name="DB_URL" value="" force="true" />
    </php>
</phpunit>

実行時には、使用する設定ファイルを明示します。

vendor/bin/phpunit -c phpunit.integration.xml

この設定でも、アプリケーション側のガードで同じDB名とホストだけを許可します。設定ファイルとガードの両方で条件を持つことで、片方の変更漏れをもう片方で検出できます。

Workerや子プロセスにもガードを入れる

同時更新のテストでは、proc_open()などで複数のPHPプロセスを起動することがあります。

親側のテストがTests\TestCaseを継承していても、Workerが直接Laravelをbootstrapしている場合、親のテストケースに入れたガードは実行されません。

Worker側にも、DBへアクセスする前に同じガードを入れます。

<?php

$app = require dirname(__DIR__) . '/bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();

\Tests\Support\TestDatabaseSafetyGuard::assertSafe($app);

// ここから先で初めてDBへアクセスする
DB::beginTransaction();

「親プロセスの環境変数を引き継ぐはず」と考えるだけでは不十分です。Workerを単独で起動された場合にも、許可されていないDBへ接続しないことを確認します。

AIエージェント向けのルールもコード化する

CodexなどのAIコーディングエージェントには、テストの実行方法と禁止事項をプロジェクトのルールとして明記します。

- 通常テストは composer test を使用する
- 通常テストでは SQLite の :memory: だけを許可する
- 実DBテストは専用Integration DBだけを使用する
- DB_URLをテストで使用しない
- DB安全ガードが失敗した場合は続行しない
- migrate:fresh、migrate:refresh、db:wipeは承認なしに実行しない
- TRUNCATE、全件削除、DB再作成は承認なしに実行しない

ただし、AGENTS.mdやSkillだけを最後の防御にしてはいけません。指示の読み違いや、想定していないWorkerの直接実行は起こり得ます。

AIエージェントへのルール、標準テストコマンド、PHPUnitの環境変数、Laravel起動後のガードを重ねます。

AIエージェントのルール
        ↓
composer test
        ↓
PHPUnitで環境変数を固定
        ↓
Laravel bootstrap後にDB接続先を検査
        ↓
RefreshDatabaseなどのテスト処理

ガード自体をテストする

安全機構を追加したら、その機構が想定どおり拒否することもテストします。

少なくとも次のような組み合わせを確認します。

環境 ドライバー DB 結果
testing sqlite :memory: 許可
testing mysql 専用Integration DB 許可
testing mysql 通常利用のDB 拒否
local sqlite :memory: 拒否
testing mysql 専用DBだが別ホスト 拒否

設定キャッシュを残した場合、DB_URLが設定されている場合、ホスト名が異なる場合など、事故につながった経路を拒否できることが重要です。

ガードが失敗したときは、テストを続行せず、接続先と読み込まれた設定を確認します。ログへ接続パスワードや完全な接続URLを出力しないように注意します。

事故後に避けたい対策

RefreshDatabaseをすべて削除する

テストDBが安全であることを保証できれば、RefreshDatabaseはテスト間の状態を分離するために利用できます。機能を削除するより、許可されない接続先で実行できないようにします。

「実行前にDB名を確認してください」とだけ書く

人間にもAIエージェントにも、毎回の確認を要求するだけでは抜けが発生します。確認をコマンドやガードへ組み込み、確認し忘れても停止する構造にします。

DB名にproductionが含まれる場合だけ拒否する

名前に特定の文字列が含まれないDBが安全とは限りません。許可する条件を列挙し、それ以外を拒否するホワイトリスト方式にします。

AIエージェントだけを原因にする

今回の事故ではCodexがテストを実行しましたが、接続先を保証していなかったことが本質です。人が同じコマンドを実行しても事故は起こり得るため、実行者ではなくシステム側で防御します。

動作確認の流れ

安全対策を導入した後は、通常の成功ケースだけでなく、誤設定時に停止することを確認します。

  1. phpunit.xmlへテスト用DBの値をforce="true"で設定する。
  2. DB_URLを空に固定する。
  3. composer testから設定クリアとテストが実行されることを確認する。
  4. Laravel bootstrap後に安全ガードが実行されることを確認する。
  5. 通常テストがSQLiteのインメモリDBへ接続することを確認する。
  6. Integration Testが専用DBへ接続することを確認する。
  7. 通常利用のDB名や別ホストを設定した場合、DB処理より前に停止することを確認する。
  8. Workerを直接起動した場合にも安全ガードが動くことを確認する。

テストが成功したことだけでなく、「許可されていないDBではテストが始まらない」ことを確認するのがポイントです。

まとめ

LaravelのFeature TestをCodexに実行させた際、テスト専用DBではないDBへ接続した状態でDBリセット処理が走り、データが失われる事故が発生しました。

この事故から、次の対策を行いました。

  1. PHPUnitのDB環境変数をforce="true"で固定する
  2. DB_URLを明示的に無効化する
  3. config:clearとテストをComposer Scriptへまとめる
  4. Laravelのbootstrap直後に実際のDB接続先を検査する
  5. 通常テストと専用Integration Testを分離する
  6. Workerや子プロセスにも同じ安全ガードを適用する
  7. AIエージェントへ標準コマンドと禁止事項を伝える
  8. 安全ガード自体の拒否ケースをテストする

最も重要なのは、RefreshDatabaseやCodexを一律に危険視することではありません。一つの設定ミスや実行者の勘違いだけでは、重要なDBへ破壊的な処理が到達しない構造を作ることです。

テストを自動化し、AIエージェントへ検証を任せるほど、テストDBの安全設計も開発環境の一部として考えておく必要があります。

参考資料

この記事を書いた人

※上が私です。

奈良市を拠点に、27年以上の経験を持つフリーランスWebエンジニア、阿部辰也です。

これまで、ECサイトのバックエンド開発や業務効率化システム、公共施設の予約システムなど、多彩なプロジェクトを手がけ、企業様や制作会社様のパートナーとして信頼を築いてまいりました。

【制作会社・企業様向けサポート】
  • 専任エンジニアのいない企業様に対するシステム面の不安を解消
  • 柔軟な契約形態や短納期での対応により、急なニーズにも迅速にサポート
  • システムの企画段階から運用まで、ワンストップでのサービスを提供

Webシステムの開発やサイト改善でお困りの際は、どうぞお気軽にご相談ください。小さな疑問から大規模プロジェクトまで、最適なご提案を心を込めてさせていただきます。

ぜひ、プロフィールやWeb制作会社様向け業務案内、一般企業様向け業務案内もご覧くださいね。

Laravelのコードレビューで遅いSQLの候補を洗い出す

2026.10.02

Laravelのコードレビューで、ループ内のDBアクセスやEloquentのN+1、大量取得、検索条件とインデックスなど、性能問題につながりやすい箇所を候補として洗い出します。Laravelで発行SQLと実行時間を確認し、MySQLのEXPLAINや実データに近い環境での計測へつなげる方法を紹介します。コードレビューだけではSQLの実行時間を断定できない点も説明します。

Laravel MySQL PHP

Windows 11でDocker Composeを使ってLaravel環境を作る:Container・Volume・Networkの基本

2026.10.01

Windows 11のDocker Desktop上に、DockerfileとDocker Composeを使ってLaravel・PHP・MySQL・Mailpitの開発環境を構築します。ImageとContainerの関係、Bind MountとNamed Volumeによるデータの保存、ポート公開とContainer間通信の違いを実例で確認。LaravelからMySQLへ接続するときに、localhostではなくService名を使う理由も説明します。

Docker Laravel PHP

LaravelのArtisanコマンドを本番運用する:冪等性・二重実行防止・再実行の設計

2026.09.22

LaravelのArtisanコマンドでデータベースを更新する処理を、本番環境で安全に運用するための考え方を整理します。商品在庫の一括更新を例に、冪等性、dry-run、チャンク処理、トランザクション、二重実行防止、失敗後の再実行、ログや終了コード、実行前後の確認手順を紹介します。

Laravel PHP

Laravel 13のNamed Rate Limiterでレート制限を実装する:IP・ユーザー・入力値ごとの設定

2026.09.19

LaravelのNamed Rate Limiterを使い、無制限に繰り返されたくない処理へレート制限を追加する方法を解説します。RateLimiter::for()とthrottleミドルウェアの基本から、IPアドレス・ユーザーID・入力値による制限、複数の制限、429レスポンス、ログイン処理への応用、Redis利用時の注意点まで整理します。

Laravel PHP

阿部辰也へのお仕事の依頼・お問い合わせ

軽いご相談もお気軽にどうぞ!

個人情報の取り扱いについて *必須 プライバシーポリシーをご確認いただき、同意いただける場合は「同意する」にチェックをしてください。

keyboard_double_arrow_up
TOP