🚀

T3 AppをGitHub Actions + Vitest + PlaywrightでVercelにデプロイする

2024/09/11に公開

T3 Stackの学習に合わせてハンズオンの幅を広げるためにGitHub ActionsとVitest、Playwrightを組み合わせてVercelへデプロイする簡易的なCI/CDパイプラインを構築してみたのでポイントを記載する。

https://create.t3.gg/

前提

T3 Appの基本的なセットアップ方法はCreate T3 Appの公式ドキュメントのFirst StepsVercelに記載されているので、GitHubリポジトリ連携によるVercelデプロイまでの手順は省略する。(細かい設定事項は下記参照)

  • create t3-appコマンド実行時の初期化選択肢は以下の通り
❯ npm create t3-app@latest

Need to install the following packages:
create-t3-app@7.37.0
Ok to proceed? (y)
   ___ ___ ___   __ _____ ___   _____ ____    __   ___ ___
  / __| _ \ __| /  \_   _| __| |_   _|__ /   /  \ | _ \ _ \
 | (__|   / _| / /\ \| | | _|    | |  |_ \  / /\ \|  _/  _/
  \___|_|_\___|_/‾‾\_\_| |___|   |_| |___/ /_/‾‾\_\_| |_|


│
◇  What will your project be called?
│  t3-app
│
◇  Will you be using TypeScript or JavaScript?
│  TypeScript
│
◇  Will you be using Tailwind CSS for styling?
│  Yes
│
◇  Would you like to use tRPC?
│  Yes
│
◇  What authentication provider would you like to use?
│  NextAuth.js
│
◇  What database ORM would you like to use?
│  Prisma
│
◇  Would you like to use Next.js App Router?
│  Yes
│
◇  What database provider would you like to use?
│  SQLite (LibSQL)
│
◇  Should we initialize a Git repository and stage the changes?
│  Yes
│
◇  Should we run 'npm install' for you?
│  Yes
│
◇  What import alias would you like to use?
│  ~/
  • IdPはDiscordをデフォルトのまま利用する
  • データベースはSupabaseを利用する
    • ローカル動作確認後にSQLiteから向き先を変更する
    • ホビーと割り切って環境区分けはせず接続先は単一のデータベースに集約する
  • ブランチ管理はトランクベースでmainブランチのみの運用とする

GitHub Actions ワークフローの構成

今回組んだCI/CDパイプラインの構成は以下の通り。

VercelではプロジェクトごとにPreviewとProduction環境が提供される。(厳密にはローカルの位置付けとなるDevelopment環境もあるが割愛)
今回はその2つの環境を活用してVitestによる単体テスト実行、VercelのPreview環境デプロイ、Preview環境上でPlaywrightによるE2Eテスト実行、最後にProduction環境デプロイの流れとしている。

cicd.yaml
name: Vercel Production Deployment
env:
  VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
  VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
on:
  push:
    branches: [main]
  workflow_dispatch:
concurrency:
  group: ${{ github.workflow }}
  cancel-in-progress: true
jobs:
  Unit-Test:
    runs-on: ubuntu-latest
    timeout-minutes: 3
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - name: Install dependencies
        run: npm ci
      - name: Run Unit Tests
        run: npm run test:coverage
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: vitest-coverage-report
          path: coverage/
          retention-days: 30
  Deploy-Preview:
    needs: Unit-Test
    runs-on: ubuntu-latest
    timeout-minutes: 3
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - name: Install Vercel CLI
        run: npm install --global vercel@latest
      - name: Pull Vercel Environment Information
        run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
      - name: Build Project Artifacts
        run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
      - name: Deploy Project Artifacts to Vercel
        id: preview
        run: echo "url=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }})" >> "${GITHUB_OUTPUT}"
    outputs:
      preview-url: ${{ steps.preview.outputs.url }}
  E2E-Test:
    needs: Deploy-Preview
    runs-on: ubuntu-latest
    timeout-minutes: 3
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - name: Install dependencies
        run: npm ci && npx playwright install --with-deps
      - name: Run tests
        run: npx playwright test
        env:
          NODE_ENV: development
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          BASE_URL: ${{ needs.Deploy-Preview.outputs.preview-url }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30
  Deploy-Production:
    needs: E2E-Test
    runs-on: ubuntu-latest
    timeout-minutes: 3
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - name: Install Vercel CLI
        run: npm install --global vercel@latest
      - name: Pull Vercel Environment Information
        run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
      - name: Build Project Artifacts
        run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
      - name: Deploy Project Artifacts to Vercel
        run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}

以下、各ジョブ定義の設定背景とポイントについて掘り下げる。

Deploy-Preview & Deploy-Production | Vercelへのデプロイ

Vercelの公式ドキュメントをベースとして追加で留意するポイントは以下の通り。

GitHubリポジトリ連携の解除

GitHubリポジトリ連携したままだとpush時に二重でデプロイが発生することになるのでSettings > Git > Ignored Build Stepで連携を解除する。

Vercel Preview環境のURLをジョブ出力

VercelのPreview環境のドメインを固定していないため、Deploy-Previewで動的に生成されるURLに対して後続のE2E-Testでアクセスできるようにジョブ出力で生成されたURLをコンテキストに渡す。

cicd.yaml
...
steps:
...
  - name: Deploy Project Artifacts to Vercel
    id: preview
    run: echo "url=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }})" >> "${GITHUB_OUTPUT}"
outputs:
  preview-url: ${{ steps.preview.outputs.url }}
...

Vercel Preview環境の認証を無効化

VercelのPreview環境はデフォルトで認証要求をするため、E2E-Testのハードルにならないようホビーと割り切ってSettings > Deployment Protection > Vercel Authenticationで認証設定を無効にする。

Unit-Test | Vitestによる単体テスト

Vitestの導入

Next.jsの公式ドキュメントをベースとしつつ最低ラインで必要な各種設定を組み込む。

関連モジュールのインストール

npm install -D vitest @vitejs/plugin-react happy-dom @testing-library/react @testing-library/jest-dom @testing-library/user-event @vitest/coverage-v8

Configのセットアップ

package.json(create t3-appとのdiff)
  • 実行用のnpm scriptを追加。合わせてカバレッジ出力にも対応しておく
package.json
     "dev": "next dev",
     "postinstall": "prisma generate",
     "lint": "next lint",
-    "start": "next start"
+    "start": "next start",
+    "test": "vitest",
+    "test:coverage": "vitest run --coverage"
   },
vitest.config.ts(前掲Next.js公式ドキュメントとのdiff)
  • ミニマムではtest.globalsの設定はなくても良い気がしたが、こちらのissueの内容でハマったので素直に定義することにした
  • test.coverage.reportertextを指定するとレポートが標準出力されるのでジョブの実行結果で簡単に確認できる
vitest.config.ts
-import { defineConfig } from "vitest/config";
+import { fileURLToPath } from "url";
+import { configDefaults, defineConfig } from "vitest/config";
 import react from "@vitejs/plugin-react";

 export default defineConfig({
   plugins: [react()],
   test: {
-    environment: "jsdom",
+    globals: true,
+    exclude: [...configDefaults.exclude, "**/e2e/**"],
+    alias: {
+      "~/": fileURLToPath(new URL("./src/", import.meta.url)),
+    },
+    environment: "happy-dom",
+    coverage: {
+      provider: "v8",
+      reporter: ["text", "html"],
+      reportsDirectory: "./coverage",
+    },
   },
 });
tsconfig.json(create t3-appとのdiff)
  • Vitestの公式ドキュメントのglobalsのガイドに従い、typesを指定(特に指定しなくても動作する気がする)
tsconfig.json
     "baseUrl": ".",
     "paths": {
       "~/*": ["./src/*"]
-    }
+    },
+    "types": ["vitest/globals"]
   },
   "include": [
     ".eslintrc.cjs",
@@ -38,5 +39,5 @@
     "**/*.js",
     ".next/types/**/*.ts"
   ],
-  "exclude": ["node_modules"]
+  "exclude": ["node_modules", "coverage"]
 }

サンプルテストコード

src/app/_components/post.test.tsx
  • T3 Appの既存コードに対して動作確認用に作成。(型付けは怪しいかも…)ファイル配置はコロケーションを意識した
  • src/app/page.tsxのテストコードも書こうとしたが、React Server Components のテスト手法に記載されている原因でコストがかかりそうだったのでスキップした(対応方針は興味深いので自分でコンポーネント設計する際は考慮したい)
post.test.tsx
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import "@testing-library/jest-dom";
import { LatestPost } from "./post";

const mockUseSuspenseQuery = vi.hoisted(() => vi.fn());
const mockMutate = vi.fn(
  async (
    newData: { name: string },
    callback: { onSuccess: () => Promise<void> },
  ) => {
    await callback.onSuccess();
  },
);
let mockIsPending = false;
const mockInvalidate = vi.fn();

vi.mock("~/trpc/react", () => ({
  api: {
    post: {
      getLatest: {
        useSuspenseQuery: mockUseSuspenseQuery,
      },
      create: {
        useMutation: vi.fn((callback: { onSuccess: () => Promise<void> }) => ({
          mutate: (newData: { name: string }) => mockMutate(newData, callback),
          isPending: mockIsPending,
        })),
      },
    },
    useUtils: vi.fn(() => ({
      post: {
        invalidate: mockInvalidate,
      },
    })),
  },
}));

describe("LatestPost", () => {
  beforeEach(() => {
    mockMutate.mockClear();
    mockUseSuspenseQuery.mockReturnValue([{ name: "Test Post" }]);
    mockIsPending = false;
  });

  it("renders with a latest post", () => {
    render(<LatestPost />);
    expect(
      screen.getByText("Your most recent post: Test Post"),
    ).toBeInTheDocument();
  });

  it("renders without a latest post", () => {
    mockUseSuspenseQuery.mockReturnValue([]);
    render(<LatestPost />);
    expect(screen.getByText("You have no posts yet.")).toBeInTheDocument();
  });

  it("renders with submitted", () => {
    render(<LatestPost />);
    expect(screen.getByText("Submit")).toBeInTheDocument();
    expect(screen.getByText("Submit")).not.toBeDisabled();
  });

  it("renders with submitting", () => {
    mockIsPending = true;
    render(<LatestPost />);
    expect(screen.getByText("Submitting...")).toBeInTheDocument();
    expect(screen.getByText("Submitting...")).toBeDisabled();
  });

  it("submits new post", async () => {
    render(<LatestPost />);
    const input = screen.getByPlaceholderText("Title");
    const button = screen.getByText("Submit");

    await userEvent.type(input, "New Post");
    await userEvent.click(button);

    expect(mockMutate).toHaveBeenCalledTimes(1);
    expect(mockMutate).toHaveBeenCalledWith(
      expect.objectContaining({ name: "New Post" }),
      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
      expect.objectContaining({ onSuccess: expect.any(Function) }),
    );
    expect(mockInvalidate).toHaveBeenCalledTimes(1);
    expect(screen.getByPlaceholderText("Title")).toBeInTheDocument();
  });
});

ジョブ定義

GitHub Actions、Vitestからはベースにできそうな公式のリファレンスを見つけることができなかったので後述のPlaywrightの初期化時に生成されるGitHub Actionsワークフローのplaywright.ymlの内容を踏襲した。

E2E-Test | PlaywrightによるE2Eテスト

Playwrightの導入

Playwrightの公式ドキュメントをベースとしつつ最低ラインで必要な各種設定を組み込む。

モジュールの初期化

テストコードの格納ディレクトリ名e2e/testsbulletproof-reactに寄せてみた。

❯ npm init playwright@latest
Need to install the following packages:
create-playwright@1.17.133
Ok to proceed? (y)
Getting started with writing end-to-end tests with Playwright:
Initializing project in '.'
✔ Where to put your end-to-end tests? · e2e/tests
✔ Add a GitHub Actions workflow? (y/N) · true
✔ Install Playwright browsers (can be done manually via 'npx playwright install')? (Y/n) · true

Configのセットアップ

playwright.config.ts(npm init playwrightとのdiff)
  • GitHub Actionsワークフロー実行時に渡される環境変数を参照するように修正する
playwright.config.ts
@@ -11,7 +11,7 @@
  * See https://playwright.dev/docs/test-configuration.
  */
 export default defineConfig({
-  testDir: "./e2e",
+  testDir: "./e2e/tests",
   /* Run tests in files in parallel */
   fullyParallel: true,
   /* Fail the build on CI if you accidentally left test.only in the source code. */
@@ -25,7 +25,7 @@
   /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
   use: {
     /* Base URL to use in actions like `await page.goto('/')`. */
-    // baseURL: 'http://127.0.0.1:3000',
+    baseURL: process.env.BASE_URL ?? "http://localhost:3000",

     /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
     trace: "on-first-retry",

テストユーザー用のダミーレコードの投入

サンプルテストコードではログイン状態までは確認できるようにダミーのCookieを作成してT3 App(NextAuth.js)の認証をバイパスする方法を試してみた。
ダミーのCookieに対応するSession TBLレコードの関連先として、いわゆるテスト用ユーザーのレコードを事前にUser TBLに投入しておく。

INSERT INTO "public"."User" ("id", "name", "email", "emailVerified", "image") VALUES ('1', 'test', 'test', current_timestamp, null);

サンプルテストコード

e2e/tests/t3-app.spec.ts
  • ログイン済みの状態でアクセスすると、ユーザ情報が表示されるのケースでは前述のテストユーザーのIDに紐付ける形でSession TBLのレコードを作成し、それに整合するようにダミーのCookieを付与している
  • ブラウザバリエーションでChrome、Firefox、Safariを並列実行する際にセッショントークンが衝突しないようにUUIDを利用する
t3-app.spec.ts
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";

test("ログイン未済の状態でアクセスすると、ユーザ情報が表示されない", async ({
  page,
}) => {
  await page.goto("/");

  await expect(page).toHaveTitle(/Create T3 App/);
  await expect(page.getByText(/Logged in as test/)).not.toBeVisible();
  await page.getByRole("link", { name: /Sign in/ }).isVisible();
});

test("ログイン済みの状態でアクセスすると、ユーザ情報が表示される", async ({
  browser,
}) => {
  const DUMMY_TOKEN = crypto.randomUUID();

  const prisma = new PrismaClient();
  await prisma.session.create({
    data: {
      sessionToken: DUMMY_TOKEN,
      userId: "1",
      expires: new Date(new Date().getTime() + 86400),
    },
  });

  const context = await browser.newContext();
  await context.addCookies([
    {
      name: process.env.CI
        ? "__Secure-next-auth.session-token"
        : "next-auth.session-token",
      value: DUMMY_TOKEN,
      domain: process.env.BASE_URL
        ? new URL(process.env.BASE_URL).hostname
        : "localhost",
      path: "/",
      httpOnly: true,
      secure: Boolean(process.env.CI),
      sameSite: "Lax",
    },
  ]);

  const page = await context.newPage();
  await page.goto("/");

  const cookie = await page.context().cookies();
  console.log(cookie);

  await expect(page).toHaveTitle(/Create T3 App/);
  await expect(page.getByText(/Logged in as test/)).toBeVisible();
  await page.getByRole("link", { name: /Sign out/ }).isVisible();

  await prisma.session.delete({
    where: {
      sessionToken: DUMMY_TOKEN,
    },
  });
});

ジョブ定義

Vercelの公式ドキュメントに記載があるが、これはdeployment_statusをトリガーとしておりGitHubリポジトリ連携を前提としているため今回の用途としては適当ではなかった。
素直にPlaywright初期化時に生成されるplaywright.ymlをベースにVercelのPreview環境のURLとSupabaseのURLを環境変数として追加している。

DATABASE_URL

SupabaseのURLを設定しているのはテストコードからPrismaで直接データベースを操作するため。
GitHub Actionsのシークレットとして設定した値を取り出す形としている。

最終的なファイル変更箇所

create t3-app初期コミットとの差分を取ると以下の通り。
prisma/schema.prismaの差分はデータベースの向き先をSupabaseに変更した際のものとなっている。

❯ git diff 7246ca9 HEAD --name-status
A       .github/workflows/cicd.yaml
M       .gitignore
A       e2e/tests/t3-app.spec.ts
M       package-lock.json
M       package.json
A       playwright.config.ts
M       prisma/schema.prisma
A       src/app/_components/post.test.tsx
M       tsconfig.json
A       vitest.config.ts

Discussion