🧪

pg-testkit SQL単体テストベンチマーク (ZTD対応)

に公開

SQL単体テストキット @rawsql-ts/pg-testkit(以下、pg-testkit)のベンチマーク計測を行いましたので結果をご紹介します。

pg-testkit とは

pg-testkitは、Node.js上で動作するTypeScript製のSQL単体テストキットで、以下の特徴を持ちます。

  • Postgresに対応
  • ZTD(Zero Table Dependency)方式とTraditional方式(一般的なSQL単体テスト)に対応

特に重要なのがZTD方式への対応です。

ZTD方式とは、マイグレーションやシーディングなしでSQLを単体テストする方式で、SQLを非常に高速に検証が可能です。

ZTDの仕組みについては、以下の記事で詳しく解説していますので、ご参照ください。

https://zenn.dev/mkmonaka/articles/c2413d99ae67bb

ベンチマークの目的

ZTDが高速であることは理論上明らかですが、Traditional方式と比べて、どこまで差が出るのかといった定量的な資料は出しておりませんでした。

そこで、実際に同一条件下でベンチマークを計測し、数値比較することにしました。

条件

  • 試行回数:5
  • テスト数:50、100、300
  • テスト方式:ZTD、Traditional
  • 並列数:1、2、4
  • DB接続方式:perTest、shared

テスト方式の違い

  • ZTD
    Zero Table Dependency 方式でSQLを解析し、意味論レベルでテストを行います。
    マイグレーションやシーディングは発生しません。
  • Traditional
    一般的なテスト方式です。
    テスト前にマイグレーションとシーディングを行い、テスト後にクリーンアップ処理が必要になります。

ZTDはマイグレーション、シーディングが存在しないため、理論上は高速になるはずです。

DB接続方式の違い

  • perTest
    各テストごとに DB 接続と切断を行います。
  • shared
    ワーカー単位で DB 接続を行い、複数テストでコネクションを共有します。

shared方式ではDB接続処理のコストを削減できますが、その代わりトランザクション共有による競合(待ち)が発生しやすくなります。

ZTDはテーブル操作を伴わないため、sharedコネクションをより有効に活用できると考えられます。

テストコード

以下は実際のコードを簡略化した例ですが、テストの考え方は同じです。
同一のfixture定義と同一のrepositoryコードを使い、ZTDモードとTraditionalモードを切り替えて実行しています。

完全なコードは以下のリポジトリを参照ください。
https://github.com/mk3008/rawsql-ts/tree/main/benchmarks/sql-unit-test

リポジトリクラス

import { customerSummarySql } from '../sql/customer_summary';
import { CustomerSummaryRepositoryClient } from './CustomerSummaryRepositoryClient';
import { CustomerSummaryRow } from './CustomerSummaryRow';

/** Repository that exposes customer summary aggregations without any additional inputs. */
export class CustomerSummaryRepository {
  constructor(private readonly client: CustomerSummaryRepositoryClient) {}

  customerSummary(): Promise<CustomerSummaryRow[]> {
    return this.client.query<CustomerSummaryRow>(customerSummarySql);
  }
}

テストコード

/**
 * ZTD / Traditional の両モードで同一のテストシナリオを実行する。
 *
 * テストコード側はmodeを切り替えるだけで、
 * SQL・repository・assert は一切変わらない。
 */
async function runInlineScenario(mode: 'ztd' | 'traditional') {
  const client = await createTestkitClient(inlineFixtures, { mode });

  try {
    const repository = new CustomerSummaryRepository(client);
    const rows = await repository.customerSummary();
    expect(rows).toEqual(inlineExpectedRows);
  } finally {
    await client.close();
  }
}

Traditional方式で発行されるSQL

マイグレーション

CREATE SCHEMA IF NOT EXISTS "ztd_traditional_mju5br8k_pvove"
;
SET search_path TO "ztd_traditional_mju5br8k_pvove", public
;
CREATE TABLE customer (
  customer_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  customer_name text NOT NULL,
  customer_email text NOT NULL,
  registered_at timestamp NOT NULL
)
;
CREATE TABLE product (
  product_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  product_name text NOT NULL,
  list_price numeric NOT NULL,
  product_category_id bigint
)
;
CREATE TABLE sales_order (
  sales_order_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  customer_id bigint NOT NULL REFERENCES customer (customer_id),
  sales_order_date date NOT NULL,
  sales_order_status_code int NOT NULL
)
;
CREATE TABLE sales_order_item (
  sales_order_item_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  sales_order_id bigint NOT NULL REFERENCES sales_order (sales_order_id),
  product_id bigint NOT NULL REFERENCES product (product_id),
  quantity int NOT NULL,
  unit_price numeric NOT NULL
)
;

シーディング

/*
(parameters: [10,"Widget","25.00",1], [11,"Gadget","75.00",2], [12,"Accessory","5.00",null])
*/
INSERT INTO "ztd_traditional_mju5br8k_pvove"."customer" ("customer_id", "customer_name", "customer_email", "registered_at") VALUES ($1, $2, $3, $4)
;
/*
(parameters: [100,1,"2025-12-04",2], [101,1,"2025-12-06",2], [200,2,"2025-12-05",2])
*/
INSERT INTO "ztd_traditional_mju5br8k_pvove"."sales_order" ("sales_order_id", "customer_id", "sales_order_date", "sales_order_status_code") VALUES ($1, $2, $3, $4)
;
/*
(parameters: [1001,100,10,2,"25.00"], [1002,101,11,1,"75.00"], [1003,200,12,3,"5.00"])
*/
INSERT INTO "ztd_traditional_mju5br8k_pvove"."sales_order_item" ("sales_order_item_id", "sales_order_id", "product_id", "quantity", "unit_price") VALUES ($1, $2, $3, $4, $5)

テストクエリ

SELECT
  c.customer_id,
  c.customer_name,
  c.customer_email,
  COUNT(DISTINCT o.sales_order_id) AS total_orders,
  COALESCE(SUM(oi.quantity * oi.unit_price), 0) AS total_amount,
  MAX(o.sales_order_date) AS last_order_date
FROM customer c
LEFT JOIN sales_order o ON o.customer_id = c.customer_id
LEFT JOIN sales_order_item oi ON oi.sales_order_id = o.sales_order_id
GROUP BY c.customer_id, c.customer_name, c.customer_email
ORDER BY c.customer_id;

クリーンアップ

DROP SCHEMA IF EXISTS "ztd_traditional_mju5br8k_pvove" CASCADE

ZTD方式で発行されるSQL

1つの選択クエリだけが実行されます。このクエリはpg-testkitで自動生成されます。
マイグレーション、シーディングはありません。

with "public_customer" as (select 1::bigint as "customer_id", 'Alice'::text as "customer_name", 'alice@example.com'::text as "customer_email", '2025-12-01T08:00:00Z'::timestamp as "registered_at" union all select 2::bigint as "customer_id", 'Bob'::text as "customer_name", 'bob@example.com'::text as "customer_email", '2025-12-02T09:00:00Z'::timestamp as "registered_at" union all select 3::bigint as "customer_id", 'Cara'::text as "customer_name", 'cara@example.com'::text as "customer_email", '2025-12-03T10:00:00Z'::timestamp as "registered_at"), "public_sales_order" as (select 100::bigint as "sales_order_id", 1::bigint as "customer_id", '2025-12-04'::date as "sales_order_date", 2::int as "sales_order_status_code" union all select 101::bigint as "sales_order_id", 1::bigint as "customer_id", '2025-12-06'::date as "sales_order_date", 2::int as "sales_order_status_code" union all select 200::bigint as "sales_order_id", 2::bigint as "customer_id", '2025-12-05'::date as "sales_order_date", 2::int as "sales_order_status_code"), "public_sales_order_item" as (select 1001::bigint as "sales_order_item_id", 100::bigint as "sales_order_id", 10::bigint as "product_id", 2::int as "quantity", '25.00'::numeric as "unit_price" union all select 1002::bigint as "sales_order_item_id", 101::bigint as "sales_order_id", 11::bigint as "product_id", 1::int as "quantity", '75.00'::numeric as "unit_price" union all select 1003::bigint as "sales_order_item_id", 200::bigint as "sales_order_id", 12::bigint as "product_id", 3::int as "quantity", '5.00'::numeric as "unit_price") select "c"."customer_id", "c"."customer_name", "c"."customer_email", count(distinct "o"."sales_order_id") as "total_orders", coalesce(sum("oi"."quantity" * "oi"."unit_price"), 0) as "total_amount", max("o"."sales_order_date") as "last_order_date" from "public_customer" as "c" left join "public_sales_order" as "o" on "o"."customer_id" = "c"."customer_id" left join "public_sales_order_item" as "oi" on "oi"."sales_order_id" = "o"."sales_order_id" group by "c"."customer_id", "c"."customer_name", "c"."customer_email" order by "c"."customer_id"

テスト結果

以下の表は、テストシナリオ一式(50 tests / 100 tests / 300 tests)を1回実行するのに要した総実行時間」を示しています。
各値は5回の試行結果の平均です。

50 tests - traditional, perTest

Method Connection Tests Parallel Mean (ms) Standard Error (ms) StdDev (ms)
traditional perTest (exclusive) 50 1 2434.68 31.96 71.46
traditional perTest (exclusive) 50 2 2554.37 16.63 37.19
traditional perTest (exclusive) 50 4 2772.67 37.82 84.56

DB側の並列処理がボトルネックとなり、並列数を増やすほど実行時間が悪化する結果となりました。

50 tests - traditional, shared

Method Connection Tests Parallel Mean (ms) Standard Error (ms) StdDev (ms)
traditional shared (reused) 50 1 2484.17 28.09 62.82
traditional shared (reused) 50 2 2564.20 49.45 110.58
traditional shared (reused) 50 4 2780.85 48.54 108.54

DBコネクションを共有しても傾向は大きく変わらず、Traditionalでは接続方式の違いによる改善効果は限定的であることが分かります。

50 tests - ztd, perTest

Method Connection Tests Parallel Mean (ms) Standard Error (ms) StdDev (ms)
ztd perTest (exclusive) 50 1 573.71 19.39 43.35
ztd perTest (exclusive) 50 2 633.39 24.14 53.99
ztd perTest (exclusive) 50 4 721.67 10.22 22.85

ZTDはTraditionalと比べて最大約 4.2 倍高速でした(2434.68 / 573.71)。
ただし、並列化による明確な性能向上はTraditional同様見られません。

50 tests - ztd, shared

Method Connection Tests Parallel Mean (ms) Standard Error (ms) StdDev (ms)
ztd shared (reused) 50 1 133.23 1.45 3.24
ztd shared (reused) 50 2 176.85 6.60 14.75
ztd shared (reused) 50 4 370.04 17.68 39.54

この条件では、ZTDはTraditionalに比べ約18.2倍高速という結果になりました(2434.68 / 133.23)。

この結果は、ZTDにおいて DBコネクションの open / close コストが、全体の実行時間に占める割合が非常に大きいことを示しています。


Traditional方式では、DBコネクションを共有して open / close の回数を減らしても、全体の実行時間はほとんど変わりませんでした。

これは、Traditional方式ではマイグレーションやシーディング、クリーンアップといった実テーブル操作がテスト単位で発生することに起因すると考えられます。
その結果、shared接続ではトランザクションやロックの競合が増え、かえって実行時間が悪化した可能性があります。

一方、ZTD方式では実テーブルを一切操作しないため、shared接続による競合がほぼ発生しません。
そのため、コネクション共有による open / close 削減の効果が、そのまま性能向上として現れたと考えられます。

100、300testsも計測はしましたが、50testsと傾向が全く同じのため省略いたします。

まとめ

  • Traditional方式では、コネクション管理よりも 実テーブル操作そのものが支配的コストとなるため、並列化や接続方式の工夫による改善は限定的。
  • ZTD方式はTraditional方式と比べて18倍以上高速。
    特にZTDはshared接続との相性がとても良い。

ZTD単体で速い(4倍高速)だけでなく、コネクション共有をすればさらに速くなる(18倍高速)ことが、定量的に確認できました。

SQLの単体テストを行う場合、まずZTD方式でテストできないかを検討し、DB依存の振る舞いを確認する必要がある場合にのみTraditional方式を使い分けるのが現実的だと思います。

余談:ベンチマークを作るにあたって

pg-testkitのテストコードを記述するのは大変ですので、効率よく記述するためのcliを開発しました。これもなかなか面白いプロダクトになりましたので、近いうちに紹介したいと思います。

Discussion