Sabigara

Playwright + Next.jsのE2Eテスト環境を構築する

やりたいこと

技術スタック

アプリ

テスト

テスト用インフラを構築する

開発環境も docker compose するとして、 インフラを立ち上げるためのスクリプトに --env test 引数を渡すことで分岐させたい。以下のスクリプトで環境ごとに読む .env* ファイルと docker-compose.* を変更する。

start-infra.sh
ENVIRONMENT=""
COMPOSE_FILE="docker-compose.yaml"
ENV_FILES=".env"

if [ "$ENVIRONMENT" == "test" ]; then
  COMPOSE_FILE="docker-compose.test.yaml"
  ENV_FILES=".env .env.test"
elif [ ! -z "$ENVIRONMENT" ]; then
  echo "Invalid environment: $ENVIRONMENT. Allowed values are '' or 'test'"
  exit 1
fi

source $DIR/setenv.sh $ENV_FILES
docker-compose -f $COMPOSE_FILE up -d
setenv.sh
# 後に指定されたファイル内の値が優先される
if [[ $# -eq 0 ]]; then
  echo "Usage: $0 <path-to-env-file1> <path-to-env-file2> ..."
  exit 1
fi

for file in "$@"; do
  if [[ -f $file ]]; then
    export $(grep -v '^#' $file | xargs)
  else
    echo "Warning: $file does not exist or is not a regular file."
  fi
done

LocalStack

ローカルでAWSサービスをエミュレートできるLocalStackの設定を docker-compose に加える。

docker-compose.test.yaml
version: "3.9"
name: test
services:
  # DBその他...
  localstack:
    image: localstack/localstack
    ports:
      - 127.0.0.1:4566:4566
      - 127.0.0.1:4510-4559:4510-4559
    environment:
      SERVICES: S3
      AWS_ACCESS_KEY_ID: localstack-access-key-id
      AWS_SECRET_ACCESS_KEY: localstack-secret-access-key
    volumes:
      - localstack:/var/lib/localstack
volumes:
  localstack:

例によってDockerサービスの起動を待機するのは面倒だ。wait-for-it.sh はリクエスト・レスポンスを細かく設定できなさそうだったので以下のようなスクリプトを書いた(AIが)。

wait-for-localstack.sh
HOST=$1
PORT=$2
TIMEOUT=${3:-30}

echo "Waiting for LocalStack to be ready..."

for i in $(seq "$TIMEOUT"); do
  if [[ $(curl -s "$HOST:$PORT/_localstack/init/ready" | jq .completed) == "true" ]]; then
    echo "LocalStack is ready!"
    exit 0
  fi
  sleep 1
done

echo "Timed out waiting for LocalStack to be ready."
exit 1
INFO

/_localstack/init/ready についてはLocalStackのドキュメントに記述がある。

バケットの作成とCORSの設定も必要だ。

start-infra.sh
docker compose -f $COMPOSE_FILE exec localstack \
  awslocal s3api create-bucket --bucket <bucket_name>
docker compose -f $COMPOSE_FILE exec localstack \
  awslocal s3api put-bucket-cors --bucket <bucket_name> --cors-configuration "$(cat localstack/bucket-cors.json)"
bucket-cors.json
{
  "CORSRules": [
    {
      "AllowedHeaders": ["*"],
      "AllowedMethods": ["PUT"],
      "AllowedOrigins": ["*"],
      "ExposeHeaders": []
    }
  ]
}
INFO

JSONファイルがホスト側に置いてありコンテナ内部からアクセスできないので文字列としてコマンドから送り込んでいる。

Playwright設定

Installation | PlaywrightIntroductionファビコンplaywright.dev

基本的に公式チュートリアル通りで問題ない。ただしテスト内部からテスト環境(DBなど)に接続するには、以下のようにテスト用dotenvファイルを自分で読みこむ必要がある。

playwright.config.ts
import { config } from "dotenv";

config();
config({ override: true, path: ".env.test" });

export default defineConfig({
  // その他...
  webServer: {
    command: `pnpm next dev --port ${PORT}`,
    url: BASE_URL,
    reuseExistingServer: !process.env.CI,
  },
});

認証

Authentication | PlaywrightIntroductionファビコンplaywright.dev

Playwrightで(実際のフローを経由せず)認証を行うにはCookieなどのストレージを直接いじるのがよさそう。基本は公式チュートリアルの通りだが、NextAuth (+ Prisma)を使う場合は以下のコードで実現できた。

type WorkerFixtures = {
  auth: { user: User };
  workerStorageState: string;
};

export const test = base.extend<Fixtures, WorkerFixtures>({
  auth: [
    async ({ workerStorageState }, use) => {
      const cookies = JSON.parse(
        fs.readFileSync(workerStorageState, "utf-8")
      ).cookies;
      const sessionToken = cookies.find(
        (cookie: any) => cookie.name === SESSION_TOKEN_COOKIE_NAME
      ).value;
      const user = await prisma.user.findFirstOrThrow({
        where: {
          sessions: {
            some: {
              sessionToken,
            },
          },
        },
      });
      await use({
        user,
      });
    },
    { scope: "worker" },
  ],

  storageState: ({ workerStorageState }, use) => use(workerStorageState),

  workerStorageState: [
    async ({ browser }, use) => {
      const id = test.info().parallelIndex;
      const fileName = path.resolve(path.join(AUTH_DIR, `${id}.json`));

      if (fs.existsSync(fileName)) {
        await use(fileName);
        return;
      }

      const { page } = await createAuthedPage(browser);
      await page.context().storageState({ path: fileName });
      await page.close();
      await use(fileName);
    },
    { scope: "worker" },
  ],
});
create-authed-page
import type { Browser } from "@playwright/test";

import { SESSION_TOKEN_COOKIE_NAME } from "./constants";

export async function createAuthedPage(browser: Browser) {
  const page = await browser.newPage({
    storageState: undefined,
  });
  const session = {
    expires: getOneMonthLater(),
    sessionToken: faker.string.uuid(),
  };
  const user = await prisma.user.create({
    data: {
      email: fakeEmail(),
      sessions: {
        create: session,
      },
    },
  });
  await page.context().addCookies([
    {
      domain: "localhost",
      expires: session.expires.getTime() / 1000,
      httpOnly: true,
      name: SESSION_TOKEN_COOKIE_NAME,
      path: "/",
      value: session.sessionToken,
    },
  ]);
  return {
    auth: {
      user,
    },
    page,
  };
}

上記実装によってワーカーごとに別のユーザーとしてログインされ、テスト内部からも auth fixture経由でアクセス可能。以下のように様々なログイン状態をエミュレートできる。

test("ログイン中", async ({ page, auth }) => {
  // test code...
});

test.describe("非ログイン", () => {
  test.use({
    storageState: {
      cookies: [],
      origins: [],
    },
  });
});

test("複数ユーザー", async ({ browser, page, auth }) => {
  const {
    page: anotherPage,
    auth: { profile: anotherProfile },
  } = await createAuthedPage(browser);
});

まとめ