T3 AppをGitHub Actions + Vitest + PlaywrightでVercelにデプロイする
T3 Stackの学習に合わせてハンズオンの幅を広げるためにGitHub ActionsとVitest、Playwrightを組み合わせてVercelへデプロイする簡易的なCI/CDパイプラインを構築してみたのでポイントを記載する。
前提
T3 Appの基本的なセットアップ方法はCreate T3 Appの公式ドキュメントのFirst StepsとVercelに記載されているので、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環境デプロイの流れとしている。
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をコンテキストに渡す。
...
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を追加。合わせてカバレッジ出力にも対応しておく
"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.reporter
にtext
を指定するとレポートが標準出力されるのでジョブの実行結果で簡単に確認できる
-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
を指定(特に指定しなくても動作する気がする)
"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 のテスト手法に記載されている原因でコストがかかりそうだったのでスキップした(対応方針は興味深いので自分でコンポーネント設計する際は考慮したい)
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/tests
はbulletproof-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ワークフロー実行時に渡される環境変数を参照するように修正する
@@ -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を利用する
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