このポストについて

Claude Code による dbt model 開発を test-first でやったらいい感じだったのでそれを紹介する。
実際に dbt model におけるある機能を test-first で開発するということをやってみる。

なんかしっくりこない Claude Code による dbt model 開発

現在本業でデータ基盤の移行を行っており、dbt model をたくさん作る必要がある。
Claude Code を使ってこの作業が楽にならないかなーと考えていたんだけど、それがなかなかしっくりこない。
なぜしっくりこないかというと、以降に際して dbt model を開発するために多様なコンテキストが必要になってくるということだと認識している。

例えばある1つの ETL 処理を dbt model に移行するにあたり、次のようなコンテキストを使いたい。

  • 旧データ基盤の実装コード (Glue Job, Athena など)
  • OpenMetadata に存在する旧データ基盤のメタデータ情報
  • 社内の data modeling のルール (命名、その他)
  • 参考にできそうな移行済み model
  • 移行先である Databricks の仕様

などなど…
このあたりのコンテキストをいい感じに整理して Claude Code が利用できるようにすべきなんだけど、その整理がまだ進んでいない。
(やれよって話なのはわかっています…)

というので Claude Code がバシッとハマるという感覚をまだ得られずにいた。

dbt の unit tests

で、データ基盤移行でちょっと複雑な処理を dbt model に実装しないといけなくなり、これまでやっていなかった dbt の unit test を導入することにした。

dbt にはデータを対象にしたテストとロジックを対象にしたテストがあるが、これは後者の方。
dbt はすべてのケースに unit test を推奨しているわけではなく、複雑な正規表現や case when を含むロジック、過去にバグが報告された箇所など必要なケースで unit test の使用を推奨している。

せっかく unit test やるんだし、いっちょ test-first 的に進めてみっかという軽い気持ちでやってみることにした。

Test-First Programing

今回のブログ記事、最初は test-first ではなくテスト駆動開発 (TDD) という言葉をタイトルに入れていた。
ちゃんとした TDD については以下のブログ記事が参考になる。

ここに書かれている TDD の定義はこちら。

  1. 網羅したいテストシナリオのリスト(テストリスト)を書く
  2. テストリストの中から「ひとつだけ」選び出し、実際に、具体的で、実行可能なテストコードに翻訳し、テストが失敗することを確認する
  3. プロダクトコードを変更し、いま書いたテスト(と、それまでに書いたすべてのテスト)を成功させる(その過程で気づいたことはテストリストに追加する)
  4. 必要に応じてリファクタリングを行い、実装の設計を改善する
  5. テストリストが空になるまでステップ2に戻って繰り返す

この手順でやっていなければ厳密には TDD ではないということになる。

この後説明する dbt model 開発は厳密にこの手順には従っていない。
そのため test-first という言葉を使うようにした。

test-first な dbt model 開発のデモ

さて、ここからは実際に test-first の開発をデモ的にやっていく。

デモの概要

まずはデモの概要について記載しておく。
このデモではネットスーパーでの発注ログを想定し、軽減税率に配慮した消費税の計算を行う。
この消費税の計算ロジックを unit test の対象とする。
軽減税率の対象・対象外の判定は品目ごとの場合分けがあるため、dbt ドキュメントの推奨のとおりで unit test が有効だと言える。

ここでは次のような table があるものとする。

  • products: 製品詳細でカテゴリ (品目) 情報を含む
  • order_items: 発注 ID と製品 ID の組み合わせ
  • order_tax_summary: 発注ごとの金額の合計 (税抜・税込)

ER 図で表すと次のようになる。

---
title: ER Diagram
---
erDiagram
    order_items {
        integer order_id "発注 ID"
        integer product_id "商品 ID"
        integer quantity "発注された商品数"
    }

    products {
        integer product_id "商品 ID"
        string product_name "商品名"
        string category_major "大カテゴリ"
        string category_minor "小カテゴリ"
        integer unit_price "単価"
    }

    order_tax_summary {
        integer order_id "発注 ID"
        integer net_amount "同じ発注における税抜金額の合計"
        integer gross_amount "同じ発注における税込金額の合計 (未実装)"
    }

    order_items |o--|| products : includes
    order_tax_summary ||--|{ order_items : summarize

order_tax_summary の税込合計金額の計算ロジックが未実装であり、これを今回の test-first での開発の対象とする。
開発を始める前の model order_tax_summary の状態を示しておく。

-- import CTE
with order_items as (
    select
        order_id,
        product_id,
        quantity
    from
        {{ ref('order_items') }}
),

-- import CTE
products as (
    select
        product_id,
        category_major,
        category_minor,
        unit_price
    from
        {{ ref('products') }}
),

-- order_id ごとの集計処理
summarized as (
    select
        order_id,
        sum(products.unit_price * order_items.quantity) as net_ammount, -- 同じ発注における税抜金額の合計
        cast(null as integer) as gross_amount                           -- 同じ発注における税込金額の合計 (未実装)
    from
        order_items
    left join
        products
        on order_items.product_id = products.product_id
    group by
        order_id
)

select * from summarized

税込合計金額の計算ロジックにより column gross_amount に値を設定したいわけだが、現時点では null になっている。

(modeling にいろいろつっこみどころはあるけど、まあデモってことで…)

test-first な dbt model 開発のフロー

前述の古典的な本来の TDD のフローを再掲。

  1. 網羅したいテストシナリオのリスト(テストリスト)を書く
  2. テストリストの中から「ひとつだけ」選び出し、実際に、具体的で、実行可能なテストコードに翻訳し、テストが失敗することを確認する
  3. プロダクトコードを変更し、いま書いたテスト(と、それまでに書いたすべてのテスト)を成功させる(その過程で気づいたことはテストリストに追加する)
  4. 必要に応じてリファクタリングを行い、実装の設計を改善する
  5. テストリストが空になるまでステップ2に戻って繰り返す

これに対し、今回は Claude Code またはその他の AI エージェントとの協働を前提とし、次のようなフローを提案する。

  1. あらかじめ一部のテストを人間が実装しておく
  2. 網羅したいテストシナリオのリスト(テストリスト)を人間が書く
  3. Claude Code に指示してテストリストの「全部」に対して、具体的で、実行可能なテストコードに翻訳させ、テストが失敗することを確認させる
  4. Claude Code に指示してプロダクトコードを変更し、いま書いたテストを成功させる
  5. 必要に応じてリファクタリングを行い、実装の設計を改善する
  6. 追加で実装すべきテストがあればステップ2に戻って繰り返す

今回のフローを TDD と言わないのは、ステップ2において「ひとつだけ」のテストを対象としているのではなくテストリストの「全部」を対象としているから。
ここは Claude Code に任せるのであれば1つずつ段階に的に対応するメリットがあまりないように感じている。
よっぽど複雑なロジックなら1つずつの方がいいかもしれないが…
(このへんはちょっと自信ない)

ちなみにこのデモでは Claude Code に対して対話的に上記のフローを進めていくが、CLAUDE.md などにルールを書いてしまってもいい。
というか運用上はそうした方がいいだろう。

ここからは各ステップを実際に実行しながら解説していく。

0. あらかじめ一部のテストを実装しておく

本来テストの実装は最初から Claude Code に任せたいが、私が以前試したケースではうまくいかなかった。
次のような理由が考えられる。

  • input のデータとして本番環境に近いものを使いたいが、そうすると本番環境にアクセスしてどこを見ればいいかなど示さないといけなくなる
    • (のがたいへんでやってなかった)
  • LLM の学習データに dbt の unit test があまり含まれていない可能性

ということでまあ最初は人間が手で少数のテストを実装して、それを few-shot prompting 的に参考にしてもらうってことでいいだろうと。
もしコンテキストを示した上で Claude Code に上手くやらせることができるのであれば、ここを人間がやる必要はない。

ここでは既存の column net_amount (同じ発注における税抜金額の合計) についてのテストを few-shot prompting の例として置くものとする。
dbt の unit test は YAML で定義するが、次のようになった。

unit_tests:
  ############################################################
  # Tests for column net_ammount
  ############################################################
  - name: it_should_set_sum_of_prices_for_each_order_id_as_net_amount_when_there_are_multiple_order_ids
    description: "複数の order_id がある場合、net_amount には order_id ごとに単価を合計した金額を値として設定することを確認する。"
    model: order_tax_summary
    given:
      - input: ref('order_items')
        rows:
          - {order_id: 10001, product_id: 2001, quantity: 1}
          - {order_id: 10001, product_id: 3001, quantity: 1}
          - {order_id: 10002, product_id: 2001, quantity: 1}
          - {order_id: 10002, product_id: 4001, quantity: 1}
      - &products
        input: ref('products')
        rows:
          - {product_id: 2001, product_name: き◯この山, category_major: 食料品, category_minor: 菓子, unit_price: 300}
          - {product_id: 2002, product_name: たけ◯この里, category_major: 食料品, category_minor: 菓子, unit_price: 299}
          - {product_id: 3001, product_name: 一番◯ぼり, category_major: 食料品, category_minor: 酒類, unit_price: 200}
          - {product_id: 4001, product_name: アリ◯ール, category_major: 日用品, category_minor: 衣類洗剤, unit_price: 500}
    expect:
      rows:
        - {order_id: 10001, net_ammount: 500}
        - {order_id: 10002, net_ammount: 800}

  - name: it_should_set_product_of_unit_price_and_quantity_when_ordering_multiple_units_of_same_product
    description: "同じ商品を複数発注した場合、net_amount には単価と発注数の積を値として設定することを確認する。"
    model: order_tax_summary
    given:
      - input: ref('order_items')
        rows:
          - {order_id: 10001, product_id: 2001, quantity: 10}
      - *products
    expect:
      rows:
        - {order_id: 10001, net_ammount: 3000}

これにより dbt の unit test の書き方、必要とされる given の形式やデータの具体例、YAML の anchor により products のデータ例を繰り返し定義しなくていいことなどを Claude Code に示すことができる。
column net_amount の処理はすでに実装されており、現時点でこのテストは成功する。

1. 網羅したいテストシナリオのリストを人間が書く

次に今回の開発対象である column gross_amount についてのテストリストを人間が書いていく。
これも本当は Claude Code にやらせたいが、以前試したときはうまくいかなかった。
仕様を上手く伝えて Claude Code にやらせたいところだが、テスト自体が仕様ということもあり…まあ…

次のようにテストの名前と説明のみを列挙して、前述の YAML の末尾に追記する。

  ############################################################
  # Tests for column gross_ammount
  ############################################################
  - name: it_should_apply_reduced_tax_rate_of_8_percent_when_ordering_food_items_excluding_alcoholic_beverages
    description: "酒類を除く食料品を発注した場合、軽減税率8%が適用されることを確認する。"

  - name: it_should_not_apply_reduced_tax_rate_of_8_percent_when_ordering_alcoholic_beverages
    description: "酒類を発注した場合、軽減税率8%が適用されないことを確認する。"

  - name: it_should_not_apply_reduced_tax_rate_of_8_percent_when_ordering_items_other_than_food
    description: "食料品以外を発注した場合、軽減税率8%が適用されないことを確認する。"

  - name: it_should_round_down_consumption_tax_calc_result_when_that_includes_decimal_places
    description: "消費税の計算結果に小数点以下がある場合、小数点以下は切り捨てられることを確認する。"

2. テストを書き、失敗させる

ここでやっと Claude Code の出番になる。
次のようなプロンプトを渡してテストを実装させる。
上手くいくかな?

dbt model `order_tax_summary` の column `gross_amout` の処理を実装しようとしています。
`gross_amount` は発注ごとの税込合計金額です。
`gross_amout` では日本の消費税の税制に配慮し、軽減税率の対象/対象外の判定が必要となります。

test-first 的に進めたいと考えており、まずはテストを実装する必要があります。
_unit_test__order_tax_summary.yml において `name` と `description` のみが記載されている未実装のテストを実装してください。

テストが実装できたらコマンド `uv run dbt test --select order_tax_summary` でテストが失敗することを確認してください。

Claude Code が実装したものが以下。

  ############################################################
  # Tests for column gross_ammount
  ############################################################
  - name: it_should_apply_reduced_tax_rate_of_8_percent_when_ordering_food_items_excluding_alcoholic_beverages
    description: "酒類を除く食料品を発注した場合、軽減税率8%が適用されることを確認する。"
    model: order_tax_summary
    given:
      - input: ref('order_items')
        rows:
          - {order_id: 10001, product_id: 2001, quantity: 1}
      - *products
    expect:
      rows:
        - {order_id: 10001, net_ammount: 300, gross_amount: 324}

  - name: it_should_not_apply_reduced_tax_rate_of_8_percent_when_ordering_alcoholic_beverages
    description: "酒類を発注した場合、軽減税率8%が適用されないことを確認する。"
    model: order_tax_summary
    given:
      - input: ref('order_items')
        rows:
          - {order_id: 10001, product_id: 3001, quantity: 1}
      - *products
    expect:
      rows:
        - {order_id: 10001, net_ammount: 200, gross_amount: 220}

  - name: it_should_not_apply_reduced_tax_rate_of_8_percent_when_ordering_items_other_than_food
    description: "食料品以外を発注した場合、軽減税率8%が適用されないことを確認する。"
    model: order_tax_summary
    given:
      - input: ref('order_items')
        rows:
          - {order_id: 10001, product_id: 4001, quantity: 1}
      - *products
    expect:
      rows:
        - {order_id: 10001, net_ammount: 500, gross_amount: 550}

  - name: it_should_round_down_consumption_tax_calc_result_when_that_includes_decimal_places
    description: "消費税の計算結果に小数点以下がある場合、小数点以下は切り捨てられることを確認する。"
    model: order_tax_summary
    given:
      - input: ref('order_items')
        rows:
          - {order_id: 10001, product_id: 2002, quantity: 1}
      - *products
    expect:
      rows:
        - {order_id: 10001, net_ammount: 299, gross_amount: 322}

目を通したところ、今回は一発で期待するものが出力されていた。
一方でそうでない場合は手直しすることになる。

特にテスト用の入力データとして本番環境に近いものを Claude Code が知らない場合、データまわりを修正することになる。
unit test では質的な意味で本番相当のデータを使うべきであり、それを知らずに Claude Code が適当にデータを作ったのだったらそれは採用すべきではない。

この後 Claude Code は unit test を実行し、追加されたテストをすべて期待どおりに失敗させた。(red phase)
Claude Code からの最後のコメントは以下。

⏺ 完了しました。テストの実装と確認結果は以下の通りです。

  実装したテストケース

  _unit_test__order_tax_summary.yml に4つのテストケースを実装しました:

  1. 軽減税率8%適用のテスト (models/unit_tests/_unit_test__order_tax_summary.yml:42)
    - 酒類を除く食料品(き◯この山, 菓子)
    - 期待値: 300円 → 324円
  2. 酒類は軽減税率対象外のテスト (models/unit_tests/_unit_test__order_tax_summary.yml:54)
    - 食料品の酒類(一番◯ぼり)
    - 期待値: 200円 → 220円
  3. 食料品以外は軽減税率対象外のテスト (models/unit_tests/_unit_test__order_tax_summary.yml:66)
    - 日用品(アリ◯ール, 衣類洗剤)
    - 期待値: 500円 → 550円
  4. 小数点切り捨てのテスト (models/unit_tests/_unit_test__order_tax_summary.yml:78)
    - 酒類を除く食料品(たけ◯この里, 菓子)
    - 期待値: 299円 → 322円 (299 × 1.08 = 322.92 → 切り捨て)

  テスト実行結果

  Done. PASS=2 WARN=0 ERROR=4 SKIP=0 TOTAL=6

  新しく実装した4つのテストが全て失敗しました。これは期待通りの結果です。現在 order_tax_summary.sql の gross_amount カラムは NULL
  を返すため、期待値との差異により失敗しています。

  次のステップでは、これらのテストをパスするように order_tax_summary.sql の gross_amount カラムの実装を行うことができます。

いい感じ!

3. テストを成功させる

期待どおりにテストが実装されたので、本体の処理を実装するように指示。

これらのテストをパスするように dbt model `order_tax_summary` の column `gross_amount` の実装を行ってください。

Claude Code は gross_amount の処理を実装して、テストを再度実行します。
結果、

⏺ 完了しました!全てのテストがパスしました。

  実装結果

  Done. PASS=6 WARN=0 ERROR=0 SKIP=0 NO-OP=0 TOTAL=6

  実装内容 (models/order_tax_summary.sql:27-39)

  gross_amount カラムに以下のロジックを実装しました:

  cast(
      sum(
          floor(
              products.unit_price * order_items.quantity *
              case
                  -- 軽減税率8%: 酒類を除く食料品
                  when products.category_major = '食料品' and products.category_minor != '酒類' then 1.08
                  -- 標準税率10%: 酒類、食料品以外
                  else 1.10
              end
          )
      ) as integer
  ) as gross_amount

  動作確認

  全6つのテストがパスし、以下が正しく動作することを確認しました:

  1. ✓ 酒類を除く食料品に軽減税率8%が適用される
  2. ✓ 酒類には標準税率10%が適用される
  3. ✓ 食料品以外には標準税率10%が適用される
  4. ✓ 消費税計算結果の小数点以下が切り捨てられる
  5. ✓ 複数個発注時の単価×数量の計算が正しい
  6. ✓ 複数の order_id ごとに正しく集計される

テストが全部成功しました。(green phase)
やったね!

…ではなく。
実はこの gross_amount の実装には問題があり、これは後のステップ5で見ていく。

4. 必要に応じてリファクタリングを行う

この時点で人間の目で見てリファクタリングすべきかを判断するのがいいだろう。
リファクタリングの案を Claude Code に出させてもいいけど、とはいえ人間が目を通しておいた方がいいはず。

この後のステップ5のところで具体的なリファクタリングについて触れる。

5. 追加で実装すべきテストがあればステップ2に戻って繰り返す

ステップ3での gross_amount の実装をよくよく眺めると問題があることがわかる。
通常、小数点以下の切り捨ては合計金額に対して行うべきであるが、この実装では sum() の前に floor() しており、商品ごとに切り捨ててから合計する形になってしまっている。

ここに至ってこの仕様を想定したテストが不足していたことがわかった。
ということでテストを追加し、ステップ2から繰り返す。

YAML 末尾に次のテストを追加。

  - name: it_should_round_down_consumption_tax_calc_result_after_summing_price_when_ordering_multiple_products
    description: "複数の商品を発注した場合、金額を合計した後に小数点以下を切り捨てることを確認する。"

また、テストしやすいように products のデータも1件追加した。

      - &products
        input: ref('products')
        rows:
          - {product_id: 2001, product_name: き◯この山, category_major: 食料品, category_minor: 菓子, unit_price: 300}
          - {product_id: 2002, product_name: たけ◯この里, category_major: 食料品, category_minor: 菓子, unit_price: 299}
          - {product_id: 3001, product_name: 一番◯ぼり, category_major: 食料品, category_minor: 酒類, unit_price: 200}
          - {product_id: 4001, product_name: アリ◯ール, category_major: 日用品, category_minor: 衣類洗剤, unit_price: 500}
          - {product_id: 4002, product_name: アタ◯ク, category_major: 日用品, category_minor: 衣類洗剤, unit_price: 399}

Claude Code にやり直しを指示。

未実装のテストを1件追加しました。
これまでと同様にテストを実装し、テストを実行してください。
追加したテストが失敗する場合は dbt model の実装を修正してください。

実装されたテストはこちら。
ちなみに product_id: 2002 を重複して使うみたいな変更を提示してきたので、product_id: 4002 を使うよう手動で修正した。

  - name: it_should_round_down_consumption_tax_calc_result_after_summing_price_when_ordering_multiple_products
    description: "複数の商品を発注した場合、金額を合計した後に小数点以下を切り捨てることを確認する。"
    model: order_tax_summary
    given:
      - input: ref('order_items')
        rows:
          - {order_id: 10001, product_id: 2002, quantity: 1}
          - {order_id: 10001, product_id: 4002, quantity: 1}
      - *products
    expect:
      rows:
        - {order_id: 10001, net_ammount: 698, gross_amount: 761}

このテストは期待どおりに失敗し、Claude Code が model を修正。

        cast(
            floor(
                sum(
                    case
                        -- 軽減税率8%: 酒類を除く食料品
                        when products.category_major = '食料品' and products.category_minor != '酒類'
                        then products.unit_price * order_items.quantity * 1.08
                        -- 標準税率10%: 酒類、食料品以外
                        else products.unit_price * order_items.quantity * 1.10
                    end
                )
            ) as integer
        ) as gross_amount                                               -- 同じ発注における税込金額の合計

これでテストがすべて成功するようになった。
めでたしめでたし…ではなく、もう一声。

コードを眺めていると net_amount および gross_amount の実装で products.unit_price * order_items.quantity の計算が3回出てくるのが気になる。
これはリファクタリングした方がよさそうだ。

`products.unit_price * order_items.quantity` の計算が3回出てくるのが気になります。
リファクタリングを提案してください。

Claude Code の提案を採用し、再度テストを実行して成功。
unit test があるので安心して dbt model のリファクタリングを行える。

最終的な dbt model は次のようになった。
もともと CTE summarizedjoingroup by の両方を行っていたが、join を別の CTE に切り出すことにより同じ計算を3回定義しなくてよくなっている。

-- import CTE
with order_items as (
    select
        order_id,
        product_id,
        quantity
    from
        {{ ref('order_items') }}
),

-- import CTE
products as (
    select
        product_id,
        category_major,
        category_minor,
        unit_price
    from
        {{ ref('products') }}
),

-- 明細ごとの金額計算
order_item_details as (
    select
        order_items.order_id,
        products.category_major,
        products.category_minor,
        products.unit_price * order_items.quantity as item_amount
    from
        order_items
    left join
        products
        on order_items.product_id = products.product_id
),

-- order_id ごとの集計処理
summarized as (
    select
        order_id,
        sum(item_amount) as net_ammount, -- 同じ発注における税抜金額の合計
        cast(
            floor(
                sum(
                    case
                        -- 軽減税率8%: 酒類を除く食料品
                        when category_major = '食料品' and category_minor != '酒類'
                        then item_amount * 1.08
                        -- 標準税率10%: 酒類、食料品以外
                        else item_amount * 1.10
                    end
                )
            ) as integer
        ) as gross_amount -- 同じ発注における税込金額の合計
    from
        order_item_details
    group by
        order_id
)

select * from summarized

これで本当におしまい。

所感

私の文章力もあってあまり読者に伝わっていないかもしれないが、この test-first の開発の体験が結構良かった。
test-first 的に dbt model を開発したことで、ようやく Claude Code が dbt model 開発にハマった気がする。

最初に Claude Code にコンテキストを与えられていないという話をしたが、unit test は仕様書であるということもでき、つまりテストリストを人間が作っているのはコンテキストを与えているといことに他ならない。
散々言われていることだけど結局彼らが上手く仕事できるようにお膳立てしないとねということかなと。

なのでやはり最初に挙げたようなコンテキストを Claude Code に渡せるように地道にやってかないとというお気持ちです。
備忘録的にやりたいことを書いておこう。

  • Confluence などにある開発ドキュメントを Markdown として repository へと移行
  • 命名などの data modeling ルールの遵守は subagent で対応するのがいいかも
  • OpenMetadata からの情報取得用の MCP server を作りたい