🎃

dataform-assertionsパッケージでdbtと同等のテスト品質を実現しよう

2024/04/21に公開

dataformが23年5月に一般提供(GA)になって、それなりの月日が経った。今のところdataformがdbtを追いかけるという構図は変わっていないように見えるが、

dataform導入のメリットとして、

  • GUIがBigQueryに近い
  • マネージドサービスである一方で、追加コストが発生しない
  • インフラ周りの設定項目が少なく、データアナリスト主体のチームが導入しやすい

といった点がある。その一方で、依然としてdbtではできるがdataformでできないこともある。

特に困るのは、データ品質テストの機能が不足している点だ。dataformのテストはassertionと呼ばれるが、デフォルトでは、uniqueness、nullチェック、自由記述のrow conditionしかサポートされていない。

それ以外のカスタムテストを実装する場合は、SQLを書くdataform固有のsqlxファイルを作成して、assertionを記述する必要がある。

-- custom_assertion.sqlx
config { type: "assertion" }

SELECT
  *
FROM
  ${ref("sometable")}
WHERE
  a IS NULL
  OR b IS NULL
  OR c IS NULL

これで好きなテスト自体は書けるのだが、dbtでいうaccepted_valuesのようなシンプルなテストならモジュール化して管理したい。

ということで、dataformではdbtのjinjaと同様にjavascriptを使って処理を記述することができるので、その方法でaccepted_valuesを実装してみる。

// assert_accepted_values.js
function assertAcceptedValues(table, column, acceptedValues, condition=null){
    return `
        SELECT
            ${column}
        FROM
            ${table}
        WHERE
            ${column} NOT IN (${acceptedValues})
            ${condition ? `AND ${condition}` : ""}
      `;
}

module.exports = { assertAcceptedValues }

関数は定義できたので、.sqlx側で呼び出す。

-- custom_assertions.sqlx
config {
    type: "assertion"
}

${functions.AssertAcceptedValues(ref("sometable"), "a", "[1,2,3]")}

とはいえ、この方法だと明確に良くないのが、1ファイルにつき1つのassertionしか記述できないことである。重要なテーブルなら大量のテストを書くことも珍しくないので、すぐにファイル数がとてつもない数になることは容易に想像がつく。

そこで公式ドキュメントをよく読んでみると、javascriptを使ってassertionを記述する方法もあることが分かる。


  assert("assertion1").query(ctx => "SELECT * FROM source_table WHERE value IS NULL");

javascript内には複数のassertionを書けるので一応これでテストをまとめられるのだが、今度は可読性の悪さが気になる。

もう少し良い方法はないかと探していたところ、つい先日、dataform-assertionsというパッケージがリリースされていた。contributorにdataformの開発者が含まれているので、現時点では半公式パッケージくらいの立ち位置だろうか。

dataformのリポジトリにインストールするためには、dbtのようにpackage.jsonという管理ファイルがあるので、dependenciesブロックに以下のように記述する。

"dataform-assertions": "https://github.com/devoteamgcloud/dataform-assertions/archive/refs/tags/[RELEASE_VERSION].tar.gz"

テストは任意のjsファイルに記述する。

const commonAssertions = require("dataform-assertions");

const commonAssertionsResult = commonAssertions({
  globalAssertionsParams: {
    "database": "sandbox-hrialan",
    "schema": "assertions_" + dataform.projectConfig.vars.env,
    "location": "EU",
    "tags": ["assertions"],
  },
  rowConditions: {
    "first_table": {
      "id_not_null": "id IS NOT NULL",
      "id_strict_positive": "id > 0"
    },
    "second_table": {
      "id_in_accepted_values": "id IN (1, 2, 3)"
    }
  },
  uniqueKeyConditions: {
    "first_table": ["id"],
    "second_table": ["id", "updated_date"]
  },
  dataFreshnessConditions: {
    "first_table": {
      "dateColumn": "updated_date",
      "timeUnit": "DAY",
      "delayCondition": 1,
    },
    "second_table": {
      "dateColumn": "updated_date",
      "timeUnit": "MONTH",
      "delayCondition": 3,
    }
  },
});

構造化されているおかげで、これまでの方法より可読性が良くなり複数のassertionをまとめて管理しやすくなった。また、source freshnessのテストなど、これまでdataformにはなかったテストも定義できるようになっている。

他にも興味深いことにデフォルトのuniqueKeyやrowConditionテストも含まれている。.sqlxのconfigよりこっちに書いて欲しいのだろうか。

では実際に動作させるために、初期化時に生成されるモデルを対象にテストを書いてみる。

const commonAssertions = require("dataform-assertions");

const commonAssertionsResult = commonAssertions({
  rowConditions: {
    "first_view": {
      "test_not_null": "test IS NOT NULL",
    }
  }
});

コード量はこれだけだが、このようにjavascriptに書いてもデータリネージには問題なく反映される。

また、rowConditionsの場合はconditionNameとして書いたキー(test_not_null)が、テスト名の一部に含まれる。(source)

const assertion = assert(`assert_${conditionName.replace(/-/g , "_")}_${tableName}`)

なお、dataformではテストと後続処理の依存関係は明示的に定義する必要があるので、ここでは後続のsecond_viewにdependenciesとして追加したテストを記載している。

-- second_view.sqlx
config {
    type: "view",
    dependencies: ["assert_test_not_null_first_view"]
}

SELECT
  test
FROM
  ${ref("first_view")}

実行される assertion_test_not_null_first_viewは、以下のようなクエリになる。


BEGIN
  CREATE SCHEMA IF NOT EXISTS `{project}.dataform_assertions` OPTIONS(location="{location}}");
EXCEPTION WHEN ERROR THEN
  IF NOT CONTAINS_SUBSTR(@@error.message, "already exists: dataset") AND
    NOT CONTAINS_SUBSTR(@@error.message, "too many dataset metadata update operations") AND
    NOT CONTAINS_SUBSTR(@@error.message, "User does not have bigquery.datasets.create permission")
  THEN
    RAISE USING MESSAGE = @@error.message;
  END IF;
END;
    CREATE OR REPLACE VIEW `{project}.dataform_assertions.assert_test_not_null_first_view`
OPTIONS(description='''Assert that rows in first_view meet test_not_null''')
AS (
  SELECT "Condition not met: test IS NOT NULL, Table: `{project}.dataform.first_view`" AS assertion_description
                   FROM `{project}.dataform.first_view`
                   WHERE NOT (test IS NOT NULL)
);
    ASSERT (
  ( SELECT COUNT(1) FROM `{project}.dataform_assertions.assert_test_not_null_first_view` ) = 0
) AS "Assertion failed, expected zero rows."

テストファイルをどのディレクトリに配置するかなど実際の運用で考えるべきことはまだあるが、dbtのような形で多様なテストを定義できるようになるという点では非常に素晴らしいパッケージではないだろうか。

elementaryやosmosisといったdbtのリッチなパッケージ群が提供するエコシステムは現状dataformにはないが、テストや増分更新など、dbtの基礎的な部分はこうしたパッケージも含めてかなり網羅されてきているので、立ち上げフェーズのデータチームがスモールスタートとして導入できる状態にはなっていそうだ。


Refenrece

Discussion