🧪

テスト用のDBをセットアップしてみた

2024/03/23に公開

経緯

少し歴史問題というか、負債を返すために必要なものであった。

バックエンド側のテストは今までかけている部分が多く、特に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.tsvitest-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, expect, it } from 'vitest';

describe.concurrent(
  'テストスイートa',
  () => {
    it('テストケース1', () => {
      expect(...)
    });
    it('テストケース2', () => {...})
  }
)

テスト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', () => {
      const dbContext = await createTestDbContext()
      const db = await dbContext.getConnection()
      const testRes = await testFunc(db, ...)
      expect(...)
    });
    it('テストケース2', () => {...})
  }
)

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テックブログ

Discussion