はじめに
こんにちは、テックリードのかどたみです。
今回もテストネタです!こちらの記事でも紹介しましたが、エモーションテックではデータストアとの結合テストを行っています。RDBに関するコードのテストは実行ごとにデータを生成するのですが、BigQueryに関しては事前にデータをアップロードしてSQLのテストを行っています。
今回はBigQueryのテストデータ準備について紹介したいと思います。
この記事はエモーションテック Advent Calendar 2024の13日目の記事です。
前提
BigQueryのデータもテストの実行ごとに生成すればよいのでは?という意見もあるかと思いますが、以下の理由から事前にデータを準備してテストする方向性にしました。
また、以下の要件も満たせるように仕組みを検討しました。
- GitHub Actionsからテストデータを更新できる
- 各カラムの名称、型は自由に指定することができる
- 開発環境、ローカル環境など環境ごとにテストデータを更新できる
- テストデータの中身自体も変更されることが多そうだったので、更新したいタイミングでテーブルから作り直すことができる
- ソフトウェアエンジニア以外の人もテストデータを作る可能性を考慮し、csvファイルとして管理することができる
- Cloud Storageを経由する方法もあるが、なるべく依存を少なくしたかったのでできればCloud Storageを使わない
BigQueryからデータを取得するマイクロサービスのアプリケーションはTypeScriptで書かれているため、テストデータを生成するコードもTypeScriptで作成しました。
仕組み
CSVフォーマット
CSVを用いてテーブルの作成からデータの生成まで行いたかったので各カラムの型情報もCSVに含めることにしました。場所としてはヘッダーに「:」区切りでカラム名と型を入れています。
型情報はData Typesのドキュメントの「SQL type name」を記載します。
ファイルの例を以下に示します。
score:INT64,target_id:STRING 10,ID1234 5,ID5678 1,ID9999
また、CSVのファイル名をBigQueryのテーブル名としてインポートするのでSQLのビルダーなどから扱いやすいファイル名にしておきます。
※ JSONをCSVから読み込むドキュメントにも記載がありますが、JSON型を利用する際はJSONの文字列をダブルクオーテーションでエスケープするのがCSVの正しいフォーマットです。知らないと私のようにハマるのでご注意ください。
データ生成処理
テーブルにデータを生成する処理は以下のようなステップを踏みます。
コードの例を以下に示します。CSVのパースにはcsv-parseを利用しています。
import { parse } from 'csv-parse/sync'; import * as fs from 'fs/promises'; const DATASET_ID = 'dataset'; const FILE_NAME = 'filename'; try { // 冪等性担保のためデータセットを削除して入れ直す await bigquery.dataset(DATASET_ID).delete({ force: true }); } catch (e) { Logger.log(e); } await bigquery.createDataset(DATASET_ID, { location }); const data = await fs.readFile(FILE_NAME); const records = parse(data); const header = records.shift(); let columns = []; const schema = header.map((column) => { // CSVのヘッダにはカラム名と型が:区切りで記述されている想定 const info = column.split(':'); if (info.length < 2) { throw new Error( 'ヘッダにはカラム名と型が`:`区切りで設定されている必要があります。e.g 「_nps:Integer」', ); } columns.push(info[0]); return { name: info[0], type: info[1], }; }); const options = { schema, location, }; await bigquery .dataset(DATASET_ID) .createTable(file.split('.')[0], options); const rows = records.map((record) => { return columns.reduce((row, column, i) => { row[column] = record[i]; return row; }, {}); }); // ここでリクエストしないとinsertでテーブルを見つけてくれずエラーになるので入れておく await bigquery .dataset(DATASET_ID) .table(file.split('.')[0]) .exists(); await bigquery .dataset(DATASET_ID) .table(file.split('.')[0]) .insert(rows);
このスクリプトを環境変数などで対象のGCPのプロジェクトを変更可能にし、GitHub Actions 上で実行し、各環境のテストデータを生成しています。
これによってCSVを作成してスクリプトを回せばBigQueryを用いた結合テストができるようになりました。
ぶつかった課題と解決策
最初のうちは紹介したスクリプトで問題なくテストが回せていましたが、いくつか問題が出て都度改善してきたので紹介します。
配列が指定できない
前章で
> 型情報はData Typesのドキュメントの「SQL type name」を記載します。
と書いたのですが、Arrayを指定するとエラーになります。
上記で利用している仕組みの制限のドキュメントが見つからなかったのですが、Cloud Storageからのロードの制限事項を見ても、この記事執筆時点ではCSVではネストや繰り返しのデータ構造はそのままでは使えないようです。
この問題はCSVをJSONに変換してロードすることで解決できます。
なので、CSVをパースして1レコード1行に対応するJSONに変換してファイルに保存、その後ファイルを指定してBigQueryにロードします。
テーブルを作成するときもtypeプロパティをARRAYにするのではなく、`mode: 'REPEATED'`を追記します。
{ name: 'column name', type: 'STRING', mode: 'REPEATED' }
空文字列とNULLの区別
NULLABLEな文字列型のカラムも利用しているので空文字列とNULL両方のデータを生成する必要があります。CSVの場合は空文字列とダブルクォートで囲んだ空文字列として区別することが可能でしたが、パースしてJSONに変換するとなるとこの区別は使えません。
CSVのままアップロードする場合はmetadataのnullMarkerプロパティを指定することで指定した文字列をnullとして扱ってくれますが、JSONの場合はそのプロパティはありません。(JSONなのでnullの場合はnullを直接指定する、は当たり前ですが、、、)
なのでCSVからJSONへ変換する際にnullに置き換えることが必要です。
空文字列もダブルクォートで囲んだ空文字列もパースしたデータの時点でどちらも区別がなくなってしまうので、特殊な文字列を挿入することで無理やり解決しています。
ここまでの課題を解決したスクリプトの例を以下に示します。
import { parse } from 'csv-parse/sync'; import * as fs from 'fs/promises'; const DATASET_ID = 'dataset'; const FILE_NAME = 'filename'; try { await this.bigquery.dataset(DATASET_ID).delete({ force: true }); } catch (e) { // 存在しない場合もエラーになるので握りつぶしておく Logger.log(e); } await this.bigquery.createDataset(DATASET_ID, { location: this.location, }); const data = await fs.readFile(FILE_NAME); const records = parse(data); const header = records.shift(); const schema: ColumnInfo[] = header.map((column: string) => { // CSVのヘッダにはカラム名と型が:区切りで記述されている想定 const info = column.split(':'); if (info.length !== 2) { throw new Error( 'ヘッダにはカラム名と型が`:`区切りで設定されている必要があります。e.g 「_nps:Integer」', ); } if (info[1] === 'ARRAY_STRING') { return { name: info[0] as string, type: 'STRING', mode: 'REPEATED', }; } else { return { name: info[0] as string, type: info[1] as string, }; } }); const rows = records.map((record: string[]) => { return schema.reduce((row: { [x: string]: any }, columnInfo, i) => { if (columnInfo.mode !== null && columnInfo.mode === 'REPEATED') { row[columnInfo.name] = record[i].split(','); } else { row[columnInfo.name] = record[i]; // load だと metadata の nullMaker が使えなかったので特殊文字を入れてnullに変換 if (record[i] == '%NULL%') row[columnInfo.name] = null; } return row; }, {}); }); const tableName = csvFileName.split('.')[0]; // loadメソッドはCSVを渡した場合、REPEATEDカラムに対応できないので一度JSONに変換する const jsonFilePath = `${this.TEST_DATA_DIR}${tableName}.json`; const jsonStrings = rows .map((row: { [x: string]: any }) => JSON.stringify(row)) .join('\n'); await fs.writeFile(jsonFilePath, jsonStrings); const metadata = { sourceFormat: 'NEWLINE_DELIMITED_JSON', schema: { fields: schema, } }; const [job] = await this.bigquery .dataset(this.DATASET_ID, { location: this.location }) .table(tableName) .load(jsonFilePath, metadata); return await fs.rm(jsonFilePath);
依然ある課題
ここまででCSVで管理しているテストデータをBigQueryに読み込ませてテストをすることができるようになりました。しかし、以下のような課題が残っています。
スクリプトを動かすとデータを作成し直す仕組み上、ブランチごとに動作するテストデータが異なるとCIをまとめて動作させることができません。タイミングを見計らってテストCIを動かす必要があるので、大規模なチームになってボトルネックになりそうであれば仕組みを改善しようと思います。
また、JSONデータの作成のしにくさも感じています。エンジニア以外がテストデータを作成することを考慮したりエディタのサポートなどがあるのでCSVにしましたが、カラムの中にJSONを書くのが辛くなってきました。現状エンジニア以外がテストデータを作成することは稀ですし、結局JSONを変換するのであれば、はじめからJSONで書いてしまったほうが楽な気もしています。このあたりももう少し最適な方法を模索していきたいと思います。
おわりに
いかがだったでしょうか?今回はBigQueryへのSQLに対するテストに関するお話でした。このような準備をするのは少し手間がかかりますが、コード変更によるバグ検知への投資としてはとてもコスパが良いと感じます。将来の自分、同僚に向けて少しでもストレスのかからない環境を用意していきたいですね。
ドキュメントを見ると分かる通りBigQueryにデータを準備する方法はたくさんあります。すべて読みきったわけでもないのでもっとシンプルで簡単な方法もあるかもしれませんが、紹介したような課題もあるので改善するときにまた探っていきたいと思います。
エモーションテックでは顧客体験、従業員体験の改善をサポートし、世の中の体験を変えるプロダクトを開発しています。プロダクトに興味のある方、テストを通して保守しやすいプロダクトを作ることに興味がある方、ぜひ採用ページからご応募をお願いいたします。