7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

dbt macro のユニットテストについて考えてみた

7
Last updated at Posted at 2025-12-19

はじめに

この記事は スタンバイ Advent Calendar 2025 の20日目の記事です。

背景と課題

dbt 1.8 から Unit tests 機能がサポートされ、dbt model に対してのユニットテストが容易になりました。

しかし、独自に作成した macro に対してのテストは依然として難しい課題が存在します。
macro の用途は、大別すると以下の2種類に分かれます。

  1. SQL 文字列を生成する macro
  2. 値 (Python/Jinja の結果) を返す macro

ところが、両者は macro の記述内容もかなり異なり、ユニットテストで期待することが異なるケースもあると思います。

今回は、上記課題の解決アプローチを模索してみることにしました。

ローカル環境のセットアップ

今回の目的はユニットテストの検証のため、以降のサンプルコードは dbt の Best Practice に沿っていない内容もあります。ご容赦下さい。

今回は dbt-duckdb を利用します。
以下の公式ドキュメントに従って pip 経由で dbt-coredbt-duckdb をインストールします。

インストール後に dbt init を実行します。今回のプロジェクト名は sample_project とします。

Unit tests のおさらい

冒頭でも触れましたが、 dbt 1.8 から搭載された機能です。

以下のサンプルコードにて、具体例を紹介します。

model の準備

dim_members という model を作ります。

この model は stg_payments のデータを取得し、total_spent の額に応じて member_rank カラムにランク情報を追加するという処理を行います。

models/example/dim_members.sql
{{ config(materialized='ephemeral') }}

select
    member_id,
    total_spent,
    case
        when total_spent >= 10000 then 'Gold'
        when total_spent >= 5000 then 'Silver'
        else 'Bronze'
    end as member_rank
from {{ ref('stg_payments') }}

input となる stg_payments の定義は以下のようにしています。

models/example/stg_payments.sql
-- Unit Test のためのダミーデータ
select 
    1 as member_id, 
    100 as total_spent
models/example/schema.yml
version: 2

models:
  - name: stg_payments
    description: "支払い実績データ"
    columns:
      - name: member_id
        data_type: integer
      - name: total_spent
        data_type: integer

ここまで準備できたら、一度 dbt run を実行し、duckdb にデータを保存しておきます。

Unit tests の記述と実行

次に、model のユニットテストを定義します。
この yaml は、model を定義したディレクトリ (デフォルトでは models/) 配下に置く必要があります。

models/example/unit_tests.yml
unit_tests:
  - name: test_member_rank_calculation
    model: dim_members
    given:
      - input: ref('stg_payments')
        rows:
          - {member_id: 1, total_spent: 10000}
          - {member_id: 2, total_spent: 9999}
          - {member_id: 3, total_spent: 5000}
          - {member_id: 4, total_spent: 4999}
    expect:
      rows:
        - {member_id: 1, member_rank: 'Gold'}
        - {member_id: 2, member_rank: 'Silver'}
        - {member_id: 3, member_rank: 'Silver'}
        - {member_id: 4, member_rank: 'Bronze'}

dbt test を実行すると model のユニットテストが実行されます。

% dbt test
(前略)
15:13:38  Concurrency: 1 threads (target='dev')
15:13:38  
15:13:38  1 of 1 START unit_test dim_members::test_member_rank_calculation ............... [RUN]
15:13:38  1 of 1 PASS dim_members::test_member_rank_calculation .......................... [PASS in 0.05s]
15:13:38  
15:13:38  Finished running 1 unit test in 0 hours 0 minutes and 0.10 seconds (0.10s).
15:13:38  
15:13:38  Completed successfully
(後略)

macro のユニットテスト

ここからが本題です。

冒頭で述べた以下の 2種類の macro に対するユニットテストですが、それぞれ全く異なるアプローチを取ります。

  1. SQL 文字列を生成する macro
  2. 値 (Python/Jinja の結果) を返す macro

SQL 文字列を生成する macro のユニットテスト

こちらについては、先述の dbt の Unit tests の応用になります。
以下の記事で紹介されている手順を参考にしました。

テストしたい macro の準備

以下のような macro を用意します。

引数 column_name のカラムに対して 5 つの window 関数を実行してその結果を返すというものです。

macros/basic_aggregation.sql
{% macro basic_aggregation(column_name) %} 
	COUNT({{ column_name }}) AS {{ column_name }}_count,
	SUM({{ column_name }}) AS {{ column_name }}_sum,
	AVG({{ column_name }}) AS {{ column_name }}_avg,
	MIN({{ column_name }}) AS {{ column_name }}_min,
	MAX({{ column_name }}) AS {{ column_name }}_max
{% endmacro %}

テストに必要なファイルの準備

まず、汎用的にテストを実行可能にするためのテストデータ用 model を用意します。

models/unit_tests/macros/macro_input.sql
{{ config(materialized='ephemeral') }}

次に、ユニットテスト用の model を用意します。
この model 内で macro を呼び出し、その結果を dbt の Unit tests の機能でテストしようというものです。

models/unit_tests/macros/test_basic_aggregation.sql
{{ config(materialized='ephemeral') }}

SELECT
    {{ basic_aggregation("score") }}
FROM {{ ref('macro_input') }}

テストの記述

最後に、テスト内容を記述した yaml を用意します。

models/unit_tests/macros/unit_test.yml
unit_tests:
  - name: test_basic_aggregation
    model: test_basic_aggregation
    given:
      - input: ref('macro_input')
        format: sql
        rows: |
          SELECT unnest([1, 2, 3, 5, 8]) AS score

    expect:
      rows:
        - {score_count: 5, score_sum: 19, score_avg: 3.8, score_min: 1, score_max: 8}

ポイント・注意点をまとめます。

  • input に ref('macro_input') を指定することで、yaml 内にテストデータと結果の記述を集約できる
  • ユニットテスト用の model は全て ephemeral で定義すること (でないと dbt run で model が作られてしまう)

値 (Python/Jinja の結果) を返す macro のユニットテスト

こちらについては dbt 標準搭載の機能では実現が難しく、 dbt_unittest という package を利用します。

dbt_unittest の機能

この package は以下のような assertion 機能を提供します。
一般的なプログラミング言語の unit test でおなじみの機能ですね。

{{ dbt_unittest.assert_true(1 == 1) }}

package.yml に以下のように記述し、dbt deps コマンドを実行してインストールします。

packages.yml
packages:
  - package: yu-iskw/dbt_unittest
    version: 0.5.0

dbt run-operation コマンドについて

今回紹介する手順では、macro を実行するコマンドである dbt run-operation を併用します。
このコマンドは、model を呼び出さずに macro を実行できるというものです。

ただし、以下の説明にあるように do run_querydo log を明示的に呼び出さないと何もしません。

Unlike hooks, you need to explicitly execute a query within a macro, by using either a statement block or a helper macro like the run_query macro. Otherwise, dbt will return the query as a string without executing it.

筆者の通常の開発でもあまり使うシーンは多くなく、デバッグ時に do log を仕込んだ上で macro の動作を確認する際に使うことがあるぐらいです。

dbt_unittest と run-operation のコラボレーション

両者を組み合わせると、一般的なプログラミング言語と似たような手法でユニットテストが実現できます。

例えば以下のような macro を用意します。
見ての通り、このテストケースは失敗することを想定しています。

macros/unit_tests/test_foo.sql
{% macro test_foo() %}
    {{ dbt_unittest.assert_equals('foo', 'bar') }}
{% endmacro %}

この macro を dbt run-operation コマンドで呼び出すと、以下のような結果となりエラーになります。
dbt run-operation コマンドの結果も失敗であることがわかります。

% dbt run-operation test_foo
16:16:40  Running with dbt=1.10.15
16:16:40  Registered adapter: duckdb=1.10.0
16:16:41  Found 4 models, 492 macros, 2 unit tests
16:16:41  Encountered an error while running operation: Compilation Error in macro test_foo (macros/unit_tests/test_foo.sql)
  FAILED: foo does not equal bar.
  
  > in macro assert_equals (macros/assert_equals.sql)
  > called by macro test_foo (macros/unit_tests/test_foo.sql)
  > called by macro test_foo (macros/unit_tests/test_foo.sql)

% echo $?
1

次に、テストが成功するように macro の内容を以下のように修正し再度実行します。

    {{ dbt_unittest.assert_equals('foo', 'foo') }}

コマンドの結果は以下のようになり、成功となります。

% dbt run-operation test_foo
16:21:49  Running with dbt=1.10.15
16:21:50  Registered adapter: duckdb=1.10.0
16:21:50  Found 4 models, 492 macros, 2 unit tests

% echo $?                   
0

成功時は逆に何もメッセージが表示されないので注意が必要です。必要に応じて log を仕込むことをお勧めします。

macro のテスト

基本的な使い方が理解できたところで、実際に macro のテストをしてみます。

ISO 形式のタイムスタンプ文字列を受け取り、日付の部分を抽出して yyyy-MM-dd 形式の文字列で返す macro を用意します。

macros/get_date_from_isoformat.sql
{% macro get_date_from_isoformat(str_timestamp) %}
    {% set datetime = modules.datetime.datetime %}
    {% set parsed_datetime = datetime.fromisoformat(str_timestamp) %}
    {% return(parsed_datetime.strftime("%Y-%m-%d")) %}
{% endmacro %}

次に、上記 macro を呼び出して assertion を実行するユニットテストの macro を用意します。

macros/test_get_date_from_isoformat.sql
{% macro test_get_date_from_isoformat() %}
    {% set expected = "2025-10-01" %}
    {% set actual = sample_project.get_date_from_isoformat("2025-10-01T11:00:00.000+09:00") %}
    {{ dbt_unittest.assert_equals(expected, actual) }}
{% endmacro %}

あとは dbt run-operation コマンドでユニットテスト用の macro を呼び出せば OK です。

% dbt run-operation test_get_date_from_isoformat

macro 内では Jinja Template 記法となるため、for を使ってのコレクション操作など自由度が高い記述も可能です。

まとめ

2 種類の macro について、理論上はユニットテストできそうなことがわかりましたが、運用に取り入れるには以下のような懸念があります。

  • テスト用の model・macro が異なるディレクトリに分散する (これは dbt の仕様なので仕方がないが)
  • dbt run-operation コマンドでは macro 名を指定する必要があるため、CI/CD に組み込んでユニットテストを実行するには工夫が必要となる (dbt コマンドで macro を取得するものは無さそう?)

まだまだ実用化できるレベルではないかもしれませんが、dbt を利用する開発者の皆さんのヒントになれれば幸いです。

参考記事

7
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?