-
Notifications
You must be signed in to change notification settings - Fork 0
test : DB結合テスト基盤の導入とrunInTransactionテストの移行 #129
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feature/uow-repository-transaction
Are you sure you want to change the base?
Changes from all commits
8ec61a6
298c65d
7bd75f1
75545e6
1ca926f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| services: | ||
| test-db: | ||
| image: "postgres:${POSTGRES_VERSION:-15}" #15のバージョン | ||
| ports: | ||
| - "${POSTGRES_PORT}:5432" | ||
| environment: | ||
| POSTGRES_USER: ${POSTGRES_USER} | ||
| POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} | ||
| POSTGRES_DB: ${POSTGRES_DB} | ||
| healthcheck: | ||
| test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] | ||
| interval: 2s | ||
| timeout: 5s | ||
| retries: 10 | ||
| tmpfs: | ||
| - /var/lib/postgresql/data |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { readFileSync } from "node:fs"; | ||
| import { fileURLToPath } from "node:url"; | ||
| import { parseEnv } from "node:util"; | ||
|
|
||
| const TEST_ENV_PATH = fileURLToPath(new URL("../../.env.test", import.meta.url)); | ||
|
|
||
| function loadTestEnv(): Record<string, string | undefined> { | ||
| return parseEnv(readFileSync(TEST_ENV_PATH, "utf8")); | ||
| } | ||
|
|
||
| function requireEnv(env: Record<string, string | undefined>, name: string): string { | ||
| const value = env[name]; | ||
| if (!value) { | ||
| throw new Error(`${name} is not set in .env.test`); | ||
| } | ||
| return value; | ||
| } | ||
|
KinjiKawaguchi marked this conversation as resolved.
|
||
|
|
||
| const loadedEnv = loadTestEnv(); | ||
|
|
||
| for (const [key, value] of Object.entries(loadedEnv)) { | ||
| process.env[key] = value; | ||
| } | ||
|
|
||
| export const testConfig = { | ||
| db: { | ||
| host: requireEnv(loadedEnv, "POSTGRES_HOST"), | ||
| port: Number(requireEnv(loadedEnv, "POSTGRES_PORT")), | ||
| name: requireEnv(loadedEnv, "POSTGRES_DB"), | ||
| user: requireEnv(loadedEnv, "POSTGRES_USER"), | ||
| password: requireEnv(loadedEnv, "POSTGRES_PASSWORD"), | ||
| }, | ||
| } as const; | ||
|
|
||
| const databaseUrl = new URL( | ||
| `postgresql://${testConfig.db.host}:${testConfig.db.port}/${testConfig.db.name}`, | ||
| ); | ||
| databaseUrl.username = testConfig.db.user; | ||
| databaseUrl.password = testConfig.db.password; | ||
|
|
||
| export const TEST_DATABASE_URL = databaseUrl.toString(); | ||
|
|
||
| process.env.DATABASE_URL = TEST_DATABASE_URL; | ||
|
|
||
| export const testProcessEnv: NodeJS.ProcessEnv = { | ||
| ...process.env, | ||
| ...loadedEnv, | ||
| DATABASE_URL: TEST_DATABASE_URL, | ||
| }; | ||
|
KinjiKawaguchi marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import { execSync } from "node:child_process"; | ||
| import { getTableName, isTable } from "drizzle-orm"; | ||
| import { drizzle } from "drizzle-orm/node-postgres"; | ||
| import { escapeIdentifier, Pool } from "pg"; | ||
| import type { DrizzleClient } from "#infrastructure/drizzle/client"; | ||
| import * as schema from "#infrastructure/drizzle/schema"; | ||
| import { TEST_DATABASE_URL, testConfig, testProcessEnv } from "./config"; | ||
|
|
||
| /** スキーマ定義から _prisma_migrations を除いた全テーブル名を動的に取得 */ | ||
| const EXCLUDED_TABLES = new Set(["_prisma_migrations"]); | ||
| // TODO: #143 が解決されたら _prisma_migrations の暫定除外を削除する。 | ||
|
|
||
| function getApplicationTableNames(): string[] { | ||
| const tableNames: string[] = []; | ||
|
|
||
| for (const value of Object.values(schema) as unknown[]) { | ||
| if (!isTable(value)) continue; | ||
| tableNames.push(getTableName(value)); | ||
| } | ||
|
|
||
| return tableNames.filter((name) => !EXCLUDED_TABLES.has(name)); | ||
| } | ||
|
KinjiKawaguchi marked this conversation as resolved.
|
||
|
|
||
| let pool: Pool | null = null; | ||
| let client: DrizzleClient | null = null; | ||
|
|
||
| /** テスト用プールを取得(なければ作成) */ | ||
| function getPool(): Pool { | ||
| if (!pool) { | ||
| pool = new Pool({ connectionString: TEST_DATABASE_URL }); | ||
| } | ||
| return pool; | ||
| } | ||
|
|
||
| /** テスト用Drizzleクライアントを取得(アサーション用) */ | ||
| export function getTestClient(): DrizzleClient { | ||
| if (!client) { | ||
| client = drizzle(getPool(), { schema }); | ||
| } | ||
| return client; | ||
| } | ||
|
|
||
| /** マイグレーションを実行してスキーマを最新にする */ | ||
| export async function setupTestDatabase(): Promise<void> { | ||
| const p = getPool(); | ||
|
|
||
| // 接続先がテスト用DBであることを確認(本番DBへの誤接続を防止) | ||
| const result = await p.query("SELECT current_database()"); | ||
| const dbName = result.rows[0].current_database; | ||
| if (dbName !== testConfig.db.name) { | ||
| throw new Error( | ||
| `テスト用DBではないDBに接続しています: "${dbName}"。接続先が ${testConfig.db.name} であることを確認してください。`, | ||
| ); | ||
| } | ||
|
Comment on lines
+50
to
54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚩 Safety guard now compares against configurable DB name instead of hardcoded value The old code at Was this helpful? React with 👍 or 👎 to provide feedback.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. これは、確かに...どうしようか
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Repo上に本番DBの情報直書きして防ぐみたいなのを思いましたがさすがにダメですよね... DB接続先情報の一部のみハードに実装しますか?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. localhostじゃなかったら警告するとかがいいかもしれない? |
||
|
|
||
| // drizzle-kit pushでスキーマを同期(マイグレーションファイル不要で現在のスキーマを反映) | ||
| execSync("vp run db:push -- --force", { | ||
| env: testProcessEnv, | ||
| stdio: "pipe", | ||
| }); | ||
| } | ||
|
|
||
| /** 全アプリケーションテーブルをTRUNCATEする */ | ||
| export async function cleanDatabase(): Promise<void> { | ||
| const tableNames = getApplicationTableNames(); | ||
| if (tableNames.length === 0) return; | ||
| const p = getPool(); | ||
| const quotedTableNames = tableNames.map(escapeIdentifier); | ||
| await p.query(`TRUNCATE ${quotedTableNames.join(", ")} CASCADE`); | ||
| } | ||
|
|
||
| /** テスト用プールを閉じる */ | ||
| export async function teardownTestDatabase(): Promise<void> { | ||
| if (pool) { | ||
| await pool.end(); | ||
| pool = null; | ||
| client = null; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import { setupTestDatabase, teardownTestDatabase } from "./db"; | ||
|
|
||
| export async function setup() { | ||
| await setupTestDatabase(); | ||
| } | ||
|
|
||
| export async function teardown() { | ||
| await teardownTestDatabase(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { beforeEach } from "vite-plus/test"; | ||
| import "./config"; | ||
|
KinjiKawaguchi marked this conversation as resolved.
|
||
| import { cleanDatabase } from "./db"; | ||
|
|
||
| beforeEach(async () => { | ||
| await cleanDatabase(); | ||
| }); | ||
|
Comment on lines
+1
to
+7
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Module import ordering ensures correct DATABASE_URL before pool creation There's a subtle dependency: the production Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { describe, expect, it } from "vite-plus/test"; | ||
| import { eq } from "drizzle-orm"; | ||
| import { getTestClient } from "../../helpers/db"; | ||
| import { getClient, runInTransaction } from "#infrastructure/drizzle/client"; | ||
| import { events } from "#infrastructure/drizzle/schema"; | ||
|
|
||
| function testEvent(id: string, name: string) { | ||
| const now = new Date().toISOString(); | ||
| return { id, name, date: now, updatedAt: now }; | ||
| } | ||
|
|
||
| describe("runInTransaction", () => { | ||
| it("複数の書き込みがすべて永続化される", async () => { | ||
| await runInTransaction(async () => { | ||
| const client = getClient(); | ||
| await client.insert(events).values(testEvent("test-1", "Event 1")); | ||
| await client.insert(events).values(testEvent("test-2", "Event 2")); | ||
| }); | ||
|
|
||
| const db = getTestClient(); | ||
| const rows = await db.select().from(events).where(eq(events.id, "test-1")); | ||
| const rows2 = await db.select().from(events).where(eq(events.id, "test-2")); | ||
| expect(rows).toHaveLength(1); | ||
| expect(rows2).toHaveLength(1); | ||
| }); | ||
|
|
||
| it("途中で例外が発生したら全て巻き戻る", async () => { | ||
| await expect( | ||
| runInTransaction(async () => { | ||
| const client = getClient(); | ||
| await client.insert(events).values(testEvent("test-rollback", "Should not persist")); | ||
| throw new Error("intentional error"); | ||
| }), | ||
| ).rejects.toThrow("intentional error"); | ||
|
|
||
| const db = getTestClient(); | ||
| const rows = await db.select().from(events).where(eq(events.id, "test-rollback")); | ||
| expect(rows).toHaveLength(0); | ||
| }); | ||
|
|
||
| it("ネスト時に内側のrunInTransactionが新しいトランザクションを開始せず、外側の失敗で全体が巻き戻る", async () => { | ||
| await expect( | ||
| runInTransaction(async () => { | ||
| await runInTransaction(async () => { | ||
| const client = getClient(); | ||
| await client.insert(events).values(testEvent("test-nested", "Nested write")); | ||
| }); | ||
|
|
||
| throw new Error("outer failure"); | ||
| }), | ||
| ).rejects.toThrow("outer failure"); | ||
|
|
||
| const db = getTestClient(); | ||
| const rows = await db.select().from(events).where(eq(events.id, "test-nested")); | ||
| expect(rows).toHaveLength(0); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import { fileURLToPath } from "node:url"; | ||
| import { defineConfig } from "vite-plus"; | ||
|
|
||
| export default defineConfig({ | ||
| resolve: { | ||
| alias: { | ||
| "#domain": fileURLToPath(new URL("./src/domain", import.meta.url)), | ||
| "#application": fileURLToPath(new URL("./src/application", import.meta.url)), | ||
| "#infrastructure": fileURLToPath(new URL("./src/infrastructure", import.meta.url)), | ||
| }, | ||
| }, | ||
| test: { | ||
| include: ["tests/**/*.integration.test.ts"], | ||
| globalSetup: ["tests/helpers/globalSetup.ts"], | ||
| setupFiles: ["tests/helpers/setupIntegration.ts"], | ||
| testTimeout: 30_000, | ||
| hookTimeout: 30_000, | ||
| fileParallelism: false, | ||
| }, | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.