
Summary
ローカル DB を起動して、認証モックの HTTP サーバを立ち上げ、一時的な環境ファイルを書く——テストや開発用ツールでこういう「複数の非同期リソースを順番に確保するスクリプト」を書くと、途中で失敗したときに既に確保したリソースを片付ける処理を入れ忘れがちです。本記事では、確保途中の失敗時にも安全に巻き戻し、終了時にも逆順で解放する実装パターンを紹介します。
こんな人におすすめ
- E2E のセットアップスクリプトで「途中で落ちるとプロセスやポートが残る」現象に困っている方
- 複数の非同期リソース(DB、HTTP サーバ、一時ファイル)を順番に立ち上げる関数を書く方
try/finallyで雑に書いていたが、もう少し堅い構造にしたい Node.js / TypeScript 開発者
全体像
prepareEnvironment()
├─ resource1 確保
├─ resource2 確保(失敗時:resource1 を解放)
├─ resource3 確保(失敗時:resource2, resource1 を解放)
└─ return { cleanup } ← cleanup は確保順の逆で解放
呼び出し側:
const env = await prepareEnvironment()
try {
// 本番処理
} finally {
await env.cleanup()
}
確保順と逆順で解放するのが基本原則です。後から確保したリソースが、前に確保したリソースに依存している可能性があるためです。
エントリの責任ある cleanup パターン
非同期リソースを順次確保する関数は、確保途中で失敗したら確保済みのリソースを必ず解放するように書きます。
export async function prepareEnvironment(repoRoot) {
const dbUrl = await ensureLocalDatabaseUrl(repoRoot)
await resetLocalDatabase(repoRoot)
const authMock = await startAuthMock(dbUrl)
const runtimeEnv = {
DB_URL: dbUrl,
AUTH_BASE_URL: authMock.baseUrl,
// ...
}
let envFiles
try {
envFiles = await stageEnvironmentFiles(repoRoot, runtimeEnv)
} catch (error) {
await authMock.cleanup() // ★ envFiles の確保失敗時は authMock を片付ける
throw error
}
return {
runtimeEnv,
cleanup: async () => {
await envFiles.cleanup() // ★ 確保順の逆で解放
await authMock.cleanup()
},
}
}
ここで重要なのは2点です。
(a) 各 cleanup は再入可能にする:1つのリソース専用にして、複数回呼ばれても安全(idempotent)に書きます。server.close() 系は close 済みの場合にエラーにならないように分岐を入れることもあります。
(b) 呼び出し側は try/finally で必ず後片付けする:以下のように書きます。
const env = await prepareEnvironment(repoRoot)
try {
// 本番処理(テスト実行など)
} finally {
await env.cleanup()
}
状態解決のフォールバック
リソースが「既に整っているなら使う、なければ用意する」というケースは多いです。リトライループを書くより、2段階のフォールバックで素直に書く方が読めます。
async function ensureLocalDatabaseUrl(repoRoot) {
// 環境変数で明示されていれば最優先
const explicit = process.env.DB_URL?.trim()
if (explicit) return explicit
// 1回目: 既に起動していれば取れる
const first = await readDatabaseStatus(repoRoot)
if (first.DB_URL) return first.DB_URL
// 起動して2回目: 起動成功後に再取得
await startDatabase(repoRoot)
const second = await readDatabaseStatus(repoRoot)
if (second.DB_URL) return second.DB_URL
throw new Error('Unable to resolve DB_URL.')
}
リトライ無限ループにせず、「明示 → 既存 → 起動して再取得」の3段で諦めるのが運用上扱いやすいです。
ポート割り当ては OS に任せる
HTTP サーバを立ち上げる場合、固定ポートにすると衝突します。listen(0) で OS に空きポートを割り当てさせるのが基本です。
async function startAuthMock(dbUrl) {
const pool = new Pool({ connectionString: dbUrl, max: 1 })
const server = createServer((req, res) => {
// 必要なエンドポイントだけ実装
})
await new Promise((resolve) => server.listen(0, resolve))
const baseUrl = `http://127.0.0.1:${server.address().port}`
return {
baseUrl,
cleanup: async () => {
await new Promise((resolve) => server.close(resolve))
await pool.end()
},
}
}
server.address().port で OS が割り当てたポート番号を取得し、URL を組み立てて返します。
アンチパターン
try/finally で全部一緒に書く
// ❌ こうやって書くと cleanup の責務が呼び出し側に染み出す
let resource1, resource2
try {
resource1 = await acquire1()
resource2 = await acquire2()
// 業務ロジック
} finally {
if (resource2) await resource2.close()
if (resource1) await resource1.close()
}
業務ロジックの行数が増えるたびに finally 節が肥大化し、複数箇所に同じ acquire/cleanup ロジックが散らばります。エントリ関数で一括管理する方が再利用しやすいです。
cleanup を呼ばない
return { ... } した時点で「呼び出し側が cleanup を必ず呼ぶ」前提を書面で残さないと、忘れられます。型で using パターンや TypeScript 5.2+ の Symbol.asyncDispose を使う選択肢もあります。
グローバル変数で共有
複数のテストやスクリプトでリソースを共有するために global.dbConnection のような書き方をすると、並行実行や再実行で破綻します。エントリ関数で都度確保してクリーンに使う方が安全です。
まとめ
複数の非同期リソースを安全に扱うには、次のパターンを意識します。
- 確保順序と逆順で解放する
cleanupを返す関数を作る - 確保途中で失敗したら、それまでに確保したリソースを
try/catchで巻き戻す - 呼び出し側は
try/finallyで必ずcleanup()を呼ぶ - 状態解決は「明示→既存→起動して再取得」の段階フォールバックで
- ポートは
listen(0)で OS に割り当てさせる
このテンプレートに乗せておくと、E2E セットアップだけでなく、外部サービス連携や統合テストのリソース管理にも使い回せます。

