超高速SQL単体テスト rawsql-ts/pg-testkit

に公開

Node.jsでSQLを高速に単体テストするライブラリ @rawsql-ts/pg-testkit を紹介します。
生SQLは当然として、PrismaやDrizzleなど既存ORMとも併用可能で、既存アーキテクチャを壊さず導入できます。

従来の問題点

SQLの単体テストを行う場合、一般的には以下の手順で行います。

  1. マイグレーション(テーブル環境構築)
  2. シーディング(テストデータ投入)
  3. テスト
  4. クリーンアップ(ロールバック、テーブル環境破棄)

通常の単体テストと異なり、マイグレーション、シーディング、クリーンアップという工程が必要です。
さらに、DBという共有ストレージを使用するため、テスト間の競合に注意が必要です。

上記の問題を回避して高速化するのは難しく、現状では

  • DBインスタンスをたくさん立てる
  • DBにスキーマをたくさん作る
  • テストデータが競合しないように気を付ける

といった力業に頼ることが主流です。

SQLite、インメモリデータベースのようなサーバーレス化で対応するという手法もありますが、実機とは異なるデータベース環境を使用するため、「実機では動くがテスト環境では記述不能」、「テストでは動いたが、実機で動かない」という別の問題が発生します。

解決案

マイグレーション、シーディング、クリーンアップはデータベースを使用しているから発生しています。かと言って、インメモリデータベースのような実機とは異なる環境を用意するのはリスクがあります。

そこで、@rawsql-ts/pg-testkit では「ZeroTableDependency(以下、ZTD)」という全く新しい手法で上記の問題を解決しようと試みています。

ZTDとは

ZTDとは、「テーブル依存をゼロにすること」です。
具体的には「本物のデータベースを使用するが、実テーブルの読み込みと書き込みをせず、全て影
(CTE)に置き換えて実行する」ことを指します。

これにより、以下が実現できます。

  • マイグレーション、シーディング、クリーンアップの工程を廃止
  • 書込みしないので副作用なし
  • データ競合問題を解消し、単体テストの並列実行に対応
  • 開発時のマイグレーション工程をなくし、テーブル定義の見直しを高速に試行
  • SQLロジックの正しさを単体テストで保証
  • 既存SQLリソースを動的に加工するので導入コストが低い
  • テストキットとして完結し、製品コードを汚さない

上記は以下の特徴を持っており、AI時代にふさわしいテストキットと言えます。

  • resettable(やり直しが何度でもできる)、
  • efficient(大量に試行できる)、
  • rewardable(各試行に対して自動的に評価や報酬が与えられる仕組みがある)

https://x.com/nukonuko/status/1990518309419466765
https://karpathy.bearblog.dev/verifiability/

では、ZTDを実現する方法について説明します。

CTE Shadowing

テーブルを読まずにクエリを実行する方法です。

CTEに物理テーブル名と同じ名称を指定するとCTE参照が優先されるので、これを利用して物理テーブルの参照を止めます。

--origin
select * from users where id = 1
;
--ZTD
with
    users as (
        select
            cast(1 as integer) as id
            , cast('Alice' as text) as name
            , cast('alice@example.com' as text) as email
            , cast('2023-01-01 10:00:00' as timestamp) as created_at
    )
select
    *
from
    users
where
    id = 1

SQLパーサ、型推論に本物のDBを使用することで、「実機では動かない」問題を防ぎます。

Result Select Query

テーブルへ書き込まずにクエリを実行する方法です。

CUDは実行結果をシミュレートする選択クエリに変換することで書き込みを止めます。

--origin
update users
set
    name = 'Bob'
where
    id = 1
returning *
;
--ZTD
with
    users as (
        select
            cast(1 as integer) as id
            , cast('Alice' as text) as name
            , cast('alice@example.com' as text) as email
            , cast('2023-01-01 10:00:00' as timestamp) as created_at
    )
select
    users.id
    , 'Bob' as name --Alice を Bob に書き換えて返却
    , users.email
    , users.created_at
from
    users
where
    id = 1

一見分かりづらいですが、更新クエリの結果セットを再現するため、選択クエリ上で列「name」を書き換えをしています。
なお、returningがない場合、カウントクエリで表現します。

--省略
select
    count(*) as count
from
    users
where
    id = 1

Connection Adaptor

CRUDからテーブル依存を排除することはできましたので、これを単体テストで利用できるようにします。

pg.Client.query をフックし、SQLを実行する直前に CTE Shadowing/Result Select Query に書き換えて流し込みます。既存コードを変更せず、接続オブジェクトだけ差し替える仕組みです。

これにより、pgを使用しているコードであれば、非破壊で@rawsql-ts/pg-testkitを導入が可能です。

オンラインデモ

ZTDは新しい概念のため、挙動をイメージしづらいと思います。
そこで、オンラインデモサイトを用意したのでお試しください。

https://mk3008.github.io/rawsql-ts/cud-demo/index.html

このサイトの左側「SQL Input」に以下のSQLを貼り付けてください。

create table users(
    id INTEGER primary key default nextval('users_id_seq')
    , name TEXT
    , email TEXT
    , created_at TIMESTAMP default now()
)
;

insert into users(
    name
    , email
)
values
    ('Alice', 'alice@example.com')
returning *
;

update users
set
    name = 'Bob'
where
    id = 1
returning *
;

右側には以下のようなSQLが出力されます。(単なる整形が起きている場合は、「Format Only」となっている個所を「Convert(Simulate)」に変更してください)

with
    __inserted_rows(name, email) as (
        select
            cast('Alice' as TEXT) as name
            , cast('alice@example.com' as TEXT) as email
    )
select
    row_number() over() as id
    , __inserted_rows.name
    , __inserted_rows.email
    , now() as created_at
from
    __inserted_rows
;

with
    users as (
        select
            cast(1 as INTEGER) as id
            , cast('Alice' as TEXT) as name
            , cast('alice@example.com' as TEXT) as email
            , cast('2023-01-01 00:00:00' as TIMESTAMP) as created_at
    )
select
    users.id
    , 'Bob' as name
    , users.email
    , users.created_at
from
    users
where
    id = 1

解説すると、以下のようなことをしています。

  1. DDLからテーブルの型情報、NULL可否、初期値を取得
  2. insert文のZTD変換
  3. update文のZTD変換(デモ用のロジックとして、insert文をシーディング扱いにしています)

これをデータベースドライバに組み込んだものが、 @rawsql-ts/pg-testkitです。

@rawsql-ts/pg-testkit の使い方

開発専用でインストールします。

npm install -D @rawsql-ts/pg-testkit

次に単体テストコードを書きます。

import { Client } from 'pg';
import { createPgTestkitClient } from '@rawsql-ts/pg-testkit';

describe('UserRepository', () => {
  it('creates a user with defaults', async () => {
    const client = createPgTestkitClient({
      connectionFactory: () => new Client({ connectionString: process.env.TEST_DB! }),
      fixtures: [
        {
          tableName: 'users',
          columns: [
            { name: 'id', typeName: 'int', required: true, defaultValue: "nextval('users_id_seq'::regclass)" },
            { name: 'email', typeName: 'text', required: true },
            { name: 'active', typeName: 'bool', defaultValue: true },
            { name: 'created_at', typeName: 'timestamp', required: true, defaultValue: 'now()' }
          ],
          rows: [
            {
              id: 1,
              email: 'alice@example.com',
              active: true,
              created_at: '2023-01-01 10:00:00'
            }
          ]
        }
      ]
    });

    const result = await client.query(
      'insert into users (email, active) values ($1, $2) returning id, email, active, created_at',
      ['bob@example.com', false]
    );

    expect(result.rows[0]).toEqual({
      email: 'bob@example.com',
      active: false,
      created_at: expect.any(String)
    });
  });
});

createPgTestkitClient

const client = createPgTestkitClient({

ZTDを実装したテスト専用の疑似DBクライアントであるPgTestkitClientを初期化します。

connectionFactory

connectionFactory: () => new Client({ connectionString: process.env.TEST_DB! }),

本物のDBエンジン(SQL解析、型チェックなど)を使用するため、接続文字列を指定します。
テーブルなどを準備する必要は一切ありません。

fixtures

fixtures: [
  {
    tableName: 'users',
    columns: [...],
    rows: [...]
  }
]

ここで指定した内容が、物理テーブルの代わりに使われる影テーブル(CTE)になります。

● tableName

Shadowingするテーブル名を指定します。CTEの名前としても利用されます。
これにより、SQLがusersテーブルを読もうとした場合、自動的にCTEが挿入されます。

with users as (...) select ...

● columns

columns: [
  { name: 'id', typeName: 'int', required: true, defaultValue: "nextval('users_id_seq'::regclass)" },
  ...
]

カラム名、DbType、NULL可否、デフォルト値を指定します。
これにより、物理テーブルを使用せずに高度な型情報を再現可能になります。

● rows

rows: [
    {
      id: 1,
      email: 'alice@example.com',
      active: true,
      created_at: '2023-01-01 10:00:00'
    }
]

ここに書いた行データが、そのままCTEのデータになります。

まとめると、以下のSQLをDSLで定義したということになります。

with
users as (
    select
        cast(1 as int) as id,
        cast('alice@example.com' as text) as email,
        cast(true as bool) as active,
        cast('2023-01-01 10:00:00' as timestamp) as created_at
)

より高度な使い方

デフォルト値、シーケンス、タイムスタンプ

デフォルト値が定数の場合、再現されます。
しかし、DB環境を使用しないため、動的要素は厳密なテストは行えません。

シーケンスは毎回1から連番が振られます。
タイムスタンプは固定値が返されます。

テーブル定義の管理方法

テストデータはテスト依存ですが、テーブル定義はプロジェクト共通のため、テストクラスごとに書くのは現実的ではありません。この場合は、create table文をファイルで用意し、それをインポートするようにしてください。

const ddlPath = path.resolve(__dirname, '../ddl/schemas');

const client = createPgTestkitClient({
  connectionFactory: () => new Client({ connectionString: process.env.TEST_DB! }),

  // DDLからCREATE TABLEを読み込む。
  // INSERT文がある場合、シードとして使用される、
  ddl: {
    directories: [ddlPath], // DLLファイルが保存されいてるパス。サブフォルダも検査対象
    extensions: ['.sql'], // 拡張子。省略可
  },

  // 必要に応じてテスト用のrowsを手動で追加できる
  // 個別のテストケースはここで指定。
  tableRows: [
    {
      tableName: 'users',
      rows: [{ id: 1, email: 'alice@example.com', active: true }],
    },
    {
      tableName: 'orders',
      rows: [{ id: 42, total: 199.99 }],
    },
  ],
});

ZTDではできないこと

ZTDに向いていないこと、スコープ外のことがありますので、明示しておきます。

DBに強く依存する制御、具体的には、ストアド、トリガー、Viewを使用している場合、対応はできません。
また、パフォーマンス、チューニング検証にも向いていません。

今後について

単体テストは高速化され、開発時にマイグレーションは不要となりました。

しかし、デプロイ時にはマイグレーションがどうしても必要ですので、DDLを比較し、マイグレーションスクリプトを吐くツールを開発予定です。

https://mk3008.github.io/rawsql-ts/migration-demo/index.html

コラム:ZTDとORMとAI

ZTDはSQLの単体テストを低コストで実現しています。
これによりSQL、リポジトリクラスの正しさを高速に試行することが可能になります。

これが一般化した場合、ORMはAIにとってオーバーヘッドになるかもしれない。
AIにとって、SQLをDSLに変換する必要はない。
DSLで管理するメリットは減少し、
SQLで最終的なデータ形状を直接作れば、
アプリ側でのDTO変換やマッピング処理は不要になる。
ORMによく見られるテーブルを模写したデータモデルを使用する場所がない。

SQLはまるでAPIのように、ドメインの要求に直接応答する。
必要なコードはすべてSQLにあり、ライブラリの深部を探る必要はない。

AIはORMを「余計な抽象化」と認識する日が来るかもしれない。

Discussion