こんにちは。
久しぶりのブログ投稿になってしまったのですが、今日は「クエリの単体テストを書こうと思ったけど、壁が厚くてどうしようかなと思った話」を書きたいと思います。
はじめに言っておきますが、この記事は特に何か解決策があるわけでもなんでもなく、「ただ、クエリの単体テストを書こうとするとこんな問題にぶち当たって、それに対してこうしたらいいとは思ったけど、誰か最高のソリューションない?笑」って聞きたかったから書いただけの記事です笑*1
この記事のモチベ
最近、BigQueryを使ったデータ分析基盤とやらを開発しているのですが、「テスト書いてないとかお前それ @t_wada さんの前でも同じこと言えんの?」って言う状態になり、「これ真面目にやろうとするとどうなるんだ」って言うことで真面目に対峙してみました*2。
ここで言う「真面目に」と言うのは、「入り得る全パターンのデータに対する集計があっている」状態を指します。
自分もAPIの単体テストを書いたりとかは普通にやったことがあるのですが、データ集計系とか機械学習系とかのテストって、まぁなんというか「すみません、時間ないっす」ってしていました*3。書いたとしても、中途半端な、「これだけはあってるよ」って言うレベルです。
でも、やっぱ「ここだけは品質を落としちゃ行けねぇ*4」って言う部分をしっかり守りたいなという状況に出くわしまして、まぁやろうとなったわけです。
で、今回は色々検討した結果、「これってめちゃめちゃ大変だけどマジでどうするの?」っていうところについて書いていこうと思います。
前提
環境
環境としては、BigQueryを中心に話していきますが、やり方は違えど考え方は同じになるのではないかなと思います。
- データウェアハウス:BigQuery
- ワークフロー:Airflow (Cloud Composer)
BigQueryはエミュレーターが存在しないので、ローカルなどでテストが実行できませんので、実際に「BigQueryにテストデータを用意し、クエリを実行する」必要があります。
クエリの単体テストでやること
BigQueryにはエミュレーターが存在しないため、クエリの単体テストを書こうと思った時は、以下のような作業手順になると思います。
- 単体テストのために必要なテストデータを用意する
- テストデータを一時的にBigQuery上にテーブル化
- テストデータが入ったテーブルに対してテストのジョブをBigQuery上で実行
- テストのジョブの結果が、正解のデータと一緒かを判断する
- 処理が終わったら、作成したテーブルを削除する
実際の処理の実装方法についてはここでは細かく言及しませんが、大まかなフローはこのようになるかなと思います。
立ちはだかる3つの壁
上記のようなフローを想定し、早速テストを書いていこうと思ったんですが、3つの壁に会いました。
- データを網羅的に用意するのが非常にめんどくさい
- クエリがテストしやすいように書かれていない
- クエリの変更にある程度テストの処理が耐えれるようにする
それぞれについて書いていこうと思います。
壁①:データを網羅的に用意するのが非常にめんどくさい
クエリはそれ単体では動かないので、データを用意する必要があります。辛いよなぁって思っていたんですが、想像以上でした。
値の範囲が広すぎる
当たり前ですが、BigQueryにも型があります。以下のような感じです。
- STRING
- TIMESTAMP
- INT64
- FLOAT64
最初は、この値をベースにして、「Faker.jsとかを利用してダミーデータ生成すればええやんけ」って思っていました。
しかし、ここで問題が発生します。例えば、次のようなクエリです。
SELECT SAFE_CAST(revenue AS FLOAT64) AS revenue -- revenueはSTRINGです FROM hogehoge
なんと言うことでしょう*5。ありがちな「とりあえずStringで入れて、後でなんとかしよう」作戦です。まぁこれは一概に間違っていなくて、本当にSTRINGが来ることもあるわけです。
ただし、STRINGだなぁと思ってダミーのデータを生成してもダメで、INTやFLOAT(マイナスから、ゼロ、プラスまで)の値をSTRINGの顔をして入れてあげないと言うことです。STRINGがくると言うことは、空白文字列も想定しないといけないよねとかもあります。
なので、普通に文字列だから文字列だけ生成してもダメだし、逆に数値しか来ないよねってたかを括ってはいけないみたいな感じです。辛い。
データを用意するライブラリがない
まぁ、作れよって言われたらそれまでなんですが、意外にめんどくさい。探してみたけど、「俺が欲しいものはなかった」と言う感じで、「単体テストをやるならここ少し踏ん張って書かねば」って言う気持ちになりました。
型に沿ってデータを生成するものはあったのですが、欲しかったのは、
- 用意したいデータは決まったカテゴリーの値しか含まれないSTRINGだったり、
- ゼロ以上の整数だけとか、
そう言う風にいい感じでBigQueryのスキーマにプラスαで指定したらいい感じでデータを生成してくれるものです。
カラム数が少ない場合とかはまぁ手動でもなんとかなりそうですが、そうじゃないと結構めんどくさいよなぁってなります。
壁②:クエリがテストしやすいように書かれていない
ここでは実行の速度とかは一旦無視して議論しますが、クエリのロジックがきちんと分かれておらず(with句で分けるとか、そもそもdatalake/dwh/datamartのレイヤーで分かれていないとか)、テストのために用意するデータが変に複雑化したり、見逃したりすることがあります。
先程のSAFE_CASTとかもそうですが、一番最初のデータを取得するレイヤーとかで型の変換処理をできる限りやっておくとか、その変換を分けておくとかだけでも、「どのようなデータが来ることを期待しているか」がわかりやすくなり、後に対処方法でも話す「データとクエリのテストを分ける」のが楽になります。
実際のクエリはそうなっていないことの方が多いので、まずは処理のフローが見通しよくする方から着手する感じになります。
壁③:クエリの変更にある程度テストの処理が耐えれるようにする
これは「壁②」の話にもつながるのですが、テストの処理の都合上、Composerなどで実際に処理の中で叩くテーブルとは違うものになります。
なので、プログラム上で変換をしてあげる必要があるわけなのですが、まぁパーティションテーブルだったりそうじゃなかったり、環境ごとに値が変わるようになっていたりとかするわけです。ここら辺きっちり耐えれるように、最初からクエリが書かれていればOKなのですが、そうじゃなかったりするので現実は大変です汗。
対処方法
ってなわけで、クエリの単体テストを書くときの壁を書いてきたのですが、それに対してどのような対処方法をしていくのが良さそうかを考えてみました。
①:そもそも入口をちゃんとする
元も子もない話ですが、後述する対処方法を考えると、「datalake」のレイヤーにくるデータがいい感じであればあるほど最高なわけです。
こんなケースの方が少ないと思うので、一旦ここまでにしておきますが、マジでこれが効いてくるんだなぁと言う気持ちです。
②:値をフィルターする処理とクエリの集計ロジックを分ける
異常値が入ってくる可能性があることを想定するのは良いことですが、それと集計ロジックをごっちゃにすると、テストをする上でうまく切り分けができなくなります。
なので、「値をフィルターする処理」と「クエリの集計ロジック」をできる限りテーブルであったり、同じクエリ内であったとしてもわかりやすく分けたりすることで、「集計ロジックに対してのテストはある程度期待されるデータのみとする」と言うようにできます*6。
以下で述べる有効範囲のテストとの切り分けも見通しが良くなり、ミスが防ぎやすくなります。
③:値の有効範囲のテストとクエリの集計のテストを分ける
これも当たり前だよって気持ちかもですが、値としては入ってくる可能性がゼロとは言えないケース(アプリケーションの仕様が変わった、ミスってデータを送ってしまった、等)はあるとして、「その値が来たことに気づくテスト」と「クエリの集計が正しいかのテスト」を分けることで、簡単になるケースがあります。
例えば、以下のようにテストを分けるとかです。
- 値の有効範囲のテスト:ユニーク制約、Enum型のチェックなど
- クエリの集計のテスト:ロジック部分にのみフォーカスを当てたチェック
この切り分けをすることで、クエリの集計のテストの部分では、さまざまな値がきたことを想定したテストをしなくて良くなり、データを用意する手間が省けます。
クエリの集計のテストだけでも大変ですが、ある程度正常系に落とし込むことができるので、これは現実的には有効な手段と思えます。
まとめ
ベストプラクティスはまだよく分かってませんが、もし知っていらっしゃる方とかいたら、ぜひ教えて欲しいです!