Next.js + Prisma + jest-prismaの環境で、playwrightのE2Eテストを行いたい
prismaを用いたシステムでのテスト時に「各テストケース内で行ったDBに対する変更を、テストケース終了後に破棄してリセットしたい」という夢を叶えるツールとして、jest-prismaという素晴らしいライブラリがある。
これをvitestのユニットテスト内で利用していたのだが、新規に立ち上げることになったE2Eテスト環境でも使えないかと思い、試行錯誤した結果を記載しておく。
尚、「様々なトレードオフを受け入れた上で、なんとか最低限実行できる環境にはなった」という感触であることを、先にお伝えしておきたい。。。
# E2E導入の背景
Next.jsの中でnext-navigation-modeを利用しており、種々の変更後にも適切にNavigationGuardが効いていることを確認する必要が出てきた。
これは流石にユーザー環境に近い状態でないとテストできないと判断し、E2Eテストを行おう、となった。
# 環境
前提となるE2Eテストの実行環境
DB: postgresqlのDockerコンテナx1
CI: github actionsでDBコンテナ起動やplaywrightのテストなどを行う
今回の主要な登場人物たちのバージョン。
next: v15.2.3
prisma: v6.5.0
@prisma/client: v6.5.0
@quramy/jest-prisma-core: v1.8.1
@quramy/prisma-fabbrica: v2.3.0
@playwright/test: v1.55.0
nextはturbopackからの過渡期のバージョンのため、devサーバーのみturbopackでの実行可(ただし、後述するがwebpackを利用することになる)。
バージョン依存の話で、それ以外は特に特筆すべきことはなさそうか。
# 達成「できた」ことと「できなかった」こと
達成できたこと
playwrightで実行するブラウザ種別ごとに、DBへの変更内容の隔離・破棄を行うこと
例えば、chromiumでのテスト時に行ったDBへの変更は、webkitでのテスト側からは参照できない状態が達成できた
watchモード的に動作しないplaywrightの実行方法であれば、テスト開始時にwebサーバーを起動し、終了後に破棄するので、これによりDBへの変更も破棄することができる(逆に言うと、vscode拡張機能やuiモード実行時には破棄されずに追加で実行されることになるので要注意)
比較的短い時間でE2Eテストを実行完了させること
devサーバーではなくビルド済みサーバーを利用する形にした(後述)ので、テストの実行時間自体は短縮された可能性あり
達成できなかったこと
各テストケースで行ったDBへの変更が、他のテストケースに全く影響しないようにすること(=fully parallelなテスト実行ができるようにすること)
ブラウザ間では影響し合わないが、同じブラウザ内では単一のトランザクション内での処理となるため、テストの実行順序に気を使う必要があるのが最大の難点
簡単な仕組みでNext.js/jest-prisma/playwrightによる環境を構築すること
next側のコードの変更に追従して、テスト結果が変わる環境を用意すること
おまけ: 今回の最終成果に至るまでに、選択肢として挙がったものの選択しなかったこと
vitestのbrowserモードの利用
E2EとUnitテストの中間に位置するもの、という印象で、ページ遷移などを含めたテストを行う必要があるのであれば、やはりplaywrightなどを直接実行するしかなさそう。
# 実装後の構成
完成形の大枠を先に説明しておく。
1. DBコンテナを起動しておく(docker compose up -dなど)。ローカルでもGithub actions上でも同じ。
2. jest-prismaによるトランザクションを噛ませたビルド成果物をnext buildによって得る
これを行う方法はいくつかありそうだが、今回はnext.configに細工をする形とした
3. 上記の2.で得た成果物を、playwright.configのwebServer: []内で、走らせるブラウザの数だけ実行する
例えば、chromium環境用には$ PORT=3001 npm run startを実行し、webkit環境用には$ PORT=3002 npm run startを実行する、といった具合
4. それぞれのサーバーに対して、テストを走らせる
# ポイント
まず、肝となる上記2.の「jest-prismaによるトランザクションを噛ませたビルド成果物」のところについて解説する。
そもそもjest-prismaが内部で何を行っているかというと(自分のざっくりとした理解では)、通常prismaクライアントに対して発行するクエリを、全てprisma.$transactionを用いたトランザクション環境に置き換えて実行するということと、そのトランザクションの開始・終了を外から行えるようなIFを提供するということ。
このトランザクションの開始・終了をPrismaEnvironmentDelegateのインスタンス経由で行えるようにしており、例えばvitestのユニットテストにおいては以下のような内容(詳細は割愛して要点のみ抽出したコード)を適所で呼び出す形になるかと思う。
code:typescript
import { initialize, resetSequence } from "@/__generated__/fabbrica"
// delegateとprisma clientの初期化と登録
const delegate = new PrismaEnvironmentDelegate({ /** delegateの各種設定がここに来る */})
const vPrisma = await delegate.preSetup()
initialize({
prisma: () => vPrisma.client
})
// トランザクションの開始
resetSequence()
await delegate.handleTestEvent({
name: "test_start",
}),
await delegate.handleTestEvent({
name: "test_fn_start",
}),
// トランザクションの終了
await delegate.handleTestEvent({
name: "test_done",
}),
await delegate.handleTestEvent({
name: "test_fn_success",
}),
これらのコードをplaywright実行時にのみ埋め込む方法を探る必要があるのだが、ここで色々と課題が出てきたので、思考・試行の過程を綴っておく。
【どうやってトランザクション終了させる?問題】
できればnext devで実行されるdevサーバーを立ててテストを行いたいが、そうすると外部から直接delegateオブジェクトにアクセスする術がない
開始はサーバー起動時などに行えばトランザクションx1は確保できるが、終了ができない
delegate.handleTestEvent()を実行するためのAPIエンドポイントをNext.js内のルートとして設置する、ということは可能だが、本番コード側にテスト用コードが紛れ込むことはしたくない
next buildの成果物をサーバーとして起動したとしても、delegateオブジェクトに直接アクセスできないのは同様
▶ 結論: 「トランザクションの終了を諦める」という選択肢を取った
【どこでvPrismaに差し替える?問題】
現在の環境では、prismaクライアントをsingletonとして扱う仕組みを導入しており、これをsrc/prisma.ts内で制御していた。このファイル内でdelegateの設置+vPrismaへの差し替えを行う形だと、vPrismaの生成時にawaitが必須となり、seedファイル実行時などに利用しているts-node環境でTop-level await対応できていない(方法はあるがこれまでのコードのままでは動かない)、という問題が発生。
ts-node(コンパイルステップあり)からtsx(type-strippingのみ)に置き換えることで、ほぼコードの書き換えを行う必要がなくなるが、代わりにseed系スクリプト実行時の型エラーが全て無視されてしまう。。。これもできれば避けたい。
他に「差し替え」が可能なのはwebpackのみか、となり、next.conifg.tsの変更を試みる。これは上手くは行くものの、next.config内でdelegateのawaitとwebpackの設定変更を行おうとすると、①next.config内でexportするものを、オブジェクトから関数に変更する必要が出てくるし②turbopackがオフになる=通常開発時のdevサーバーもwebpack版になってしまう
①の理由は、こちらでもやはりvPrisma生成時にTop-level awaitが発生するためで、関数型であればTop-levelではなくなる
①によって②が引き起こされてる感じがする(関数型にした時点で、turbo: {}のオプションが効かなくなってるっぽい。深追いしてないので確証はない。)が、どうなのだろう。。。
next.configのロケーションなどをnextのcliコマンドで選べるようになっていればよいのだが、そんなオプションが存在しないので、E2Eテスト時だけwebpackを利用し、その他の場合はturbopack、という切り替えができない(書きながら気づいたが、export defaultする際に条件分岐すればよいのか。。。)
▶ 結論: prisma.tsでの差し替えやturbopackの利用を諦めて、next.configでの差し替え+webpack利用への変更、を選択した
【どうやってE2Eテスト用のサーバー立てる?問題】
next devで実行したdevサーバーは、ページ遷移時に遷移先ページのコンパイルを始めるが、コンパイルに謎に時間が掛かったり何ならコンパイルが永遠に終わらないことがあるので、安定して自動テストを行うことができない
next buildの成果物を利用する方法だと、ビルド完了までの時間は掛かるがテスト時でも安定した稼働は可能
▶ 結論: devサーバーに対するテスト実行を諦めて、「実行に時間がかかるし色々面倒だが、安定してテスト実行できるnext buildの成果物を利用する」という選択肢を取った
# 最終的なコード
最初に、今回変更は行っていないがprisma.tsは以下の状態。
ただし、「ここでglobalThisに対してprismaの登録を行っているため、webpackを用いたglobalThis内のprismaオブジェクト差し替えが効いてくる」というのは重要なポイント。
code:src/prisma.ts
import { PrismaClient } from "@prisma/client"
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
const prisma = globalForPrisma.prisma || new PrismaClient()
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma
}
export { prisma }
次に、next.configの中身。
これまでに触れていない注意点として、PrismaEnvironmentDelegateのタイムアウト時間を伸ばしておかないと、テスト中にタイムアウトしてしまう(デフォルト値5秒)。
code:next.config.ts
import {
type JestPrisma,
type PrismaClientLike,
PrismaEnvironmentDelegate,
} from "@quramy/jest-prisma-core"
import { initialize } from "@quramy/prisma-fabbrica"
import { withSentryConfig } from "@sentry/nextjs"
import type { NextConfig } from "next"
import { webpack } from "next/dist/compiled/webpack/webpack"
let delegate: PrismaEnvironmentDelegate
let vPrisma: JestPrisma<PrismaClientLike>
export default async (
phase: string,
{ defaultConfig }: { defaultConfig: Record<string, unknown> },
) => {
// 以下は環境に依らない共通設定
let nextConfig: NextConfig = {
...defaultConfig,
output: "standalone", // 我々の本番環境ではstandaloneモードでビルドしている
/** 中略 */
}
// 以下はE2Eテスト用のPrisma Clientをグローバルに注入するためのコード
if (process.env.APP_ENV === "e2e") {
// 最初にdelegateを生成する (各種の設定はここで可能)
delegate = new PrismaEnvironmentDelegate(
{
// @ts-ignore: 必須プロパティなしでも動作する
projectConfig: {
testEnvironmentOptions: {
// これらの設定を行わないと、テスト途中でトランザクションが勝手に終了してしまう
maxWait: 1000000,
timeout: 1000000,
},
},
/** 中略 */
},
)
// ここのawaitのために関数型configにする必要がある...
vPrisma = await delegate.preSetup()
initialize({
prisma: () => vPrisma.client,
})
// transactionの開始を宣言する
await Promise.all([
// @ts-ignore: 必須プロパティなしでも動作する
delegate.handleTestEvent({
name: "test_start",
}),
// @ts-ignore: 必須プロパティなしでも動作する
delegate.handleTestEvent({
name: "test_fn_start",
}),
])
nextConfig = {
...nextConfig,
cleanDistDir: true,
distDir: ".next.e2e",
/** 中略 */
webpack: (webpackConfig, { isServer, nextRuntime }) => {
// E2Eテスト用のPrisma Clientをグローバルに注入するためのコード
if (
isServer &&
nextRuntime === "nodejs" &&
process.env.APP_ENV === "e2e"
) {
console.warn(
"⚠️ TEST ONLY: Injecting jest-prisma client into globalThis",
)
// 以下が今回の変更の核で、globalThisに対してprisma: vPrisma.clientを登録している
webpackConfig.plugins.push(
new webpack.ProvidePlugin({
prisma: vPrisma.client,
vPrismaDelegate: delegate,
vPrisma: vPrisma,
}),
)
}
// webpackConfigをreturnすることを忘れずに
return webpackConfig
},
}
}
return nextConfig
}
そして、上記を元にplaywrightを問題なく実行するために用意した、今回のプロジェクトにおけるpackage.json内のscriptセクション。
ここでもいくつかポイントがある。
Next.jsの機能として.env.(development|production|test)を自動で読み込んでくれるというものがあるが、我々の環境ではこれだとDATABASE_URLが上書きされてしまってテスト用DBに繋げないので、元々.env.developmentだったものを敢えて.env.devという名前に変更し、dotenvで個別に読み込む形にしている
E2Eで/.nextをそのまま利用すると予期せぬ不具合が起きそうなので、/.next.e2eというフォルダにビルド成果物を保存するようにしている。またそのためにpublicなどのファイルの再配置が必要になっている(理由までは追っていないが、これで動いている)
npm-watchは必須ではないが、「テストファイルの変更に対して、watchモード的にテスト再実行させる」はこれで達成可能。注意が必要なのは「ビルド成果物をテスト対象にしているので、Next.js内の変更に対して追従させるには都度ビルドが必要になる」ということ。(自分はそこまではしなくていいという判断)
code:package.json
{
/** 前略 */
"scripts": {
"dev": "dotenv -e .env.dev next dev --turbopack", // --turbopackは効いていないが念の為残している...
"build": "next build",
"build:e2e": "dotenv -e .env.e2e -- next build && cp -pr ./public ./.next.e2e/standalone && cp -pr ./.next.e2e/static ./.next.e2e/standalone/.next.e2e",
"start": "next start",
"start:e2e": "echo \"-- Starting e2e server --\" && dotenv -e .env.e2e -- node .next.e2e/standalone/server.js",
"e2e": "npm run build:e2e && playwright test",
"e2e:init": "playwright install --with-deps",
"e2e:watch": "npm-watch e2e"
},
"watch": {
"e2e": {
"patterns": "tests/e2e/**", "playwright.config.ts",
"extensions": "ts,tsx",
"delay": 300
}
},
/** 後略 */
}
最後に、playwright.configの設定(キーポイントのみ抜粋)。
chromium/firefox/webkitに対して一つづつnext.jsのサーバーを立て、ポートによって切り分けている形。
各Webサーバーは個別にトランザクションを持っているので、同じDBに対しての変更も、隔離した環境で実行が可能となっている。
code:playwright.config.ts
import { defineConfig, devices } from "@playwright/test"
import { config } from "dotenv"
config({ path: ".env.e2e", quiet: true })
export default defineConfig({
testDir: "./tests/e2e",
workers: 3,
projects: [
{
name: "desktop chromium",
use: {
...devices"Desktop Chrome",
baseURL: "http://localhost:3001",
},
workers: 1,
},
{
name: "desktop firefox",
use: {
...devices"Desktop Firefox",
baseURL: "http://localhost:3002",
},
workers: 1,
},
{
name: "desktop webkit",
use: {
...devices"Desktop Safari",
baseURL: "http://localhost:3003",
},
workers: 1,
},
],
webServer: [
{
name: "Server:chromium",
command: "PORT=3001 npm run start:e2e",
url: "http://localhost:3001",
reuseExistingServer: !process.env.CI,
},
{
name: "Server:firefox",
command: "PORT=3002 npm run start:e2e",
url: "http://localhost:3002",
reuseExistingServer: !process.env.CI,
},
{
name: "Server:webkit",
command: "PORT=3003 npm run start:e2e",
url: "http://localhost:3003",
reuseExistingServer: !process.env.CI,
},
],
})
# 最後に
Next.jsのビルド周りの柔軟性の低さが、今回のように複雑な構成にせざるを得なくなった理由の一つにある気がしており、このあたりがNext.jsのイマイチな部分だと強く感じた。。。
また、こんなに複雑なことをしなくても、もっと簡単にできるぜ、という方法もあると思うので、もし良い案があればぜひお話お伺いしたいところでございます。