こんにちは。Sansan EU Master Dataグループ所属の金子と申します。
桜花の候、皆さまはいかがお過ごしでしょうか。弊社本社オフィスがあるサクラステージからは、渋谷桜丘の綺麗な桜並木を一望できます。東京では桜が満開ですが、三寒四温とは言ったものでまだまだ気温変化は激しく、私は毎朝アウターを着ていくかどうかに脳みそのリソースを著しく奪われております。何か気の利いた春物アウターでも欲しいものですね。
さて、今回はDataformでテーブル関数をテストする方法をご紹介したいと思います。
背景
私たちのチームではデータ基盤を開発しており、データパイプラインとしてDataformを使っています。これまでDataformを使っていく中で溜めた知見については過去記事にもありますので是非ご覧ください。
buildersbox.corp-sansan.com buildersbox.corp-sansan.com
今回私はあるクエリで、BigQueryの機能であるテーブル関数を使用しました。テーブル関数がどんなものか簡単に説明すると、引数を受け取ることができるクエリです。UDF(ユーザ定義関数)も同様に引数を受け取ることができますが、UDFが単一の値を返すのに対して、テーブル関数ではテーブルデータを返すことができます。これがどんな場面で役に立つかというと、例えば一定の時刻以降に更新されたデータをクエリするテーブル関数は次のように書くことができます。
CREATE OR REPLACE TABLE FUNCTION test.table_functionA(baseDate TIMESTAMP) AS SELECT * FROM test.table_A WHERE updated_at >= baseDate; SELECT * FROM test.table_functionA(TIMESTAMP('2025-04-01'));
引数はクエリの実行時に任意の値を指定できます。私たちのチームではデータパイプラインとしての用途以外にも、運用で使用するクエリの管理にDataformを使用しています。こうしたクエリで動的にクエリの条件を変えたい場合等に、テーブル関数は非常に有効です。
問題
テーブル関数をDataform上で定義するには、JavaScriptだと次のように書きます。
operate('table_functionA') .schema('test') .hasOutput(true) .queries( ( ctx, ) => /*sql*/ `CREATE OR REPLACE TABLE FUNCTION ${ctx.self()}(baseDate TIMESTAMP) AS SELECT * FROM ${ctx.ref('test_tableA')} WHERE updated_at >= baseDate;`, );
しかし、ここである問題が発生しました。せっかく定義したテーブル関数にテストを書く方法が用意されていないことです。テーブル関数では引数を指定できるので、引数の値によってどのように返ってくるテーブルデータが変わるかテストを書いておきたいところです。そこで、いろいろと試行錯誤を重ねて、テストを書く方法を見つけることができました。最初に成功したアプローチを、次に試してみたものの失敗したアプローチをご紹介します。
解決策
成功したアプローチは、「テスト時はテーブル関数をテーブル定義に変換してテストする」です。これだけ聞いても何が何やらですが、百聞は一見に如かず。まずはテストコードを掲載します。
const { TABLE_FUNCTION_A_QUERY_FOR_TEST } = table_functions; const { generate_table_function_test } = unit_test_utils; generate_table_function_test( 'table_functionA_引数で指定したupdated_at以降のデータが取得されること', 'test.table_functionA', [ { query: TABLE_FUNCTION_A_QUERY_FOR_TEST('"2025-04-01"'), input_tables: [ { name: 'test_tableA', value: test_util.toSelectQuery([ { updated_at: new Date('2025-03-31'), }, { updated_at: new Date('2025-04-01'), }, { updated_at: new Date('2025-04-02'), }, ]), }, ], expected_output: test_util.toSelectQuery([ { updated_at: new Date('2025-04-01'), }, { updated_at: new Date('2025-04-02'), }, ]), }, ], );
※ toSelectQuery関数は先述の過去記事で紹介した、独自に定義した関数です。
独自にテスト用の関数を定義しているため少し通常のDataformのテストの構文とは違いますが、概ね分かりやすいテストになっていると思います。引数で指定した2025-04-01以降に更新しているデータとして、2025-04-01, 2025-04-02の2件のデータを期待しています。
次に、もう少し細部を見ていきます。まずは、このテスト中で query として渡している箇所についてです。TABLE_FUNCTION_A_QUERY_FOR_TEST は、次のように定義しています。
const TABLE_FUNCTION_A_QUERY = (ctx) => { return `SELECT * FROM ${ctx.ref('test_tableA')} WHERE updated_at >= baseDate`; }; const TABLE_FUNCTION_A_QUERY_FOR_TEST = (baseDate) => { return (ctx) => TABLE_FUNCTION_A_QUERY(ctx).replace('baseDate', `TIMESTAMP(${baseDate})`); }; module.exports = { TABLE_FUNCTION_A_QUERY, TABLE_FUNCTION_A_QUERY_FOR_TEST, };
これを includes/table_functions.js のファイル名で定義し、他のファイルから参照できるようにしています。まず最初に TABLE_FUNCTION_A_QUERY についてですが、これはテーブル関数のSELECT以降を抜き出したものになります。次に、 TABLE_FUNCTION_A_QUERY_FOR_TEST についてですが、これは TABLE_FUNCTION_A_QUERY において、テーブル関数の引数として与えられていた箇所を実際の値に置換する処理になっています。つまり、先ほどのテスト中で query として指定していた箇所 TABLE_FUNCTION_A_QUERY_FOR_TEST('"2025-04-01"') は、次のように変換されます。
SELECT * FROM ${ctx.ref('test_tableA')} WHERE updated_at >= TIMESTAMP('2025-04-01')
これはテーブル関数ではなく、通常のSELECT文です。テストではテーブル関数に対してではなく、この定義に対してテストを行なっています。
また、テーブル関数を定義している箇所は次のように書き換えています。テスト対象と実際のテーブル関数が別物であったら意味がないので、テーブル関数の定義でも TABLE_FUNCTION_A_QUERY を使用し、コードの二重管理を防いでいます。
operate('table_functionA') .schema('test') .hasOutput(true) .queries( ( ctx, ) => /*sql*/ `CREATE OR REPLACE TABLE FUNCTION ${ctx.self()}(baseDate TIMESTAMP) AS ${table_functions.TABLE_FUNCTION_A_QUERY(ctx)}`, );
最後に本丸のテスト用関数についてです。この関数の実装では、過去記事にある create_udf_test 関数を大いに参考にしました。
function generate_table_function_test(test_name, udf_name, test_cases) { const splitted_udf_name = udf_name.split('.'); const table_name = splitted_udf_name.at(-1); test_cases.forEach((test_case) => { const dummy_view_name = `${table_name}_${uuidv4()}`; const { query } = test_case; create_dataform_table_function_test_view(dummy_view_name, query); run_table_function_test( test_name, dummy_view_name, test_case.input_tables, test_case.expected_output, ); }); } function create_dataform_table_function_test_view(dummy_view_name, query) { publish(dummy_view_name) .schema('for_unit_test') .type('view') .disabled(true) .query((ctx) => { return query(ctx); }); } function run_table_function_test( test_name, dummy_view_name, inputs, expected_output, ) { let testObj = test(test_name).dataset(dummy_view_name); inputs.forEach((input) => { testObj = testObj.input(input.name, input.value); }); testObj.expect(expected_output); }
詳細に踏み入ると長くなってしまうので、かいつまんで説明します。この関数で行なっていることは、先ほど紹介したテスト用クエリをテスト用のビューにし、そのビューとexpectするデータが一致しているかを比較しています。関数の引数を設定した状態でテスト用ビューを作成するというアプローチはUDFのテストと同様のアプローチになっています。
失敗したアプローチ
最後に、失敗したアプローチについてもご紹介します。
テーブル関数をそのままテスト対象とする
test関数の引数にテーブル関数をそのまま指定することはできませんでした。
test('table_functionA_引数で指定したupdated_at以降のデータが取得されること') .dataset('table_functionA') .input( 'test_tableA', test_util.toSelectQuery([ { updated_at: new Date('2025-03-31'), }, { updated_at: new Date('2025-04-01'), }, { updated_at: new Date('2025-04-02'), }, ]), ) .expect( test_util.toSelectQuery([ { updated_at: new Date('2025-04-01'), }, { updated_at: new Date('2025-04-02'), }, ]), );
Error: Dataset table_functionA could not be found. というコンパイルエラーが発生します。
UDFと同様のアプローチでテストする
先ほどご紹介した過去記事で、UDFをテストする方法についてご紹介しています。UDF用のテスト関数は次のような挙動をしています。
- UDFに引数を渡した状態でテスト用のビューを作成する
- テスト用のビューと、expectするデータが一致するかをテストする
この方法をテーブル関数についても適用し、テーブル関数に引数を渡した状態のテスト用ビューを作成しました。すると、テーブル関数中のrefで参照しているデータが本番のテーブルを参照してしまい、テストデータを参照してくれませんでした。ライブラリ内部の動作までは確認できていませんが、恐らくテスト用ビューの時点でクエリからrefが消えているため、テストデータを差し込むことができなかったものと推測しています。成功したアプローチでテーブル定義をテーブルに変換しているのは、上手くrefを解決させるためです。
まとめ
今回はテーブル関数をテストする方法についてご紹介しました。期待通りにテストできるようになったのは良かったですが、正直ワークアラウンド的な側面も大きいと思っています。こうした機能はライブラリ本体に取り込まれていた方が多くの人々が幸せになるので、コントリビューションにも挑戦していきたいと思います。
最後に、Sansanではデータ基盤の構築を通じてビジネスの成長に貢献できるエンジニアを募集しています。