docker環境(LEMP)のlaravel 11でのブラウザテストの環境構築 & 実行方法&トラブルシューティング

## 前置き


初投稿です。
ハマったので備忘録的に記事を書きます。
同様の現象だったり、他にもこんなエラーが起きたよって人がいれば解決策の共有をしてもらえると助かります。
古いLaravelのバージョンでのうまく行ったパターンが多すぎて辛み。
Laravel歴2〜3ヶ月とかなので当たり前のことを知っていない可能性があります。
誤解を生みそうな部分や、間違えている部分のご指摘いただけると助かります。


## 前提

- ホストはUbuntu mate25.x
- WindowsからVirtualBoxでLinux起動
- Docker環境構築済みかつLaravelが動いている
- LEMP環境かつseleniumがWebアプリサーバとは別
- Laravel 11以降
- Laravel Dusk使用
- Vite使用

## 結論

先に結論というか、重要なポイントを簡単にピックアップします。
ハマってる人にはこれで伝わるはず。

- Laravel 11でのdusk実行時の挙動
  過去のLaravel(laravel 10以前)では dusk実行時、　`.env`は`.env.dusk.local`の内容で上書きをしていた。
  laravel 11(dusk)では上書きが行われない。
  明示的に上書きをする必要がある。
  shellを作成の上、実行すると事故が無いです。後述（CSRFトークン不一致の項）
  
- viteサーバ
   hot/public（ホットリロード）がある場合、セレニウムからcss/jsが読み込めない。
   npm run devをしている場合、dusk実行時は停止する必要がある。

- CSRFトークン不一致
  Laravel 11でのdusk実行時の挙動のせいで`.env`の`APP_URL`が一致していないと起きる。
  これはサーバーとして正しい仕様ですが、cookieの取得ができなくて初めて気がつくと思います

- seleniumでアクセスするURLはサービス名に合わせる

--------

以下詳細です。
長くなりすぎたので適宜開閉できるようにしました。
必要なら開いて確認してください。

## 構成図

<details>

<summary><font color="Red">アプリケーション構成を表示</font></summary>

| 項目        | バージョン例        | 説明 |
|-------------|---------------------|------|
| Laravel     | 11.x                | PHPフレームワーク |
| PHP         | 8.3.2.1             | Laravel実行用 |
| MySQL       | 8.0.36              | データベース |
| Nginx       | 1.24                | Webサーバー |
| Node.js     | v18.20.8            | フロントエンドビルド |
| Composer    | 2.8.9               | PHPパッケージ管理 |
| OS          | Ubuntu 25.02        | 開発ベース環境 |
| Docker      | 28.1.1              | コンテナ実行環境 |
| Docker Compose | 2.34             | コンテナオーケストレーション |

</details>



<details>

<summary><font color="Red">システム構成を表示</font></summary>

ChatGPTくんに丸投げ

```mermaid
graph TD
    browser["🌐 browser\n(外部アクセス)"] --> nginx["🖧 Nginx\nWebサーバー\ポート: 80"]
    nginx --> app["🧱 app (Laravel 11 + Vite)\nPHP + Node.js\ポート: 5173"]
    app --> db[("🗄️ db\nMySQL 8.x\nポート: 3306")]
    phpmyadmin["🛠️ phpMyAdmin\nDB管理UI\nポート: 8080"] --> db

    app -- WebDriver通信 --> selenium["🧪 selenium\nブラウザ自動操作\nポート: 4444 / 7900"]
    selenium -- テストブラウザでアクセス --> nginx
```

</details>


-----------------------------------------------------------------------------


## Docker関連

必要なら展開していってください。
docker-compose.ymlは重要です。

<details>

<summary><font color="Red">Docker関連のファイル例を表示</font></summary>

### dockerFile

必要パッケージを入れたり、作業用のユーザーを作っています。
本来ならユーザーの存在チェックがあったほうがいいですが、書き忘れてます。
イメージ作り直す度にユーザーも作り直されるので問題は基本起きないはずです。

<details>

<summary><font color="Green">展開</font></summary>

```dockerfile
FROM php:8.3-fpm

# UID/GIDを引数で受け取る（デフォルト1000）
ARG UID=1000
ARG GID=1000
ARG USERNAME=appuser
ARG GROUPNAME=appuser

# 必要パッケージのインストール
RUN apt-get update && apt-get install -y \
    git \
    unzip \
    curl \
    vim \
    libzip-dev \
    mariadb-client \
    zlib1g-dev \
    sudo \
    gnupg

# wait-for-it.sh をコンテナ内にコピー
# COPY ../../wait-for-it.sh /usr/local/bin/wait-for-it.sh
# RUN chmod +x /usr/local/bin/wait-for-it.sh

# PHP拡張モジュールのインストール
RUN docker-php-ext-install zip pdo_mysql

# Node.js 18.x をインストール
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
 && apt-get install -y nodejs \
 && npm install -g npm vite vue

# Laravel用ユーザー作成 + sudo権限付与
RUN groupadd -g ${GID} ${GROUPNAME} \
 && useradd -u ${UID} -g ${GROUPNAME} -m ${USERNAME} \
 && echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers

# Composer インストール
RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \
 && php composer-setup.php \
 && php -r "unlink('composer-setup.php');" \
 && mv composer.phar /usr/local/bin/composer

# 作業ディレクトリ作成と権限調整
WORKDIR /var/www/laravel-project
RUN chown -R ${USERNAME}:${GROUPNAME} /var/www

# 実行ユーザー切替
USER ${USERNAME}

```


</details>

### docker-compose.yml

ポイントはサービス名です。(db,appといった)
サービス名とアクセスする時の名前は一致している必要があります。
あと色々とヘルスチェックしていますが、これは基本やらなくていいです。
起動(up)の整合性を取る場合は記載してください。

<details>

<summary><font color="Green">展開</font></summary>

```yml

services:
  db:
    image: mysql:8.0.36
    container_name: db
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: ${MYSQL_DATABASE}
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    volumes:
      - ./docker/db/data:/var/lib/mysql
      - ./docker/db/my.cnf:/etc/mysql/conf.d/my.cnf
      - ./docker/db/init:/docker-entrypoint-initdb.d
    ports:
      - "${MYSQL_PORT}:3306"

  app:
    container_name: app_laravel
    build:
      context: .
      dockerfile: docker/php/Dockerfile
      args:
        UID: ${UID}
        GID: ${GID}
    user: "${UID}:${GID}" 
    volumes:
      - .:/var/www
      - ~/.docker_bash_history:/appuser/.bash_history
      - ./wait-for-it.sh:/usr/local/bin/wait-for-it.sh:ro
    command: ["/usr/local/bin/wait-for-it.sh", "db:3306", "--", "php-fpm"]
    depends_on:
      - db
    ports:
      - 5173:5173

  nginx:
    image: nginx
    container_name: nginx
    volumes:
      - .:/var/www
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
      - ./wait-for-it.sh:/usr/local/bin/wait-for-it.sh:ro
    working_dir: /var/www
    command: ["/usr/local/bin/wait-for-it.sh", "app:9000", "--", "nginx", "-g", "daemon off;"]
    depends_on:
      - app
    ports:
      - "${APP_PORT}:80"

  phpmyadmin:
    container_name: test_phpmyadmin
    image: phpmyadmin
    environment:
      PMA_USER: ${PMA_USER}
      PMA_PASSWORD: ${PMA_PASSWORD}
    depends_on:
      - db
    ports:
      - "${PMA_PORT}:80"

  selenium:
    image: selenium/standalone-chrome:latest
    container_name: selenium
    depends_on:
      - nginx
    command: ["/usr/local/bin/wait-for-it.sh", "nginx:80", "--", "/opt/bin/entry_point.sh"]
    ports:
      - 4444:4444
      - 7900:7900
      - 5900:5900
    privileged: true
    volumes:
      - ./wait-for-it.sh:/usr/local/bin/wait-for-it.sh:ro
      - /dev/shm:/dev/shm
    shm_size: 2g

```

</details>

### default.conf

<details>

<summary><font color="Green">展開</font></summary>

```conf

server {
  listen 80;
  # server_name nginx localhost 127.0.0.1;
  root /var/www/laravel-project/public;
  index index.php;
  location / {
    try_files $uri $uri/ /index.php?$query_string;
  }
  location ~ \.php$ {
    try_files $uri =404;
    fastcgi_split_path_info ^(.+\.php)(/.+)$;
    fastcgi_pass app:9000; 
    fastcgi_index index.php;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_param PATH_INFO $fastcgi_path_info;
    fastcgi_param HTTP_HOST $host; 

    # HostヘッダーをLaravelに渡す
    # fastcgi_param HTTP_HOST $host;
    # fastcgi_param HTTP_HOST $server_name; # または $host

    # Cookieを渡す
    # fastcgi_param HTTP_COOKIE $http_cookie;
  }
}

```

</details>

</details>

-----------------------------------------------------------------------------

## インストール手順

すでにlaravelは動いていて、composeで入れるだけの前提

<details>

<summary><font color="Red">インストール手順を表示</font></summary>

- docker
  seleniumを追加 追加方法は `default.conf`の`selenium`をコピー
  一度dockerを停止の上再度立ち上げ

```bash
docker compose down
docker compose up --build
>UID=1000 GID=1000 docker compose --env-file .env up -d --build
```

念の為dockerの立ち上げ確認

```bash
docker ps
docker compose container ls
# seleniumが追加されていることを確認する。
```


appサーバに入る
`docker compose exec app bash`

appサーバ内で以下コマンド(php artisanが通る場所)
```bash
composer require --dev laravel/dusk
php artisan dusk:install

# tests/Browser ディレクトリと、DuskTestCase.php が作成される。
```

apt-getで必要なパッケージを入れる。
頻繁に環境を作り直す場合は、dockerfileに移動したり、シェルにしたほうがいいです。
私はシェルにしています。

```bash
#!/bin/bash

# sudoで実行

apt-get update && apt-get install -y \
    libglib2.0-0 \
    libnss3 \
    libgconf-2-4 \
    libfontconfig1 \
    libxss1 \
    libappindicator1 \
    libasound2 \
    fonts-liberation \
    xdg-utils \
    wget \
    unzip \
    iputils-ping \
    net-tools # debug
```



### ファイルの修正

#### .env.dusk.local

新規作成
.envをコピーして、以下必要な箇所だけ書き換えする。
viteの変更は結果的に無駄だったのでいらないです。
ポイントはコメントがついているサービス名です。
これを、docker-compose.ymlのサービス名と合わせます。

```
# -------------------
# dusk起動に必要な最小構成
APP_ENV=dusk.local
APP_URL=http://nginx  # nginxコンテナ名
# APP_URL=http://app  # appコンテナ名
DB_HOST=db            # dbコンテナ名
DB_PORT=3306
DB_DATABASE=laravel_db
DB_USERNAME=root
DB_PASSWORD=password

# SESSION_DOMAIN=null 
SESSION_DOMAIN=null
SESSION_DRIVER=file
SESSION_SECURE_COOKIE=false

# DuskがリモートSelenium使う設定
DUSK_DRIVER=remote

VITE_APP_NAME=vite
# 開発時のviteサーバ
#VITE_DEV_SERVER_URL=http://app:5173
FRONTEND_URL=http://app:3000

# sanctum
# SANCTUM_STATEFUL_DOMAINS=nginx:8000,nginx:3000,nginx
SANCTUM_STATEFUL_DOMAINS="localhost,127.0.0.1,nginx"

# ------------------

以下略

```

作成後、.env.dusk.localを.envに上書きしますが、バックアップを取った上で上書きしてください。
何かあった時に戻せるようにしておけば大丈夫です。



#### DuskTestCase.php

以下を修正
```php

    ~~~~~~~~~~~~~

    #[BeforeClass]
    public static function prepare(): void
    {
        if (! static::runningInSail()) {
            // static::startChromeDriver(['--port=9515']); // これを削除
        }
    }

    protected function driver(): RemoteWebDriver
    {
        $options = (new ChromeOptions)->addArguments([
            '--window-size=1920,1080',
            '--disable-search-engine-choice-screen',
            '--disable-smooth-scrolling',
            '--no-sandbox', // 追記
            '--disable-gpu', // 追記
            '--headless=new', // 追記 古いセレニウムの場合は=newでエラーになる可能性あり
        ]);

        return RemoteWebDriver::create(
            'http://selenium:4444/wd/hub', // ここをseleniumのサービス名/wd/hubにする
            DesiredCapabilities::chrome()->setCapability(
                ChromeOptions::CAPABILITY, $options
            )
        );
    }

    ~~~~~~~~~~~~~

    

```


### 動作確認

app,nginx,seleniumでcurl等で疎通ができることを確認する。

#### app

念の為コンテナに入るところから始めます。他も同様

```bash
# コンテナに入る
docker compose exec app
# nginx
curl -i http://nginx
# selenium
curl -i http://selenium:4444

```


##### nginx
```bash
# コンテナに入る
docker compose exec nginx
# app 
curl -i http://app:9000
# selenium
curl -i http://selenium:4444

```

#### selenium
```bash
docker compose exec selenium
# nginx
curl -i http://nginx
# app
curl -i http://app:9000
```

</details>

### テスト実施

スクリーンショットは必ず取ったほうがいいです。
とりあえず実行方法についてだけ記載します。

```bash
# 全テストの実行
php artisan dusk --env=dusk.local
# 特定のテストの実行
php artisan dusk tests/Browser/ExampleTest.php --env=dusk.local
# 特定のメソッドのみ実行
php artisan dusk --filter testBasicExample --env=dusk.local
```

```bash
# .env.dusk.stagingを作った場合
php artisan dusk --env=dusk.staging
```

実行前にキャッシュクリアやseederの実行をしておきます。


-----------------------------------------------------------------------------


## 発生した問題とその対応

地の文多めです。後で直すかも。

- css,jsがseleniumから読み込めない

<details>

<summary><font color="Red">展開</font></summary>

seleniumとの疎通確認後、スクショをみてファッっとなったやつ
原因は npm run devをしているときに php artisan duskを実行したため。
仕様で,public/hotがある場合はそっちを見に行く。
つまり、 app側からは見えるけど、selenium側からは見えないよ！って状態になる。
npm run dev中でも動かせるよう解決しようとしたけど、実現が難しそうなのでduskを実行時はnpm run buildをして、publicのcss,jsを参照するようにした。
問題点として、ホットリロードが効かないので、ブラウザテストで問題があって修正をする場合、手動でビルドが必要になる。逆に言うとそれくらい。

</details>

- CSRFトークン不一致

<details>

<summary><font color="Red">展開</font></summary>

formのテストを作った時に発生。
原因はapp側のserver nameとseleniumから要求する際のserver nameが違っていたため。
テスト実行時、`.env.dusk.local`は`artisan dusk`起動で自動的に読み込まれる。（はずだが、明示的に--env=dusk.localを指定したほうがいい）
appからseleniumに対しては`.env.dusk.local`の設定が使用される。
seleniumからnginxにアクセスする際は.envが使われるため、`.env`と`.env.dusk.local`の`APP_NAME`は一致している必要がある。

流れとしてはこう。
app→selenium `.env.dusk.local`を使う
selenium→nginx `.env`を使う

`.env.dusk.local`はなんのための設定？って思うよね。
ワイトもそう思います。
実際の運用時はコピー用として使うことにしました。
.envはlocalhost:8000で開発している人が多そうだから頻出しそうな問題。

対応策として、.envを.env.dusk.localで上書きする。

laravel-projec直下に以下のシェルを用意するといいです。
作成後、実行権限を付与してください。(chmod +x ****.sh)

ブラウザテスト前に.envの複製をとって、.env.dusk.localで.envを上書きする。
`prepare_dusk_env.sh`

<details>

<summary><font color="Green">展開</font></summary>


```shell
#!/bin/bash

# プロジェクトのルートディレクトリに移動
cd /var/www/laravel-project/

# .env ファイルのバックアップパス
ENV_BACKUP=".env.bak"
# テスト用の .env ファイルパス
ENV_DUSK=".env.dusk.local"
# 実際の .env ファイルパス
ENV_CURRENT=".env"

echo "Duskテスト用の.envファイルを準備します..."

# 1. .env ファイルをバックアップ
if [ -f "$ENV_CURRENT" ]; then
    echo "既存の .env を $ENV_BACKUP にバックアップします。"
    cp "$ENV_CURRENT" "$ENV_BACKUP"
else
    echo "既存の .env ファイルが見つかりません。バックアップはスキップします。"
fi

# 2. .env.dusk.local を .env にコピー
if [ -f "$ENV_DUSK" ]; then
    echo "$ENV_DUSK を $ENV_CURRENT にコピーします。"
    cp "$ENV_DUSK" "$ENV_CURRENT"
    # SANCTUM_STATEFUL_DOMAINS の警告を避けるため、空文字列に置き換え
    # sed -i はmacOSとLinuxでオプションが異なる場合があるため、互換性を考慮
    if [[ "$OSTYPE" == "darwin"* ]]; then
      # macOS
      sed -i '' 's/^SANCTUM_STATEFUL_DOMAINS=null$/SANCTUM_STATEFUL_DOMAINS=""/g' "$ENV_CURRENT"
    else
      # Linux (GNU sed)
      sed -i 's/^SANCTUM_STATEFUL_DOMAINS=null$/SANCTUM_STATEFUL_DOMAINS=""/g' "$ENV_CURRENT"
    fi
else
    echo "エラー: $ENV_DUSK が見つかりません。中断します。"
    exit 1
fi

# 3. Laravelの設定キャッシュをクリア
echo "Laravelの設定キャッシュをクリアします..."
php artisan config:clear
php artisan cache:clear
php artisan route:clear
php artisan view:clear

echo "Duskテスト用の.envファイルの準備が完了しました。"
```

</details>

ブラウザテスト後に.envをもとに戻す

<details>

<summary><font color="Green">展開</font></summary>

```shell
#!/bin/bash

# プロジェクトのルートディレクトリに移動
cd /var/www/laravel-project/

# .env ファイルのバックアップパス
ENV_BACKUP=".env.bak"
# 実際の .env ファイルパス
ENV_CURRENT=".env"

echo "Duskテスト後の.envファイルをクリーンアップします..."

# 1. .env ファイルを元に戻す
if [ -f "$ENV_BACKUP" ]; then
    echo "バックアップした .env を元に戻します。"
    mv "$ENV_BACKUP" "$ENV_CURRENT"
    # 元に戻した後のキャッシュクリアも重要
    echo "Laravelの設定キャッシュを再度クリアします..."
    php artisan config:clear
    php artisan cache:clear
else
    echo "バックアップした .env ファイルが見つかりません。"
    # 元々.envファイルが存在しなかったがDusk用に作成された場合
    if [ -f "$ENV_CURRENT" ]; then
        echo "Dusk用に作成された .env を削除します。"
        rm "$ENV_CURRENT"
    fi
fi

echo "Duskテスト後の.envファイルのクリーンアップが完了しました。"
```
</details>

テスト用に、ドメインを確認するルートを追加したりで確認できます。
CSRF mismatchが発生した時は仕込んで確認してみてください。

web.php
```php
    Route::get('/debug-domain', function () {
    // return response('Domain: ' . var_export(config('session.domain'), true));
        return response('Session Domain: ' . var_export(config('session.domain'), true) . ' | App URL: ' . config('app.url'));
    });
    Route::get('/debug-sanctum-domains', function () {
        return response()->json(config('sanctum.stateful'));
    });
    Route::get('/debug-app-url', function () {
    return response()->json([
        'app_url_env' => env('APP_URL'),
        'parsed_app_url_host' => parse_url(env('APP_URL'), PHP_URL_HOST),
        'base_url_config' => config('app.url'),
    ]);

```

テスト例
```php
    public function testSunctumDomainCheck(): void
    {
        $this->browse(function (Browser $browser) {
            \Log::info('[Dusk] 現在のURL: ' . $browser->driver->getCurrentURL()); // ログ例
            $browser->visit('http://nginx/debug-sanctum-domains'); // http://nginx は環境に合わせてください

            // ページが完全にロードされるのを待つ (重要)
            $browser->pause(1000); // 1秒待機 (または waitForText など)

            // ページの生のソースコードをダンプ
            $pageSource = $browser->driver->getPageSource();
            dump($pageSource);

            // 必要であれば、JSONとしてパースし、'nginx'が含まれているか確認
            // ただし、生のJSON文字列がページソースとして取得されるため、
            // parse_urlで得られるホスト名が配列に含まれているか確認する方が確実です
            $jsonResponse = json_decode(strip_tags($pageSource), true); // HTMLタグを除去してからJSONパース
            dump($jsonResponse);

            // Nginxドメインが配列に含まれていることをアサート
            $this->assertContains('nginx', $jsonResponse);

            $browser->screenshot($this->getScreenShotPath('sanctum-domain-check', 1));
        });
    }

```


</details>

- ログイン確認機能において、ログイン済みとして扱われる
<details>

<summary><font color="Red">展開</font></summary>

仕様らしいです。
browse() はクロージャごとにブラウザインスタンスを使い回す（ブラウザの新規起動ではない）
→ 前のセッションを維持したまま次のクロージャが動く

ログイン系のテスト前にログアウト処理を入れて対応しました。
laravel breezeを使ってる人なら/logoutにpostすればOK.
もしくはsessionを強制的に削除したりと対応は色々可能

</details>

- seleniumのイメージが古い

<details>

<summary><font color="Red">展開</font></summary>

極端に古い場合、通るはずのテストが通らない可能性があります。
`selenium/standalone-chrome:latest`を使うか、新しめのバージョンを明示的に指定しましょう。
ログを仕込んでみて、原因不明なエラーだったり挙動をした場合は更新するに限ります。

</details>

## 直接関係のない雑備忘録

- selenium導入後にURLのポートが消える

<details>

<summary><font color="Red">展開</font></summary>

　例： localhost:8000→localhostになる。
明確な原因がわかっていないが、今までLaravel,nginxがよろしく解釈してくれていた部分がしてくれなくなったぽい。

対応は強制的に`.env`の`APP_URL`を使用するようにした。
```php
# Providers/AppServiceProvider.php

    public function boot(): void
    {
        // ポートが欠落することがある為対応
        if (config('app.url')) {
            URL::forceRootUrl(config('app.url')); // これを追加
        }
    }

```

</details>

- .env.dusk.localについて

<details>

<summary><font color="Red">展開</font></summary>

.env.dusk.local はartisan dusk起動で自動で読み込まれる。  
個別に指定する場合は以下  

```bash
# .env.dusk.stagingを作った場合
php artisan dusk --env=dusk.staging
```

</details>


## github
専用の環境ではないので確認しにくいかもです。
https://github.com/git-syuu2449/laravel_docker

以上です。