🐢

Docker上のPlaywrightから、Docker上のNext.jsをe2eテストする

に公開

これは何?

  • Dockerに構築したPlaywrightから、Dockerに構築したNext.jsをe2eテストする設定を行った
  • GitHub Actions による CI, Playwright UI test もできるようにした
  • 上記設定を、備忘録として残す

構築環境

  • Next.js環境
    • Next.js 15.3.1
    • Prisma 6.3.1
    • Auth.js 5.0.0-beta.25
    • Postgres 16.4
    • パッケージマネージャ
      • bun
    • Dockerイメージ
      • oven/bun:latest
  • Playwright環境
    • Playwright v15.2
    • パッケージマネージャ
      • npm
    • Dockerイメージ
      • mcr.microsoft.com/playwright:v1.52.0-noble
  • ローカル環境
    • Windows 11(WSL2)
    • Docker Compose v2.32.4-desktop
  • CI
    • GitHub Actions

モチベーションとメリット

  • Dockerでe2eを動かすことで、ローカルとCIとの差異を無くしたい
  • CIの設定を簡素化したい
  • Docker環境でも、なるべくパフォーマンスを落とさずにホットリロードしたい

デメリット

  • 開発環境が複雑になった
    • 依存関係が生まれ、管理が複雑になった
      • コンテナを立ち上げてからファイルを編集しないと、編集結果がリアルタイムにコンテナに反映されない
    • Next.jsはbun、Playwrightはnpmという二重管理になった。
      • PlaywrightはDockerで動かす際にmicrosoftのDockerイメージを使うが、npmの利用が前提になっている
  • 開発環境の初回立ち上げが遅い
    • テスト環境と開発環境で同じDockerfileを使い、テストをなるべく本番に似せた環境で実行したいためにnext buildを実施している。そのため、開発環境の初回立ち上げ時にはビルドが走る。結果として、開発環境のコンテナを初回起動させるためには140秒程度の時間がかかっている。

開発体験が損なわれるのは割と大きなデメリットなので、正直なんとかしたいところ。しかし初回起動でなければコンテナの起動時間は10秒以内に収まるので、依存関係の煩わしさと引き換えに妥協した。改善の余地はある(最後のおまけに記述)。

環境構築

ディレクトリ構成

最終的なディレクトリ構成は以下のとおり(主要なものだけ記載)。
npmとbunで違うpackage.jsonを使うのが特徴。

e2eと同じようにNext.js用の環境もapplicationのようなディレクトリを切って管理したほうが綺麗な構造かもしれないが、今はやっていない。

nextjs-joke # リポジトリのルート(プロジェクトルート)
├── /.github/workflows
│   └── e2e.yml
├── /e2e
│   ├── package.json # npmで管理
│   ├── package-lock.json
│   ├── Dockerfile # Playwright用
│   ├── .dockerignore
│   └── playwright.config.ts
├── package.json # bunで管理
├── bun.lockb # bunが出力するパッケージ管理ファイル
├── Dockerfile # Next.js用
├── .dockerignore
├── docker-compose.yml # 開発環境用
└── docker-compose.e2e.yml # e2e用

リポジトリを直接見たい方はこちら:
https://github.com/YasuakiOmokawa/nextjs-joke

Next.js

過去記事の通り:
https://zenn.dev/yasuakiomokawa/articles/60e6d9750c000d

Playwright

package.json

e2e用の独立したファイルとした。

e2e/package.json
{
  "name": "e2e",
  "version": "1.0.0",
  "scripts": {
    "test": "playwright test",
    "test-ui": "playwright test --ui-host=0.0.0.0 --ui-port=9323"
  },
  "devDependencies": {
    "@playwright/test": "^1.52.0",
    "@prisma/client": "^6.6.0",
    "@types/node": "22.15.3"
  }
}

パッケージインストールするときは、プロジェクトルートから以下のように実行:

$ npm --prefix ./e2e install ./e2e

Dockerfile

e2e/Dockerfile
# 公式イメージ。依存関係を含むパッケージがデフォルトでインストールされている
# このイメージにはplaywright自体は入ってないので別途インストールする必要がある
FROM mcr.microsoft.com/playwright:v1.52.0-noble

EXPOSE 9323
WORKDIR /app

# playwrightのインストール
COPY ./package*.json .
RUN npm ci

# e2e/配下の残りのファイルをホスト側からコピー
COPY . .

# ホスト側のプロジェクトルートから、必要なファイルをコピー
# commonの設定はe2e/docker-compose.ymlに記載
# commonはプロジェクトルートを指定
COPY --from=common ./tsconfig.json .
COPY --from=common ./prisma prisma/
COPY --from=common ./prisma.ts .

# テストデータ作成のためにprisma clientを設定
RUN npx prisma generate

COPY --from=common がミソ。

docker-compose.e2e.yml

e2e/docker-compose.e2e.yml
volumes:
  pg-data:

services:
  e2e-postgres:
    image: postgres:16.8-alpine3.21
    volumes:
      - pg-data:/var/lib/postgresql/data
    ports:
      - "5433:5433"
    environment:
      - POSTGRES_DATABASE=postgres
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=password
      - POSTGRES_ROOT_PASSWORD=root
      - PGPORT=5433
    healthcheck:
      test: pg_isready -U postgres -d postgres
      interval: 3s
      timeout: 3s
      retries: 5

  e2e-application:
    build:
      context: '.' # プロジェクトルートから読み込むことを指定
      dockerfile: Dockerfile
    ports:
      - "3001:3001"
    command: >
      sh -c "bunx prisma migrate deploy && bun start"
    user: vscode
    environment:
      - POSTGRES_PRISMA_URL=postgres://postgres:password@e2e-postgres:5433/postgres?pgbouncer=true&connect_timeout=15
      - POSTGRES_URL_NON_POOLING=postgres://postgres:password@e2e-postgres:5433/postgres
      - APP_ENV=test
      - AUTH_TRUST_HOST=http://e2e-application:3001
      - PORT=3001
      - AUTH_SECRET=test-dummy
    develop:
      watch:
        - action: sync
          path: .
          target: /app
        - action: rebuild
          path: ./package.json
    depends_on:
      e2e-postgres:
        condition: service_healthy

  e2e:
    build:
      context: './e2e' # プロジェクトルート/e2e から読み込むことを指定
      dockerfile: Dockerfile
     # contextをプロジェクトルート/e2e にしたので、dockerはこのcontext配下のファイルしか
     # 読めない設定となったが、additional_contextsを指定することで特定のディレクトリを
     # 読み込み対象とすることができる。ここでは、プロジェクトルート配下のファイルを読みたい
     # のでプロジェクトルートをcommonとしてコンテキストを追加した。
     # 実際の使い方はe2e/Dockerfileを参照
      additional_contexts: 
        - common=./
    environment:
      - CI=${CI}
      # Docker同士の通信では、サービス名をホスト名に指定する
      - PLAYWRIGHT_BASE_URL=http://e2e-application:3001
      - POSTGRES_PRISMA_URL=postgres://postgres:password@e2e-postgres:5433/postgres?pgbouncer=true&connect_timeout=15
      - POSTGRES_URL_NON_POOLING=postgres://postgres:password@e2e-postgres:5433/postgres
      - APPDOMAIN=e2e-application
    # CIとローカルで起動するコマンドを分けたいので、環境変数をコマンドラインに渡して
    j# 判定させる
    command: >
      sh -c "${E2E_SERVICE_COMMAND}"
    volumes:
      - ./e2e/playwright-report:/app/playwright-report
      - ./e2e/test-results:/app/test-results
    ports:
      - "9323:9323"
    develop:
      watch:
        # ホスト側の./e2eディレクトリ配下のファイルが変更されたら、コンテナをホットリロード
        - action: sync
          path: ./e2e
          target: /app
        # ホスト側の./e2e/package.jsonが変更されたら、Dockerイメージをリビルド
        - action: rebuild
          path: ./e2e/package.json
    depends_on:
      - e2e-application

特徴は、contextを./e2eに指定して余計なファイルをDockerに読ませないようにしたこと。
ただ、そうするとプロジェクトルート配下のファイルが読まれないのでadditional_contextsを指定。
https://docs.docker.com/reference/compose-file/build/#additional_contexts

ちなみに、プロジェクトルートをコンテキストにすると、dockerfileの指定が./e2e/Dockerfileであっても、プロジェクトルート配下の.dockerignoreが読まれてしまい、意図しない挙動となる可能性があるので注意。(dockerの動きをあまり理解していなくてここで2時間くらい溶かした)。

watchの設定を入れている理由としては、テストコードを修正したらすぐ再テストしたいから。また、関連してアプリケーションコードも弄りたいだろうからe2e-applicationにもwatchの設定を入れてホットリロードを可能にした。

.github/workflows/e2e.yml

こちらは参考元の記事を殆どそのまま利用。
https://zenn.dev/ishiyama/articles/c85138b42e3e1f

起動単位はpushにしたが、e2eは時間のかかるテストなので検討の余地あり。

.github/workflows/e2e.yml
name: End-to-End Testing
run-name: E2E Testing By @${{ github.actor }}
on: [push] # コードがpushされるごとに実行

jobs:
  e2e:
    timeout-minutes: 10
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: docker compose build
        run: docker compose -f docker-compose.e2e.yml build

      - name: Run Playwright tests
        # E2E_SERVICE_COMMANDにnpm testを渡し、非UIモードでテスト
        run: E2E_SERVICE_COMMAND='npm test' docker compose -f docker-compose.e2e.yml up --abort-on-container-exit --exit-code-from e2e

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: e2e/playwright-report/
          retention-days: 30

      - name: Cleanup Docker resources
        if: always()
        run: docker compose -f docker-compose.e2e.yml down --rmi all --volumes --remove-orphans

npm test でpackage.jsonに記載したplaywright testが動く。なお、npm testは特殊なコマンドで、test以外を実行しようとする場合にnpm run コマンド名と記載する必要があるのを忘れてハマった(bunだと基本的にbun コマンド名で動くのでごっちゃになった)。

ref. https://qiita.com/kikkaya088/items/65cf41a826bbb9513702

動作確認

ローカルでUIモードでテスト

$ E2E_SERVICE_COMMAND='npm run test-ui' docker compose -f docker-compose.e2e.yml up --build --watch

package.jsonに記載したplaywright test --ui-host=0.0.0.0 --ui-port=9323が起動し、UIモードでテストできる。

ブラウザにlocalhost:9323とアクセスすると以下のようになる。

※ Docker上で起動したplaywrightをブラウザで表示させたいときはui-hostui-portの指定が必要。
ref. https://playwright.dev/docs/docker#remote-connection

--watch とつけているので、アプリケーションコードやテストコードを修正したらホットリロードされる(ただ、プロジェクトルートからコピーしたtsconfig.jsonなどを修正しても自動反映されないので、Dockerイメージをリビルドしないといけない)

CI

CIが正常終了し、結果レポートもアップロードされている。

開発環境の立ち上げ

docker-compose.e2e.ymlの立ち上げと混同しがちなので、--buildは常につけると混乱しない。

$ docker compose up --build --watch

終わりに

参考とした記事がとてもよかった。
開発体験の向上が今後の課題だが、個人開発で使うのであれば充分に捗りそうな予感がしている。
VRTも試してみたい。

e2eを充実させていきたい。

おまけ

開発環境の立ち上げを高速にしたい場合、docker compose--build-argオプションで可能だった。

まず、DockerfileのRUNの記述にARGで変数を指定し、デフォルト値としてアプリケーションのビルドコマンドを設定しておく。

ARG APP_BUILD_CMD='bun deployable-test'
RUN bunx prisma generate \
&& ${APP_BUILD_CMD}

次に、--build-argオプションで指定した変数に空文字を渡せばアプリケーションのビルドコマンドが実行されなくなる。

docker compose build --build-arg APP_BUILD_CMD='' && docker compose up --watch

これで立ち上げが60秒くらい早くなる(ただ、アプリケーション全体の動きが多少もっさりする)。

Discussion