dbt tests that actually catch bugs
Every dbt project ships with not_null and unique tests, and they catch almost nothing interesting in practice. Real data bugs are semantic: a revenue column that goes negative, an order_status that takes a value outside the agreed enum, a join key that fans out and silently inflates row counts. Those failures sail right through schema tests.
The tests that have actually caught production incidents for me are relationship checks across grain boundaries, accepted-values assertions on categorical fields, and custom row-count ratio tests that alert when a table shrinks by more than a few percent between runs. They take ten minutes to add and have saved hours of incident response.
Generic tests are table stakes
not_null and unique guard against the dumbest possible failures — a pipeline that drops every row still passes not_null. They belong in every schema.yml, but stop there and you are flying blind.
The surface area they miss:
- Referential integrity. A
fact_orders.customer_idthat references no row indim_customersis perfectly non-null and unique per order. - Cardinality explosions. A many-to-many join in an intermediate model that was meant to be one-to-many will inflate every downstream metric silently.
- Enum drift. A source system adds
"pending_review"toorder_statussix months after you shipped your mart; yourCASEstatement now has anELSE NULLclause firing on 4% of rows. - Stale sources. An upstream table stops refreshing; your incremental model merrily processes zero new rows and reports success.
Each of those is a real incident I have investigated. None were caught by not_null or unique.
Relationship and accepted_values tests
These two built-in tests cover most referential and categorical bugs with near-zero overhead.
# models/marts/schema.yml
version: 2
models:
- name: fact_orders
columns:
- name: customer_id
tests:
- not_null
- relationships:
to: ref('dim_customers')
field: customer_id
- name: order_status
tests:
- not_null
- accepted_values:
values: ["placed", "shipped", "delivered", "cancelled"]
severity: warn # warn first, promote to error after one sprint
- name: order_id
tests:
- not_null
- unique
The relationships test runs a query equivalent to:
SELECT count(*)
FROM {{ model }}
WHERE customer_id NOT IN (SELECT customer_id FROM {{ ref('dim_customers') }})
AND customer_id IS NOT NULL
It returns the count of orphaned keys. Zero orphaned keys is the invariant you are actually asserting.
accepted_values with severity: warn is the right posture when you first add a test to a mature column — you will almost certainly find a value you forgot. Graduate it to error once you have confirmed the exhaustive list.
Semantic tests: assert business invariants
Built-in tests cover structure. Business rules need custom singular tests — plain SQL files in tests/ that return rows only when the assertion fails.
Test: revenue is never negative
-- tests/assert_revenue_non_negative.sql
SELECT
order_id,
revenue_usd
FROM {{ ref('fact_orders') }}
WHERE revenue_usd < 0
Test: row count does not drop more than 5% day-over-day
-- tests/assert_orders_row_count_ratio.sql
WITH today AS (
SELECT count(*) AS n FROM {{ ref('fact_orders') }}
WHERE order_date = current_date
),
yesterday AS (
SELECT count(*) AS n FROM {{ ref('fact_orders') }}
WHERE order_date = current_date - 1
)
SELECT
today.n AS today_count,
yesterday.n AS yesterday_count,
round(today.n::numeric / nullif(yesterday.n, 0), 4) AS ratio
FROM today, yesterday
WHERE yesterday.n > 0
AND today.n::numeric / yesterday.n < 0.95
This query returns a row (and fails the test) only when today’s count is less than 95% of yesterday’s. Silent pipeline truncations — a bug I have hit three times in different warehouses — show up immediately.
Register these in schema.yml so they participate in dbt test --select:
# models/marts/schema.yml (continued)
- name: fact_orders
tests:
- assert_revenue_non_negative
- assert_orders_row_count_ratio
Source freshness and CI gating
Schema and singular tests tell you the data is structurally correct right now. Freshness tells you whether the pipeline that produced it is still running.
# models/sources.yml
sources:
- name: raw_orders
database: warehouse
schema: raw
freshness:
warn_after: {count: 6, period: hour}
error_after: {count: 24, period: hour}
loaded_at_field: _loaded_at
tables:
- name: orders
Run dbt source freshness before dbt build in CI. A stale source that feeds an incremental model will pass every schema test — zero new rows means zero constraint violations — while silently producing metrics that are 18 hours out of date.
CI pipeline order that actually works:
dbt source freshness— fail fast on stale inputsdbt build --select state:modified+— build and test changed models and their dependents- Block merge on any
ERRORseverity failure; allowWARNto pass with a Slack notification
The most expensive data incident I have seen was a dashboard that showed flat revenue for two days before anyone noticed the source table had stopped loading. A 30-second freshness check would have caught it on the first missed pipeline run.
Takeaway
not_null and unique are the floor. Add relationships tests at every FK boundary, accepted_values on every categorical column, singular tests for each business invariant, and freshness checks on every source. Budget two hours per mart model the first time. After that it is copy-paste from a template. The tests that seem like overkill are always the ones that fire on a Friday afternoon.
Mọi dự án dbt đều có sẵn test not_null và unique, và thực tế chúng hầu như không bắt được gì thú vị. Lỗi dữ liệu thực sự là về ngữ nghĩa: cột revenue âm, order_status nhận giá trị ngoài enum đã thỏa thuận, khoá join bị fan-out làm tăng số hàng âm thầm. Những lỗi đó đi qua schema test như không có gì.
Những test thực sự đã bắt được sự cố production cho tôi là kiểm tra mối quan hệ qua ranh giới grain, assertion accepted-values trên các trường phân loại, và test tỷ lệ số hàng tùy chỉnh cảnh báo khi bảng thu nhỏ hơn vài phần trăm giữa các lần chạy. Chúng mất mười phút để thêm vào và đã tiết kiệm hàng giờ xử lý sự cố.
Generic test chỉ là ngưỡng tối thiểu
not_null và unique bảo vệ khỏi những lỗi ngớ ngẩn nhất — một pipeline xóa hết toàn bộ hàng vẫn pass not_null. Chúng cần có trong mọi schema.yml, nhưng chỉ dừng ở đó là bạn đang bay mù.
Những vùng chúng bỏ qua:
- Toàn vẹn tham chiếu. Một
fact_orders.customer_idkhông tương ứng với bất kỳ hàng nào trongdim_customersvẫn hoàn toàn non-null và unique theo order. - Bùng nổ cardinality. Một join many-to-many trong intermediate model vốn được thiết kế là one-to-many sẽ thổi phồng mọi metric downstream một cách âm thầm.
- Enum drift. Hệ thống nguồn thêm
"pending_review"vàoorder_statussáu tháng sau khi bạn triển khai mart; câu lệnhCASEcủa bạn giờ có mệnh đềELSE NULLkích hoạt trên 4% số hàng. - Nguồn dữ liệu cũ. Một bảng upstream ngừng refresh; incremental model của bạn vui vẻ xử lý không có hàng mới nào và báo cáo thành công.
Mỗi trường hợp đó là sự cố thực tế tôi đã điều tra. Không cái nào bị not_null hay unique bắt được.
Test relationships và accepted_values
Hai test built-in này xử lý hầu hết lỗi tham chiếu và phân loại với chi phí gần như bằng không.
# models/marts/schema.yml
version: 2
models:
- name: fact_orders
columns:
- name: customer_id
tests:
- not_null
- relationships:
to: ref('dim_customers')
field: customer_id
- name: order_status
tests:
- not_null
- accepted_values:
values: ["placed", "shipped", "delivered", "cancelled"]
severity: warn # warn trước, nâng lên error sau một sprint
- name: order_id
tests:
- not_null
- unique
Test relationships chạy một query tương đương:
SELECT count(*)
FROM {{ model }}
WHERE customer_id NOT IN (SELECT customer_id FROM {{ ref('dim_customers') }})
AND customer_id IS NOT NULL
Nó trả về số lượng khóa mồ côi. Không có khóa mồ côi là bất biến bạn đang thực sự kiểm tra.
accepted_values với severity: warn là cách tiếp cận đúng khi bạn lần đầu thêm test vào một cột đã tồn tại lâu — gần như chắc chắn bạn sẽ tìm thấy một giá trị bị bỏ sót. Nâng lên error sau khi đã xác nhận danh sách đầy đủ.
Test ngữ nghĩa: kiểm tra bất biến nghiệp vụ
Các test built-in kiểm tra cấu trúc. Quy tắc nghiệp vụ cần singular test tùy chỉnh — file SQL thuần trong tests/ chỉ trả về hàng khi assertion thất bại.
Test: revenue không bao giờ âm
-- tests/assert_revenue_non_negative.sql
SELECT
order_id,
revenue_usd
FROM {{ ref('fact_orders') }}
WHERE revenue_usd < 0
Test: số hàng không giảm quá 5% theo ngày
-- tests/assert_orders_row_count_ratio.sql
WITH today AS (
SELECT count(*) AS n FROM {{ ref('fact_orders') }}
WHERE order_date = current_date
),
yesterday AS (
SELECT count(*) AS n FROM {{ ref('fact_orders') }}
WHERE order_date = current_date - 1
)
SELECT
today.n AS today_count,
yesterday.n AS yesterday_count,
round(today.n::numeric / nullif(yesterday.n, 0), 4) AS ratio
FROM today, yesterday
WHERE yesterday.n > 0
AND today.n::numeric / yesterday.n < 0.95
Query này trả về một hàng (và fail test) chỉ khi số hàng hôm nay nhỏ hơn 95% của hôm qua. Pipeline bị cắt ngầm — một lỗi tôi đã gặp ba lần ở các warehouse khác nhau — hiện ra ngay lập tức.
Đăng ký chúng trong schema.yml để chúng tham gia vào dbt test --select:
# models/marts/schema.yml (tiếp theo)
- name: fact_orders
tests:
- assert_revenue_non_negative
- assert_orders_row_count_ratio
Source freshness và CI gating
Schema test và singular test cho bạn biết dữ liệu có đúng cấu trúc ngay lúc này. Freshness cho bạn biết pipeline tạo ra nó có còn đang chạy không.
# models/sources.yml
sources:
- name: raw_orders
database: warehouse
schema: raw
freshness:
warn_after: {count: 6, period: hour}
error_after: {count: 24, period: hour}
loaded_at_field: _loaded_at
tables:
- name: orders
Chạy dbt source freshness trước dbt build trong CI. Một nguồn dữ liệu cũ feed vào incremental model sẽ pass mọi schema test — không có hàng mới nghĩa là không có vi phạm constraint — trong khi âm thầm tạo ra metric đã 18 giờ không cập nhật.
Thứ tự CI pipeline thực sự hiệu quả:
dbt source freshness— fail nhanh với input cũdbt build --select state:modified+— build và test các model thay đổi cùng downstream- Block merge với bất kỳ lỗi
ERRORseverity; cho phépWARNpass kèm thông báo Slack
Sự cố dữ liệu tốn kém nhất tôi từng thấy là một dashboard hiển thị doanh thu phẳng trong hai ngày trước khi ai đó nhận ra bảng nguồn đã ngừng load. Một freshness check 30 giây sẽ bắt được nó ngay ở lần chạy pipeline đầu tiên bị bỏ lỡ.
Kết luận
not_null và unique là sàn tối thiểu. Thêm test relationships tại mọi ranh giới FK, accepted_values trên mọi cột phân loại, singular test cho mỗi bất biến nghiệp vụ, và freshness check trên mọi nguồn. Dành hai giờ cho mỗi mart model lần đầu. Sau đó chỉ là copy-paste từ template. Những test có vẻ thừa thãi luôn là những test kích hoạt vào chiều thứ Sáu.