テストとデータ品質 ― 壊れたデータを下流に流さない
整える層の価値は、壊れたデータを下流に流さないことにあります。not_null・unique・relationships・accepted_valuesといった汎用テストと独自テスト、警告と失敗の使い分けを整理し、計測設計の連載で設計した接合率を常時監視するテストへ落とし込む方法を、SOLNA(架空のD2C)と匿名化した実案件の一次データで示します。
by Shin
マートが静かに壊れていないことを、どうやって保証するのか。答えは、守りたい性質(一意である・欠けていない・参照が整合している・取りうる値の範囲に収まっている)をテストとして宣言し、それが破れたら下流に流す前に止める仕組みを持つことです。この記事では、dbtの汎用テストと独自テスト、警告と失敗の線引きを整理し、前回までに作ったstagingの上で、計測設計の連載(なぜ計測は静かに壊れるのか ― 計測設計という土台)で設計した接合率を常時監視するテストへ落とし込むまでを、SOLNA(過去の受託案件をもとに設計した架空のD2C)と匿名化した実案件の一次データで具体化します。
棲み分けを先に置きます。計測設計の連載が扱うのは「データが生まれる前」(収集・同意・識別・送信)の設計でした。本連載が扱うのは「データが入った後」(ローデータ → マート)の変換です。接合率も同じ分担で、計測設計の連載が"設計"を、本連載が"マートに保持してテストで監視する恒常化"を担います。接合率の定義そのものは記述・予測編に置き、ここでは「それを壊れないように見張る」側だけを書きます。
なぜテストが「整える」の核心か
第0回で、その場限りのSQLとスプレッドシートが「秘伝のタレ」になる構造を見ました。秘伝のタレが負債なのは、複雑だからではありません。正しさを誰も保証していないからです。書いた人の頭の中にしか「このクエリが前提にしている条件」が無く、その条件が崩れても、誰にも知らされない。
テストは、この暗黙の前提を実行可能な仕様として外に出す行為です。「order_idは一意のはず」「customer_idは欠けないはず」という頭の中の期待を、毎回のビルドで機械的に検証できる形に変える。テストの無いパイプラインは、壊れたことを誰にも教えてくれません。テストのあるパイプラインは、壊れた瞬間に手を挙げます。この差が、属人的な秘伝のタレと、組織で再現できる資産との分かれ目になります。
ここで一つ、翻訳しておきたい区別があります。ソフトウェア工学の「テスト」と、データの「テスト」は、似て非なるものです。
汎用テスト(not_null / unique / relationships / accepted_values)
dbtの汎用テストは、schema.ymlにカラムの性質を宣言するだけで効きます。最初に押さえるべきは4つです。not_null(欠けていない)、unique(重複が無い)、relationships(参照先が実在する=参照整合性)、accepted_values(取りうる値の集合に収まっている)。
SOLNAのcustomersとordersに当てると、こう書けます。
# models/marts/_marts.yml
models:
- name: customers
columns:
- name: customer_id
tests:
- not_null
- unique
- name: orders
columns:
- name: order_id
tests:
- not_null
- unique
- name: customer_id
tests:
- not_null
- relationships: # orders の顧客は customers に実在するか
to: ref('customers')
field: customer_id
- name: order_type
tests:
- accepted_values: # 取りうる値はこの3つだけ
values: ['first', 'repeat', 'subscription']宣言の意味を読み解くと、4つのテストはそれぞれ別の壊れ方を捕まえています。uniqueは二重計上(同じ注文が二回入った)を、not_nullは結合キーの欠損(下流の結合で行が消える原因)を、relationshipsは親無し子レコード(存在しない顧客に紐づく注文=孤児行)を、accepted_valuesは未知のカテゴリ流入(媒体が新しいorder_typeを送ってきた)を検知します。
最後のaccepted_valuesは地味ですが効きます。上流が予告なく新しい値を送り始める——channelに見慣れない媒体名が増える、statusに未定義のラベルが混じる——のは、サイレント障害のもっとも多いパターンの一つだからです。許容値を宣言しておけば、増えた瞬間に気づけます。
独自テストと、警告 vs 失敗の設計
汎用テストで拾えない「事業のルール」は、独自テスト(任意のSQLで書くテスト)で表現します。dbtの作法は単純で、「異常な行を返すSELECT」を1本書くだけです。返ってくる行数がゼロなら合格、1行でも返れば不合格、と読み替えられます。
-- tests/assert_order_amount_non_negative.sql
-- 異常 = 金額がマイナスの注文。0行なら合格。
select
order_id,
amount
from {{ ref('orders') }}
where amount < 0ここで運用設計の肝になるのが、警告(warn)と失敗(error)の線引きです。dbtはテストごとに深刻度を設定でき、errorはビルドを止め、warnは記録するだけで通します。
- name: customer_id
tests:
- not_null:
config:
severity: error # 結合キーの欠損は致命的。止める。
- name: amount
tests:
- not_null:
config:
severity: warn # 金額の欠損は監視に留め…
warn_if: ">0"
error_if: ">100" # …100件を超えたら異常事態として止めるこの線引きを誤ると、テスト体制そのものが破綻します。よくある失敗は全部をerrorにすることです。少しの揺らぎでもビルドが止まるので、現場は「またか」と赤を無視し始め、やがて本当の障害も見過ごされる。オオカミ少年化です。逆に全部warnにすれば、壊れたデータが平然と下流に流れます。
線引きの原則はシンプルです。「壊れたら下流の数字が間違う」ものはerrorで止める(結合キーの欠損、参照整合性の破れ、二重計上)。「監視はしたいが即停止までは要らない」ものはwarnで見張る(軽微な欠損、想定レンジからの逸脱)。warn_if/error_ifで件数の閾値を持たせ、平常時のノイズはwarnで飲み、量が異常になったらerrorに昇格させる、という二段構えが運用に耐えます。
接合率をテストで監視する(計測設計の連載の続き)
ここが本連載の固有の仕事です。計測設計の連載で設計した接合率——成果のうち、その手前の行動まで一本で辿れるものの割合——を、マートの上に常設し、テストで見張ります。計測設計の連載が"一度測って設計する"側なら、本連載は"毎日測り続けて壊れたら止める"側です。
まず接合率を計算するモデルを置きます。SOLNAでは、成果(顧客)のうち初回購入より前のセッションがcustomer_idで辿れる割合を、獲得チャネル別に出します。
-- models/marts/data_quality/spine_coverage.sql
-- 接合率 = 成果(顧客)のうち、初回購入より前の行動が辿れる割合
with customers as (
select customer_id, acquisition_channel, first_order_at
from {{ ref('customers') }}
),
linked as ( -- 購入前のセッションが辿れた顧客
select distinct s.customer_id
from {{ ref('sessions') }} as s
inner join customers as c
on s.customer_id = c.customer_id
where s.session_start < c.first_order_at -- 購入より前の行動だけ
)
select
c.acquisition_channel,
count(*) as customers_total,
count(l.customer_id) as customers_linked,
count(l.customer_id) * 1.0 / count(*) as spine_coverage
from customers as c
left join linked as l
on c.customer_id = l.customer_id
group by 1これを閾値テストにします。ただし一律の閾値は使えません。チャネルごとに「辿れて当たり前の水準」が違うからです。サイトフォーム経由は行動まで濃く辿れますが、プラットフォーム内で完結するインスタントフォーム経由は行動接点がそもそも無く、接合率はほぼ0になる。ここに一律< 0.7の閾値を当てれば、後者は永遠に赤のままで、テストはまた信用を失います。
そこで、チャネル別の期待下限を持ち、それを割ったら不合格、とします。期待値はseedやマート側に持たせ、テストは「期待を下回ったチャネルが存在するか」だけを問います。
-- tests/assert_spine_coverage_within_expected.sql
-- 異常 = 接合率が、そのチャネルの期待下限を下回ること。0行なら合格。
select
cov.acquisition_channel,
cov.spine_coverage,
exp.min_expected
from {{ ref('spine_coverage') }} as cov
inner join {{ ref('spine_coverage_thresholds') }} as exp
on cov.acquisition_channel = exp.acquisition_channel
where cov.spine_coverage < exp.min_expected接合率を監視する実利は、抽象論ではありません。匿名化した実案件(都内のオフィス仲介・受託・数値はレンジで丸めています)では、サイトフォーム経由の接合率は7〜8割のレンジ、インスタントフォーム経由はほぼ0という地力の差がありました。ある時期、媒体側のパラメータ仕様変更で識別子(gclid)の取りこぼしが増え、サイトフォーム経由の接合率が通常レンジから数ポイント低下したことがあります。これを閾値テストが拾い、下流の真のCAC算出に影響が出る前に手当てできました。
接合率が下がるとなぜ困るのか、を一段だけ降ろしておきます。接合率は下流の上限を画定します。たとえば成果の半分しか行動が紐づかなければ、予測モデルが学習に使える実弾はその半分です。接合率が静かに落ちれば、予測も因果も気づかぬうちに痩せていく。だからこれは「あれば便利な指標」ではなく、整える層がマートに保持し、テストで守るべき一級の品質指標です。
データ品質を運用に乗せる
テストは書いて終わりではなく、運用に乗って初めて効きます。最小限、三つを押さえます。
一つ目は、失敗の保存です。テストが落ちたとき、store_failuresを有効にすると「どの行が異常だったか」がテーブルに残ります。赤が出てから原因を探すのではなく、落ちた行そのものを見て即座に切り分けられる。デバッグの速度がまるで変わります。
二つ目は、深刻度の階層化です。前述のerror/warnに加え、warn_if/error_ifの件数閾値で「平常運転のノイズ」と「異常事態」を分けます。テスト体制の寿命は、現場が赤を信じ続けられるかで決まります。信じられる赤だけを赤にする——これが運用設計の本質です。
三つ目は、データの鮮度です。中身が正しくても、更新が止まっていれば数字は古い。dbtにはソース鮮度(freshness)を監視する仕組みがあり、「最後にデータが届いてから何時間経ったか」を見張れます。鮮度・系譜・CIをどう運用に組むかは第7回でまとめて扱うので、ここでは「品質は正しさと鮮度の両輪」とだけ置いておきます。
書き手として正直に補足すると、dbtのテスト記法やコマンドはバージョンで変わります。本稿のコードは最小構成で、最新の正確な引数や利用可能なテストは公式ドキュメントを確認してください。本連載が固定したいのは記法ではなく、「守りたい性質を宣言し、壊れたら下流の前で止める」という設計思想のほうです。
章末チェック
- 4つの汎用テスト(
not_null/unique/relationships/accepted_values)を、それぞれどの壊れ方に当てるか言えるか - 警告(
warn)と失敗(error)を、「下流の数字が間違うか否か」で線引きできているか - 全部を
errorにしてオオカミ少年化させていないか - 接合率をマートに常設し、チャネル別の期待下限でテストしているか
- テスト失敗を
store_failuresで追える状態にしているか
ここまでで、整える層に「壊れたら止まる」性質を持たせました。投資すべきかどうかの判断(整える層が無いと何が壊れるか)は、決裁者向けのデータ基盤に投資すべきかへ。接合率が何を画定し、その上で何を測り何を動かすかは記述・予測編へ。次回はintermediate層——再利用される中間モデルと、どこで結合しどこで集計するかのモデリング設計を扱います。