🔎

SQLでテストクエリを書いてみよう

に公開

はじめに

筆者はSQLのレビュワーとしての業務経験があります。レビュワーといっても、エンジニアではないビジネス職のメンバーが業務オペレーションの一つとして作成するものや、分析に用いるクエリの品質を担保するという役割です。

その中でテストクエリを記述してきましたので、ナレッジを共有させていただきます。

SQLレビューの4つの観点

SQLレビューでは、以下の4つの観点でチェックを行ってきました。

1. 要件適合性

  • そもそも求められている結果を返すか
  • ビジネス要件との整合性

2. 正確性

  • データの正しさ(結合漏れ、計算ミス、境界値など)
  • エッジケースへの対応

3. パフォーマンス

  • コスト効率(クラウドDWHの場合)

4. 可読性

  • メンテナンス性
  • チームでの理解しやすさ

本記事では特に「正確性」に焦点を当て、実務で使っている検証SQLパターンを紹介します。

実践:検証SQLパターン集

レビューで「正確性」を担保するために、検証用のSQLを書いて実際にデータを確認することが重要です。以下、実務で頻繁に使うパターンを紹介します。

テストSQLの基本方針

期待値と実際の値を比較する検証SQLを書く

-- テストの基本パターン
SELECT
  actual_value,
  expected_value,
  actual_value = expected_value AS is_valid
FROM result_table
WHERE actual_value != expected_value;  -- 差分があるレコードのみ抽出

差分があればテスト失敗、差分がなければテスト成功という明確な基準でSQLの正しさを検証します。

パターン1:結合テスト - NULLの発生を検証する

LEFT JOINを使った場合、想定外のNULLが発生していないかをテストします。

-- 結合漏れ検証テスト
WITH joined_data AS (
  SELECT
    u.user_id,
    u.registered_date,
    o.first_order_date,
    CASE 
      WHEN o.user_id IS NULL THEN 1 
      ELSE 0 
    END AS is_null
  FROM users u
  LEFT JOIN orders o ON u.user_id = o.user_id
),
test_result AS (
  SELECT
    COUNT(*) AS null_count,
    -- 期待値:登録から1年以上経過したユーザーの約20%は未注文と想定
    FLOOR(COUNT(*) * 0.2 * 0.8) AS expected_min,  -- 期待値の下限(-20%許容)
    FLOOR(COUNT(*) * 0.2 * 1.2) AS expected_max   -- 期待値の上限(+20%許容)
  FROM joined_data
  WHERE is_null = 1
    AND DATE_DIFF(CURRENT_DATE(), registered_date, DAY) >= 365
)
SELECT
  null_count AS actual,
  expected_min,
  expected_max,
  CASE
    WHEN null_count BETWEEN expected_min AND expected_max THEN 'PASS'
    ELSE 'FAIL'
  END AS test_status
FROM test_result;

テストのポイント

  • NULLの発生件数が想定範囲内か
  • ビジネスロジックとして妥当な結合結果か

パターン2:計算ロジックテスト - 複雑な計算式を検証する

複雑な計算式が仕様通りに実装されているかをテストします。

-- 計算ロジック検証テスト
WITH calculation_test AS (
  SELECT
    user_id,
    profit,
    -profit AS loss,
    FLOOR(total_deposit * 0.2) AS twenty_percent,
    -- 期待値:「損失額」「預金残高の20%」「500円」のうち最小値
    LEAST(-profit, FLOOR(total_deposit * 0.2), 500) AS expected_compensation,
    actual_compensation
  FROM result_table
)
SELECT
  user_id,
  expected_compensation,
  actual_compensation,
  expected_compensation - actual_compensation AS diff,
  CASE
    WHEN expected_compensation = actual_compensation THEN 'PASS'
    ELSE 'FAIL'
  END AS test_status
FROM calculation_test
WHERE expected_compensation != actual_compensation;  -- 差分があるレコードのみ

テストのポイント

  • 期待値と実際の値の差分がゼロか
  • 計算の各ステップを可視化して検証

差分があるレコードが1件でも返ってきたら、計算ロジックに問題があることを意味します。

パターン3:データ鮮度テスト - テーブルの更新状況を検証する

使用しているテーブルが最新データで更新されているかをテストします。

-- テーブル更新状況検証テスト
WITH table_freshness AS (
  SELECT
    'table_a' as table_name,
    MAX(updated_at) AS last_update_datetime,
    DATE_DIFF(CURRENT_DATE(), DATE(MAX(updated_at)), DAY) AS days_since_last_update,
    1 AS expected_max_days  -- 期待値:1日以内の更新
  FROM `project.dataset.table_a`

  UNION ALL

  SELECT
    'table_b' as table_name,
    MAX(updated_at) AS last_update_datetime,
    DATE_DIFF(CURRENT_DATE(), DATE(MAX(updated_at)), DAY) AS days_since_last_update,
    1 AS expected_max_days
  FROM `project.dataset.table_b`
)
SELECT
  table_name,
  last_update_datetime,
  days_since_last_update AS actual_days,
  expected_max_days,
  CASE
    WHEN days_since_last_update <= expected_max_days THEN 'PASS'
    ELSE 'FAIL'
  END AS test_status
FROM table_freshness
WHERE days_since_last_update > expected_max_days;  -- 期待値を超えているテーブルのみ

テストのポイント

  • 最終更新日時が想定範囲内か
  • 日次更新が止まっていないか

テストが失敗する場合、データパイプラインに問題がある可能性を示唆します。

パターン4:条件網羅性テスト - WHERE句の条件を検証する

WHERE句の条件が意図通りに機能し、論理的に整合しているかをテストします。

-- 条件の整合性検証テスト
WITH category_check AS (
  SELECT
    category,
    sub_category,
    COUNT(*) AS cnt,
    CASE
      -- 期待値:electronicsならsmartphone/tabletのみ許容
      WHEN category = 'electronics' AND sub_category IN ('smartphone', 'tablet') THEN 'VALID'
      -- 期待値:appliancesならrefrigerator/washing_machineのみ許容
      WHEN category = 'appliances' AND sub_category IN ('refrigerator', 'washing_machine') THEN 'VALID'
      ELSE 'INVALID'
    END AS validation_status
  FROM `project.dataset.product_master`
  WHERE update_date >= '2025-01-01'
    AND category IN ('electronics', 'appliances')
    AND (sub_category = 'smartphone' OR sub_category = 'tablet')
)
SELECT
  category,
  sub_category,
  cnt,
  validation_status,
  CASE
    WHEN validation_status = 'VALID' THEN 'PASS'
    ELSE 'FAIL'
  END AS test_status
FROM category_check
WHERE validation_status = 'INVALID';  -- 不整合なデータのみ

テストのポイント

  • 複数の条件の組み合わせが論理的に正しいか
  • 想定外のデータパターンが混入していないか

マスタデータの不整合を早期発見できます。

パターン5:境界値テスト - 閾値判定を検証する

金額や数量の閾値判定が正しく機能しているかをテストします。

-- 境界値検証テスト
WITH boundary_test AS (
  SELECT
    o.order_id,
    o.user_id,
    o.total_amount,
    o.shipping_fee,
    o.point_granted,
    -- 期待値の計算
    CASE WHEN o.total_amount >= 1000 THEN 0 ELSE 500 END AS expected_shipping_fee,
    CASE WHEN o.total_amount >= 5000 THEN FLOOR(o.total_amount * 0.05) ELSE 0 END AS expected_point,
    CASE
      WHEN o.total_amount = 999 THEN 'boundary_999'
      WHEN o.total_amount = 1000 THEN 'boundary_1000'
      WHEN o.total_amount = 1001 THEN 'boundary_1001'
      WHEN o.total_amount = 4999 THEN 'boundary_4999'
      WHEN o.total_amount = 5000 THEN 'boundary_5000'
      WHEN o.total_amount = 5001 THEN 'boundary_5001'
      ELSE NULL
    END AS boundary_type
  FROM `project.dataset.orders` o
  WHERE o.order_date >= '2025-11-01'
    AND o.order_date < '2025-12-01'
    AND o.status = 'COMPLETED'
)
SELECT
  boundary_type,
  total_amount,
  expected_shipping_fee,
  shipping_fee AS actual_shipping_fee,
  expected_point,
  point_granted AS actual_point,
  CASE
    WHEN shipping_fee = expected_shipping_fee AND point_granted = expected_point THEN 'PASS'
    ELSE 'FAIL'
  END AS test_status
FROM boundary_test
WHERE boundary_type IS NOT NULL
  AND (shipping_fee != expected_shipping_fee OR point_granted != expected_point);  -- 差分があるもののみ

テストのポイント

  • 閾値の境界(999/1000、4999/5000など)で正しく判定されているか
  • 「以上」「より大きい」などの条件が仕様通りか

境界値での挙動を実データで確認することで、仕様との齟齬を早期発見できます。

テストパターンの体系化とLLMフィードバックループ

テストパターンをナレッジ化する

これらのテストパターンを単にレビュー時に使うだけでなく、Markdownドキュメントとして体系化し、LLMを活用したフィードバックループに組み込むことで継続的な改善を実現することができます。

1. レビューで新たな問題パターン発見

2. 検証SQLテストをMarkdownドキュメントに追記

3. SQL生成時にClaude APIがテストパターンを参照

4. テストを通過するSQLが生成される

5. レビュー負荷軽減・同じミスが減る

6. 新たなパターン発見(1に戻る)

ナレッジ管理の実装

Markdownによる文書化

## 境界値テスト

### 概要
閾値判定(1000円以上、5000円以上など)が正しく機能しているかを検証する

### テストSQL
[上記のSQLコード]

### チェックポイント
- 境界値±1での挙動確認
- 「以上」「より大きい」の条件が仕様通りか

Git管理

  • 差分管理、変更履歴の追跡
  • チーム全体での更新・共有
  • レビューコメントとの紐付け

LLM連携

  • LLM APIに全ドキュメントを読み込ませる
  • 依頼内容に応じて適切なテストパターンを適用したSQLを生成
  • 生成されたSQLには検証SQLも含まれる

SQL生成ツールの動作例

依頼内容
「1000円以上で送料無料になる注文データを集計するSQLを書いて」

生成されるSQL

-- メインクエリ
SELECT
  order_date,
  COUNT(*) AS order_count,
  SUM(CASE WHEN total_amount >= 1000 THEN 1 ELSE 0 END) AS free_shipping_count
FROM `project.dataset.orders`
WHERE order_date >= '2025-11-01'
  AND status = 'COMPLETED'
GROUP BY order_date;

-- 検証SQL(境界値テスト)
WITH boundary_check AS (
  SELECT
    total_amount,
    shipping_fee,
    CASE WHEN total_amount >= 1000 THEN 0 ELSE 500 END AS expected_fee
  FROM `project.dataset.orders`
  WHERE order_date >= '2025-11-01'
    AND status = 'COMPLETED'
    AND total_amount IN (999, 1000, 1001)  -- 境界値のみ
)
SELECT *
FROM boundary_check
WHERE shipping_fee != expected_fee;  -- 差分があればFAIL

テストパターンがドキュメント化されているため、メインクエリとテストクエリがセットで生成されます。

おわりに

本記事で紹介したテスト駆動SQLの考え方は:

  • 期待値と実際の値を明示的に比較する
  • 差分があればFAIL、差分がなければPASSという明確な基準
  • テストパターンを体系化し、再利用可能にする

という汎用的な原則に基づいています。ぜひ自身の業務に合わせてカスタマイズし、活用してください。

また、LLMを活用したフィードバックループは、SQL生成に限らず様々な定型業務に応用できる考え方です。「人間が見つけた知見をLLMに還元し、次の作業に活かす」というサイクルを回すことで、継続的な業務改善が実現できます。


参考になった方は、ぜひいいねやコメントをいただけると嬉しいです。

Discussion