テスト用のDBをセットアップしてみた
経緯
少し歴史問題というか、負債を返すために必要なものであった。
バックエンド側のテストは今までかけている部分が多く、特にDBへのデータアクセスに関するものはほぼテストが書かれていなかった。
それで、この部分のテストを拡充していく中で、実際に生成されたクエリがちゃんと動いているか、データの整合性、インテグレーションテストや今後のパフォーマンスやスケーラビリティテストといった面を考慮した上で、モックデータではなく、リアルなテストDBが必要との結論になっていた。
初回の試み
vitestのワークスペース
テストはvitestを運用している。vitestでは ワークスペース を定義することで簡単にテスト対象ファイルのスコープを定義することが可能。
例えば、
// <project>/vitest-workspace/projects/db/vitest.config.ts
import * as path from 'node:path';
import { mergeConfig } from 'vitest/config';
import baseConfig from '...'
// このrootはあくまでもこの定義ファイルのスコープとなっていて、
// 例えば今回バックエンドテストなので、バックエンドのファイルがあるところに指定
const root = path.resolve(__dirname, '../backend');
export default mergeConfig(
baseConfig,
test: {
root,
include: ['**/__tests__/**/*.db.test.{js,ts}'],
// これは後で説明
timeout: 50_000,
},
//...
});
テスト設定の定義は、vitest-workspace
とかのフォルダーを作って1箇所にまとめることがおすすめ。 ただ、要注意するのは、vitest.config
が位置するフォルダ名が、「プロジェクト名」として扱われて、その名前がユニークでなければなりらない。例えば、vitest-workspace/projects/a/db/vitest.config.ts
とvitest-workspace/projects/b/db/vitest.config.ts
があるとしたら、dbとの名前が重複になるので実行時はエラーになってしまう。
ワークスペースのjsonファイル(たとえb、vitest.db.workspace.json
)はその定義ファイルのパスを指定する。この時に複数を選択することも可能。
["<project>/vitest-workspace/projects/db/*", "..."]
package.json
では、--workspace
を指定するだけで良い。
"scripts": {
"test": "vitest",
"test:db": "vitest --workspace vitest.db.workspace.json",
}
テストケースとテストスイート
ここでテストケースとテストスイートの定義として、
- テストケース = 一つの
it
のスコープで定義された特定の機能をテストするためのテストコードの最小単位 - テストスイート = 一つの
describe
のスコープ内に含まれる全てのテストケースの集合、かつ原則1ファイルには1つしかdescribe
を定義しない
これは、慣習だけではなく、vitestの並行実行を最大限に利用したいとの意図もある。vitestにはデフォルトでマルチスレッド で、1つのファイルを1つのスレッドで実行させている。なお、1つのファイル内では、もし複数のdescribe
が定義されても、describe.concurrent
にしても、同時に1つしか実行されない。というのは、describe.concurrent
はあくまでもテストスイート内に定義されたテストケースを並行に実行させる意図である。そのため、describe
= 1ファイルの方が都合が良い。
なので、基本このような形にテストが書かれていく。
// a.test.ts
import { describe, it } from 'vitest';
describe.concurrent(
'テストスイートa',
() => {
it('テストケース1', ({expect}) => {
expect(...)
});
it('テストケース2', ({expect}) => {...})
}
)
テストDBを作る
これで実行のスコープが切り分けできたし、並行実行のための構造も決まったので、早速本題のテストDB作成に入りたい。
考えとしては、
-
createTestDbContext
とか一つの関数を作り、テストケースまたはテストスイートごとに都度呼び出すようにする - 呼び出されるたびに、そのテストケース専用のテストDBを立ち上げる
- DBを立ち上げる際に、マイグレーションと必要なシードを実行する
- テスト実行中には、上記のコンテキストからコネクションのセッションを取得し、テスト対象のコードに渡す
- 実行が終わったら、セッションのクローズと、テストDBの削除を行う
- テストスイート内やテストスイート間で、並行実行の邪魔にならないようにしなければならない
今回DBはpostgres、クライアントはKnexを使っていたので、以下はポスグレ+Knexでコード例にする。
import type { Knex } from 'knex';
import knex from 'knex';
import assert from 'node:assert';
import { spawn } from 'node:child_process';
import { afterAll } from 'vitest';
// getConnectionでテストに必要なDBクライエントのインスタンスを取得
export interface TestDbContext {
getConnection: () => Promise<Knex>;
};
export const createTestDbContext = async (): Promise<TestDbContext> => {
const config = {
host: process.env.TEST_PG_HOST ?? '127.0.0.1',
port: Number.parseInt(process.env.TEST_PG_PORT ?? '7089', 10),
user: process.env.TEST_PG_USER ?? 'postgres',
password: process.env.TEST_PG_PASSWORD ?? 'postgres',
};
const client = knex({
client: 'pg',
connection: config,
});
let isUp = false;
const dbPrefix = '<random_prefix>'
let database: string | null = null;
const createNew = async () => {
if (!isUp) {
database = `${dbPrefix}<random_suffix>`;
await client.raw(`CREATE DATABASE ${database}`);
}
assert(database != null, 'database cannot be nullish');
return database;
};
let conn: Knex | null = null;
const getConnection = async () => {
if (conn != null) {
return conn;
}
const database = await createNew();
conn = knex({
client: 'pg',
connection: {
...config,
database,
},
});
return conn;
};
const up = async () => {
if (isUp) return;
const database = await createNew();
const args = [
'--client',
'pg',
'--connection',
`postgresql://${config.user}:${config.password}@${config.host}:${config.port}/${database}`,
];
// knex.migrateとかメソッドがあったがエラーになるため敢えてchild_processに任せる
const migrateProcess = spawn('npm', [
'run',
'knex',
'--',
'migrate:latest',
...args,
]);
await new Promise((resolve, reject) => {
console.log('spawn migrate process');
migrateProcess.on('close', resolve);
migrateProcess.on('error', reject);
});
// seedも同じ感じで実行
// ...
isUp = true;
};
const down = async () => {
if (!isUp) return;
console.log('teardown db');
// 削除対象のテーブルを取得して削除
const { rows } = await client.raw(
`SELECT datname FROM pg_database WHERE datname LIKE :dbPrefix || '%'`,
{ dbPrefix }
);
const dbNames = rows.map((row) => row.datname);
await Promise.all(
dbNames.map(async (name) => {
if (name.startsWith(dbPrefix)) {
await client.raw(`DROP DATABASE ${name}`);
} else {
console.warn(`${name} skipped.`);
}
}),
);
// クライエントインスタンスを削除、dbContextスコープ内の変数を初期値に戻す
await client.destroy();
await conn.destroy();
isUp = false;
conn = null;
database = null;
};
// ここでvitestのフックを使うことで、都度dbContext.down()の呼び出しが不要になる
afterAll(async () => {
await down();
});
// createTestDbContext呼び出し時にセットアップしたいので、ここでupを実行
await up();
return {
getConnection,
};
};
テスト実装時には、
import { describe, expect, it } from 'vitest';
import { createTestDbContext } from '../database/test-context';
import type { TestParams } from '../database/test-params';
describe.concurrent(
'テストスイートa',
() => {
it('テストケース1', ({expect}) => {
const dbContext = await createTestDbContext()
const db = await dbContext.getConnection()
const testRes = await testFunc(db, ...)
expect(...)
});
it('テストケース2', ({expect}) => {...})
}
)
afterAll
のフックをうまく利用すれば、upとdownはコンテキスト内部で完結できるので、割とシンプルに使えるようになる。
問題点
何が問題なのか
これで一旦、シンプルなテストDBセットアップコードができた。しかし、一つ大きな懸念点がある。それは実行時間の長さだ。
上記のような実装で運用していくと、一つのテストスイートでは20秒近くないし以上(最も時間のかかるテストケースによる)の場合がある。そのせいで、vitestのコンフィングを定義する時にやむを得ずtimeoutを50秒までもしていた。これは並行実行している状態でもあって、今後テストファイルが増えていくと、一回のテストでとんでもない時間がかかるのではないかとの懸念点が生まれている。
それで、何が時間かかるかというと、すでにお察しの通りかもしれないが、マイグレーションとシードの実行なのだ。
この2つの実行がおおよそ9割の時間を締めているので、この辺をなんとかしないと、いくらシャーディング化しても、スレッドを増やしても根本解決にはならない。ここの改善の考えとして、
- マイグレーションは一回だけで良いのでは?
- シードの実行は本当に必要なのか?
が上がられた。
重複マイグレーションをなくす
そもそも、マイグレーションは都度実行しているのはおかしい。
ポスグレには、テンプレートDB との運用がある。これは、CREATE DATABASE
を実行時に、既存のDBからコピーすることが許されているのを意味する。つまり、一回テンプレートのDBを作成とマイグレーションしてから、テストコンテキストを作る際にそこからコピーすれば、マイグレーションは一回だけで良い。これでマイグレーションの時間がO(n)からO(1)になる。
CREATE DATABASE dbname TEMPLATE template0;
それで、いつこのテンプレートDBを作るかというと、全てのテストが実行される前というタイミングしかないとの結論になった。というのは、並行実行がある限り、どのファイルが先に実行されるかわからないし、実行時にテンプレートDBの存在チェックと作成を行ってもロックのマネジメントが面倒(そもそもやり方がわからないが)だし、そこしかないのではないかと。
vitestでは、globalSetup
(こちら )との項目があり、ここで全てのテストが始まる前と後の、setup
及びteardown
を定義することができる。今回のようなテンプレートDBだけではなく、例えばテストサーバーの立ち上げとか、終了後の後処理とか、色々と運用の可能性がある。ただ、要注意するのは、globalSetup
ではvitest
へのアクセスがなく、beforeAll
とかのフックももちろん利用できない。ここで定義したいのはあくまでもテスト運用上にベースとなっているものであって、テスト自体と直接関わっているものではない(vitestのAPIを使いたい場合は別途setupFiles
があるが、実行タイミングと運用目的は違う)。
シード実行を考え直す
もう一つの考えとして、シードはそもそも必要なのかとのところであった。元々シードファイルは、ローカル開発のために定義されているもので、自然にテスト環境にも使いたくなるが、結局同じものを利用すると、今度はテストコードがシードファイルへの依存性が生じることになる。ローカル開発のためのテストデータと、テスト実行のためのデータはやはり目的と関心が別々で、シードをいじるとテストが落ちるようになるのが流石に避けたい。
ただ、全くシード実行をやめるか、と言われても、あくまでも今のようなchild_processに介した時間のかかるものではなく、直接テスト用のデータをコンテキスト作成時にDBへインサートする形が理想かもしれなない。例えば、createTestDbContext
の関数に引数を渡して、その引数を受け取って内部でデータ挿入を行うことで、実質シード実行の役割を果たしてくれる。
改善の結果
改善後のコード
まずはグローバルセットアップのテンプレートDB作成を用意する。
// <path-to>/global-db-test-setup.ts
import knex from 'knex';
import { spawn } from 'node:child_process';
import * as path from 'node:path';
export const dbPrefix = 'test_';
export const templateDbName = `${dbPrefix}template`;
export const config = {
host: process.env.TEST_PG_HOST ?? '127.0.0.1',
port: Number.parseInt(process.env.TEST_PG_PORT ?? '7089', 10),
user: process.env.TEST_PG_USER ?? 'postgres',
password: process.env.TEST_PG_PASSWORD ?? 'this_is_local_db',
};
const client = knex({
client: 'pg',
connection: config,
// ...
});
export const setup = async () => {
const templateDbExists = await client.first(
client.raw(
'exists ? as exists',
client
.select('datname')
.from('pg_catalog.pg_database')
.where('datname', templateDbName)
.limit(1),
),
);
if (!templateDbExists.exists) {
await client.raw(`CREATE DATABASE ${templateDbName}`);
const args = [
'--client',
'pg',
'--connection',
`postgresql://${config.user}:${config.password}@${config.host}:${config.port}/${templateDbName}`,
];
const p = spawn('npm', ['run', ...]);
await new Promise<void>((resolve, reject) => {
// ...
});
}
};
export const teardown = async () => {
await client.raw(`DROP DATABASE ${templateDbName}`);
await client.destroy();
};
これをvitestの設定に追加する。
// <project>/vitest-workspace/projects/db/vitest.config.ts
// ...
export default mergeConfig(
baseConfig,
test: {
root,
include: ['**/__tests__/**/*.db.test.{js,ts}'],
globalSetup: [
path.resolve(root, '<path-to>/global-db-test-setup.ts'),
],
},
//...
});
他はcreateTestDbContex
に残し、シードをパラメーターとして追加する。
// ...
import { config, dbPrefix } from 'global-db-test-setup'
export const createTestDbContext = async (seed: Record<string, unknown>): Promise<TestDbContext> => {
const client = knex({
client: 'pg',
connection: config,
});
let isUp = false;
let database: string | null = null;
const createNew = async () => {
// ...
};
let conn: Knex | null = null;
const getConnection = async () => {
// ...
};
// シンプルな形でtableにdataを挿入する
// ただ実際にはよりテスト向けに型定義をしておく必要があるかもしれない
const insertSeedData = async () => {
if (conn == null) {
throw new Error('...')
}
for (const { table, data } of seed) {
await conn.table(table).insert(data);
}
}
const up = async () => {
if (isUp) return;
const database = await createNew();
// ここでマイグレーションとかの実行が不要になる
// テストごとのデータ挿入だけで十分
await insertSeedData()
isUp = true;
};
// ...
};
効果
では、どのくらい時間短縮できたのか。
同じテストをCI環境で実行させた結果でいうと、元々3つのテストケースが10秒かかったものが、0.4秒で終わるようになった。単純に一個のファイルだけみても、実行時間が95パー減になるが、ファイルが増えていくと恩恵が大きくなると想定できる。
✓ |app-db| src/utils/__tests__/db-context-dummy.db.ts (3 tests) 10329ms
✓ |app-db| src/utils/__tests__/db-context-dummy.db.ts (3 tests) 436ms
終わりに
今回はテストDBのセットアップについて、vitestを運用してやり方と考えをまとめた。
テスト用のシードデータをどう扱うか、テストケースごとのデータと、テストスイート共通のデータなど、レイヤーの切り分けも色々とまだ模索中なところがある。
いくつかの施策で期待通りの実行時間短縮ができたので、一旦現在の形で共有するようにした。
また今後何かアップデートと改善があったら追記していきたいと思う。
ではでは。
世界のラストワンマイルを最適化する、OPTIMINDのテックブログです。「どの車両が、どの訪問先を、どの順に、どういうルートで回ると最適か」というラストワンマイルの配車最適化サービス、Loogiaを展開しています。recruit.optimind.tech/
Discussion