Playwrightで実際のDBを用いてテストを並列実行し高速化する

Playwrightで実際のDBを用いてテストを並列実行し高速化する

2025 / 07 / 01

目次

最近は、LLMによるコード生成が日常的になっています。 それに伴って、テストはコードが正しく実行されているかを保証するために今後更に重要になっていきます。 そこでLLMにとっても人間にとっても、実行速度が重要な要素となりますが、特にe2eは実行速度が遅い点が課題です。

さらに実際のDBを用いたテストを行う際、並列に実行した場合にはテスト全体を冪等にすることは難しく、直列実行が一般的です。(GitHub Actionsのmatrixやコンテナで隔離すれば可能)

今回は、実際のDBを用いつつ、並列に実行し、e2e全体の実行時間を大幅に短縮しつつ堅牢にする方法を考えます。

今回使用する技術スタック

  • Next.js
  • NextAuth.js
  • Prisma
  • PostgreSQL
  • Playwright
  • Testcontainers
Note

今回は、next-authの戦略はJWTを利用します

Playwrightの問題点

webserver は、単一のサーバー起動のみしかサポートしていないため、これに依存することはできません。 Playwrightは複数のAPPを起動することを想定してないため、もちろんポートの管理などもありません。APIからのアクセスはできるものの特に利用できるものもないためここは自分たちで対応する必要があります。

全体の流れ

  1. APP側で認証を通るようにする
  2. テストするユーザーの認証を全体のテスト前に行い、storageStateとして保存し、各テストは認証をスキップする
  3. 各ワーカーはテスト実行前に、動的にPostgreSQLコンテナとAPPを起動する
  4. 各テストは、afterEachでDBのデータをリセットする
  5. 各ワーカーはテストが終了次第、DBの破棄とAPPの終了を行う

APP側で認証を通るようにする

Googleなどの認証をe2eで突破するのは大変なので、あまりやりたくないですが、プロダクトコード内で偽装します。 NEXTAUTH_TEST_MODE === "true"のときに、JWTのエンコード/デコード処理を上書きします。

E2EテストでNextAuth認証(OAuthなど)を突破する方法

zenn.dev
E2EテストでNextAuth認証(OAuthなど)を突破する方法

import type { NextAuthConfig } from "next-auth";
export const configForTest = {
jwt: {
encode: async ({ token }) => {
return btoa(JSON.stringify(token));
},
decode: async ({ token }) => {
if (!token) {
return {};
}
return JSON.parse(atob(token));
},
},
} satisfies Omit<NextAuthConfig, "providers">;
export const config = {
providers: [],
callbacks: {
session: ({ session }) => {
return session;
},
},
...(process.env.NEXTAUTH_TEST_MODE === "true" ? configForTest : {}),
} satisfies NextAuthConfig;

ユーザーの認証状態を事前に作成する

毎回のテストでログイン処理を実行することは、実行時間の観点から非効率的です。 Playwrightの公式ドキュメントにもあるように、テストで使用するすべてのユーザーの認証状態を事前にstorageStateとして保存しておくことで、各テストケースではすでにログイン済みの状態から開始できるようになります。 これを実行すると、e2e/.authにそれぞれのアカウントの認証状態がjsonで保存されます。

Authentication | Playwright

Introduction

playwright.dev
Authentication | Playwright
import type { User } from "next-auth";
export const user1: User = {
id: "id1",
name: "user1",
image:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAMElEQVR42u3OMQEAAAQAMDrpp4Zuyojh2RIsa7bjUQoICAgICAgICAgICAgICHwHDhv0ROEuXMGUAAAAAElFTkSuQmCC",
role: "USER",
};
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true, // 並列実行させる
projects: [
{
name: "setup",
testMatch: /.\/e2e\/setup\/.*.ts/,
},
{
name: "chrome",
use: {
...devices["Desktop Chrome"],
},
dependencies: ["setup"], // setupの後に実行
},
],
});
import type { BrowserContext, TestType } from "@playwright/test";
import type { User } from "next-auth";
import type { JWT } from "next-auth/jwt";
export async function createUserAuthState(context: BrowserContext, jwt: JWT) {
// Next.jsのNextAuthで使用されるCookieを手動で設定
// 実際のログインプロセスを省略し、認証済み状態を直接作成
await context.addCookies([
{
name: "next-auth.session-token",
value: btoa(
JSON.stringify({
...jwt,
sub: jwt.user.id,
}),
),
domain: "localhost",
path: "/",
httpOnly: true,
sameSite: "Lax",
expires: Math.round((Date.now() + 60 * 60 * 24 * 1000 * 7) / 1000),
},
]);
// ブラウザの認証状態をファイルに保存し、各テストでこのファイルを読み込むことにより認証済み状態を復元
await context.storageState({
path: getStorageStatePath(jwt.user.id ?? ""),
});
}
import { test as setup } from "@playwright/test";
import { user1 } from "../dummyUsers";
import { createUserAuthState } from "../helpers/users";
setup("Create user1 auth", async ({ context }) => {
await createUserAuthState(context, {
user: user1,
});
});

ワーカーごとの独立した環境を構築する

複数のDBとAPPを並列に起動するための仕組みを構築します。これにより、各ワーカーが独立した環境でテストを実行できるようになります。

動的にDBを起動させる準備をする

Testcontainersを用いて、PostgreSQLのコンテナを動的に起動し、各ワーカーが独自のデータベースを使用できるようにします。これにより、テスト間でのデータ競合を防ぎます。

Testcontainers

Testcontainers is an opensource library for providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.

testcontainers.com
Testcontainers

今回は、dockerのcomposeを利用していますが、そうでない場合はGenericContainerを利用してください。portを0にしておくことにより、空いているポートに勝手にアサインされます。

ここでは競合ポートを避ける処理と起動までの準備を行います。

services:
db:
image: postgres:17
ports:
- "${DATABASE_PORT:-5432}:5432" # 動的ポート対応のため、上書きできるようにしておく
environment:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { DockerComposeEnvironment, Wait } from "testcontainers";
import { Prisma, PrismaClient } from "../src/app/__generated__/prisma";
import { createDBUrl } from "../src/app/_utils/db";
const execAsync = promisify(exec);
export async function setupDB({ port }: { port: "random" | number }) {
const container = await new DockerComposeEnvironment(".", "compose.yml")
.withEnvironmentFile(".env.test") // テスト専用の環境変数ファイル
.withEnvironment({
// 0を指定することでOSが自動的に空きポートを割り当て
DATABASE_PORT: port === "random" ? "0" : `${port}`,
})
.withWaitStrategy("db", Wait.forListeningPorts())
.up(["db"]);
const dbContainer = container.getContainer("db-1");
const mappedPort = dbContainer.getMappedPort(5432);
const url = createDBUrl({
host: dbContainer.getHost(),
port: mappedPort,
});
// migrate
await execAsync(`DATABASE_URL=${url} npx prisma db push`);
const prisma = new PrismaClient({ datasources: { db: { url } } });
async function down() {
await prisma.$disconnect();
await container.down();
}
return {
url,
container,
port: mappedPort,
prisma,
down,
async [Symbol.asyncDispose]() {
await down();
},
} as const;
}

ちなみにdb.setup.tsはvitestで並列にDBを立ち上げることもできるので、共通化しておくと便利です。

動的にAPPを起動する準備

データベースの隔離と同様に、各ワーカーは独自のポートでNext.jsを起動する必要があります。baseURLや、dbPortappPortを実行時に環境変数で上書きし、各種ポートや向き先を変更します。 また、ポートの確率やヘルスチェックを行うためのユーティリティ関数も用意します。 get-port を利用してもいいです。

ここでも競合ポートを避ける処理と起動までの準備を行います。

import { exec } from "node:child_process";
import { getRandomPort } from "./getRandomPort";
import { waitForHealth } from "./waitForHealth";
export async function setupApp(dbPort: number) {
const appPort = await getRandomPort();
const baseURL = `http://localhost:${appPort}`;
const cp = exec(
`NEXTAUTH_URL=${baseURL} DATABASE_PORT=${dbPort} pnpm start --port ${appPort}`,
);
await waitForHealth(baseURL);
return {
appPort,
baseURL,
async [Symbol.asyncDispose]() {
if (cp.pid) {
process.kill(cp.pid);
}
},
} as const;
}
import { createServer } from "node:http";
export async function getRandomPort() {
return new Promise<number>((resolve) => {
const server = createServer();
server.listen(0, () => {
const address = server.address();
const port = address && typeof address === "object" ? address.port : null;
if (port) {
server.close();
resolve(port);
}
});
});
}
import { setTimeout } from "node:timers/promises";
export async function waitForHealth(baseUrl: string) {
const maxAttempts = 30;
const interval = 100;
const healthUrl = `${baseUrl}/api/health`;
let attempts = 0;
while (attempts < maxAttempts) {
try {
const response = await fetch(healthUrl);
if (response.ok) {
const data = await response.json();
if (data.status === "ok") {
return;
}
}
} catch {}
attempts++;
await setTimeout(interval);
}
throw new Error(`Server health check failed after ${maxAttempts} attempts`);
}

fixturesでDBとAPPを起動する

次に、Playwrightのfixturesを利用して、各ワーカーごとに独立したデータベースとアプリケーションを起動する仕組みを実装します。 workerスコープを定義し、workerプロセスごとに起動のsetupを自動で行います。 useの前がテスト実行前となるので、実行前にDBやAPPを起動し、もし後処理が必要であれば、useの後に書くことが出来ます。

Fixtures | Playwright

Introduction

playwright.dev
Fixtures | Playwright
import { test as base } from "@playwright/test";
import type { User } from "next-auth";
import { setupDB } from "../tests/db.setup";
import { setupApp } from "./helpers/app";
import { generatePrismaClient } from "./helpers/prisma";
export type TestFixtures = {};
export type WorkerFixtures = {
setup: Awaited<{
prisma: Awaited<ReturnType<typeof setupDB>>["prisma"];
appPort: number;
baseURL: string;
dbURL: string;
}>;
};
export const test = base.extend<TestFixtures, WorkerFixtures>({
setup: [
async ({ browser }, use) => {
await using dbSetup = await setupDB({ port: "random" });
await using appSetup = await setupApp(dbSetup.port);
const baseURL = appSetup.baseURL;
const originalNewContext = browser.newContext.bind(browser);
// 新しいbaseURLを含んだコンテキストを新たに作成
browser.newContext = async () => {
return originalNewContext({ baseURL });
};
await use({
prisma: dbSetup.prisma,
appPort: appSetup.appPort,
baseURL,
dbURL: dbSetup.url,
});
},
{
scope: "worker",
auto: true,
},
],
});
import { PrismaClient } from "../../src/app/__generated__/prisma";
export async function generatePrismaClient(url: string) {
const prisma = new PrismaClient({
datasources: {
db: {
url,
},
},
});
return {
prisma,
async [Symbol.asyncDispose]() {
await prisma.$disconnect();
},
} as const;
}

テストユーザーを利用できるようにする

最初に作成したログイン済みユーザーをテストで利用できるように変更します。 fixturesにuserをDBへ登録するメソッドとcookieとDBを飛ばすためのメソッドを追加します。(厳密にはUserだけを飛ばしたほうがいいが、afterEachで飛ばしているので一緒)

setupfixturesで定義しているので、registerToDBresetからアクセスし、DBやAPPのポートなどの状態を取得できます。

import { test as base } from "@playwright/test";
import type { User } from "next-auth";
import { setupDB } from "../tests/db.setup";
import { setupApp } from "./helpers/app";
import { generatePrismaClient } from "./helpers/prisma";
import { registerUserToDB } from "./helpers/users";
export type TestFixtures = {
storageState: string;
registerToDB: (user: User) => Promise<void>;
reset: () => Promise<void>;
};
export type WorkerFixtures = {
6 collapsed lines
setup: Awaited<{
prisma: Awaited<ReturnType<typeof setupDB>>["prisma"];
appPort: number;
baseURL: string;
dbURL: string;
}>;
};
export const test = base.extend<TestFixtures, WorkerFixtures>({
setup: [
24 collapsed lines
async ({ browser }, use) => {
await using dbSetup = await setupDB({ port: "random" });
await using appSetup = await setupApp(dbSetup.port);
const baseURL = appSetup.baseURL;
const originalNewContext = browser.newContext.bind(browser);
// 新しいbaseURLを含んだコンテキストを新たに作成
browser.newContext = async () => {
return originalNewContext({
baseURL,
});
};
await use({
prisma: dbSetup.prisma,
appPort: appSetup.appPort,
baseURL,
dbURL: dbSetup.url,
});
},
{
scope: "worker",
auto: true,
},
],
registerToDB: async ({ reset, setup }, use) => {
await use(async (user: User) => {
await registerUserToDB(user, setup.dbURL);
});
await reset();
},
reset: ({ context, setup }, use) => {
use(async () => {
await using db = await generatePrismaClient(setup.dbURL);
await Promise.all([truncate(db.prisma), context.clearCookies()]);
});
},
});
import type { BrowserContext, TestType } from "@playwright/test";
import type { User } from "next-auth";
import type { JWT } from "next-auth/jwt";
import type { TestFixtures, WorkerFixtures } from "../fixtures";
import { generatePrismaClient } from "./prisma";
export async function registerUserToDB(user: User, dbUrl: string) {
await using db = await generatePrismaClient(dbUrl);
await db.prisma.user.create({
data: {
...user,
accounts: {
create: {
type: "oauth",
provider: "google",
providerAccountId: `${Math.random()}`,
id_token: "id_token",
access_token: "access_token",
token_type: "Bearer",
scope: "scope",
},
},
},
});
}
export async function createUserAuthState(context: BrowserContext, jwt: JWT) {
20 collapsed lines
await context.addCookies([
{
name: "authjs.session-token",
value: btoa(
JSON.stringify({
...jwt,
// google provider attaches `sub` to the token
sub: jwt.user.id,
}),
),
domain: "localhost",
path: "/",
httpOnly: true,
sameSite: "Lax",
expires: Math.round((Date.now() + 60 * 60 * 24 * 1000 * 7) / 1000),
},
]);
await context.storageState({
path: getStorageStatePath(jwt.user.id ?? ""),
});
}
export async function useUser<T extends TestType<TestFixtures, WorkerFixtures>>(
test: T,
user: User,
) {
test.use({ storageState: getStorageStatePath(user.id) });
// 毎テストごとにDBにもuserの情報を登録しておく
test.beforeEach(async ({ registerToDB: registerToDB }) => {
await registerToDB(user);
});
}
function getStorageStatePath(id: string) {
return `e2e/.auth/${id}.json`;
}

これでテストを実行する準備ができました。

ページのテストを書く

今回は、 Page Object Models を採用し、fixtures経由でページそれぞれのオブジェクトを取得します。

import { user1 } from "../dummyUsers";
import { test } from "../fixtures";
import { useUser } from "../helpers/users";
test.describe("no sign in", () => {
test("should redirect to signIn page", async ({ topPage, signInPage }) => {
await topPage.goTo();
await signInPage.expectUI();
});
});
test.describe("sign in", () => {
useUser(test, user1);
test("should show my name", async ({ topPage }) => {
await topPage.goTo();
await topPage.expectUI("signIn", user1);
});
});
import { expect, type Locator, type Page } from "@playwright/test";
import type { User } from "next-auth";
import { Base } from "./Base";
export class TopPage extends Base {
textUserStatusLabelLocator: Locator;
constructor(page: Page) {
super(page);
this.textUserStatusLabelLocator = this.page.locator(
'[aria-label="User status"]',
);
}
async goTo() {
return await this.page.goto("/");
}
async expectUI(state: "signIn" | "signOut", user?: User) {
if (state === "signIn") {
await expect(this.textUserStatusLabelLocator).toContainText(
`you are signed in as ${user?.name} 😄`,
);
}
if (state === "signOut") {
await expect(this.textUserStatusLabelLocator).toContainText(
"you are not signed in 🥲",
);
}
}
}
import { test as base } from "@playwright/test";
import type { User } from "next-auth";
import { setupDB, truncate } from "../tests/db.setup";
import { setupApp } from "./helpers/app";
import { generatePrismaClient } from "./helpers/prisma";
import { registerUserToDB } from "./helpers/users";
import { TopPage } from "./models/TopPage";
export type TestFixtures = {
topPage: TopPage;
4 collapsed lines
storageState: string;
registerToDB: (user: User) => Promise<void>;
reset: () => Promise<void>;
a11y: () => AxeBuilder;
};
export type WorkerFixtures = {
6 collapsed lines
setup: Awaited<{
prisma: Awaited<ReturnType<typeof setupDB>>["prisma"];
appPort: number;
baseURL: string;
dbURL: string;
}>;
};
export const test = base.extend<TestFixtures, WorkerFixtures>({
topPage: ({ page }, use) => {
use(new TopPage(page));
},
setup: [
24 collapsed lines
async ({ browser }, use) => {
await using dbSetup = await setupDB({ port: "random" });
await using appSetup = await setupApp(dbSetup.port);
const baseURL = appSetup.baseURL;
const originalNewContext = browser.newContext.bind(browser);
// 新しいbaseURLを含んだコンテキストを新たに作成
browser.newContext = async () => {
return originalNewContext({
baseURL,
});
};
await use({
prisma: dbSetup.prisma,
appPort: appSetup.appPort,
baseURL,
dbURL: dbSetup.url,
});
},
{
scope: "worker",
auto: true,
},
],
registerToDB: async ({ reset, setup }, use) => {
4 collapsed lines
await use(async (user: User) => {
await registerUserToDB(user, setup.dbURL);
});
await reset();
},
reset: ({ context, setup }, use) => {
4 collapsed lines
use(async () => {
await using db = await generatePrismaClient(setup.dbURL);
await Promise.all([truncate(db.prisma), context.clearCookies()]);
});
},
});

注意点が一つあり、useUser(認証状態の復元)はpage.context.storageStateの制約上、pageが作られる前に追加しないといけないのでtestの前に実行が必要となります。 なのでtest.describeの中で実行する必要があり、ユーザーのスコープはdescribeとなります。

import { user1 } from "../dummyUsers";
import { test } from "../fixtures";
import { useUser } from "../helpers/users";
test.describe("sign in", () => {
useUser(test, user1);
test("should show my name", async ({ topPage }) => {
await topPage.goTo();
await topPage.expectUI("signIn", user1);
});
});

Authentication | Playwright

Introduction

playwright.dev
Authentication | Playwright

これで実際のDBを用いて並列にテストを実行することができ、e2eの実行時間が大幅に短縮されました。とあるプロジェクトだと10分から3分ぐらいかわりました。

まとめ

下地を作るのが大変ですが、これを一度作ってしまえば、あとは各テストで認証が必要であればuseUserを使うだけで、実際のDBを用いた並列テストが可能になります。 特にLLMを活用した開発が増えている現在において、このような堅牢なテスト環境の構築と実行時間は今後も最低限必要なガードレールとなるのだと思います。


類似の内容でunit testでの実装は以下を参照

Testcontainersを利用し実際のデータベースを用いたテストを並列実行する - hiroppy's site

開発時の環境と同じ実際のデータベースを用いて、テストを並列に実行する方法を紹介

hiroppy.me
Testcontainersを利用し実際のデータベースを用いたテストを並列実行する - hiroppy's site

リポジトリは以下を参照

GitHub - hiroppy/web-app-template: A minimal web service template 🎃 "npx create-app-foundation@latest" !

A minimal web service template 🎃 "npx create-app-foundation@latest" ! - hiroppy/web-app-template

github.com
GitHub - hiroppy/web-app-template: A minimal web service template 🎃  "npx create-app-foundation@latest" !

前後の記事

関連する記事