dbt 4 — Testing & documentation
Vì sao phải test dữ liệu
Ở các bài trước ta đã dựng được model và materialization rồi kéo dữ liệu vào qua sources & snapshots. Nhưng có một sự thật phũ phàng: dữ liệu luôn thay đổi, và mọi giả định bạn viết trong SQL sẽ sớm bị vỡ. Hôm nay cột khach_hang_id là khóa duy nhất; tuần sau upstream đổi logic và sinh ra bản ghi trùng. Hôm nay trang_thai chỉ có bốn giá trị; tháng sau team vận hành thêm giá trị refund_partial mà không báo ai. Hôm nay fct_don_hang.khach_hang_id luôn khớp với dim_khach_hang; ngày kia một đơn hàng mồ côi lọt vào.
Không có test, những lỗi này âm thầm chảy xuống tận dashboard BI. Sếp nhìn thấy con số sai, đặt câu hỏi, và đội data mất nửa ngày dò ngược từng bảng. Với test, dbt bắt lỗi ngay khi build, ở đúng model gây ra vấn đề, trước khi bất kỳ báo cáo nào bị nhiễm độc.
Triết lý của dbt là mang kỹ thuật phần mềm vào data. Trong phần mềm, code không có test không được merge. Trong dbt, định nghĩa một model chỉ thực sự "xong" khi nó có test và có tài liệu. Bài này đi qua toàn bộ hệ sinh thái đó: bốn loại đảm bảo chất lượng (generic test, singular test, package test, unit test) và tài liệu tự sinh kèm lineage graph.
Quy ước: sandbox của chúng ta là PostgreSQL và dbt không thực sự chạy ở đây, nên toàn bộ đoạn mã dưới đây là minh hoạ cú pháp dbt 1.x.
Generic data tests — bốn cột trụ
Generic test là test được khai báo bằng YAML, gắn trực tiếp lên cột hoặc model trong file .yml (thường đặt cạnh model, ví dụ models/marts/_marts.yml). Bản chất mỗi generic test là một câu SQL trả về các dòng vi phạm: nếu truy vấn trả về 0 dòng thì test pass, có từ 1 dòng trở lên thì test fail. dbt đóng gói sẵn bốn test cực kỳ thông dụng.
unique— cột không có giá trị trùng.not_null— cột không có giá trị null.accepted_values— mọi giá trị nằm trong danh sách cho phép.relationships— kiểm tra toàn vẹn tham chiếu (khóa ngoại): mọi giá trị của cột này phải tồn tại ở một cột của model khác.
# models/marts/_marts.yml (minh hoạ)
version: 2
models:
- name: fct_don_hang
description: "Bảng fact mức đơn hàng, hạt là một đơn hàng."
columns:
- name: don_hang_id
description: "Khóa chính của đơn hàng."
data_tests:
- unique
- not_null
- name: trang_thai
description: "Trạng thái vòng đời đơn hàng."
data_tests:
- accepted_values:
arguments:
values: ['placed', 'shipped', 'completed', 'returned']
- name: khach_hang_id
description: "Khóa ngoại trỏ tới dim_khach_hang."
data_tests:
- not_null
- relationships:
arguments:
to: ref('dim_khach_hang')
field: khach_hang_id
Bốn dòng đầu — unique + not_null trên khóa chính — là bộ test mà mọi model nên có, không có ngoại lệ. Chúng là hàng phòng thủ chống lại lỗi fan-out (join làm nhân đôi dòng) phổ biến nhất trong data warehouse.
Lưu ý cú pháp: dbt hiện khuyến nghị dùng key
data_tests:thay chotests:(bản cũ vẫn còn hiểu được để tương thích ngược). Cách truyền tham số quaarguments:là dạng chuẩn của các bản dbt gần đây; nhiều ví dụ cũ hơn viết tham số phẳng ngay dưới tên test.
Cấu hình test: severity, where, limit, store_failures
Test không phải chỉ "đỏ hay xanh". Mỗi test nhận thêm khối config để điều chỉnh hành vi.
severity:error(mặc định — fail dừng build) hoặcwarn(chỉ cảnh báo, build vẫn tiếp tục). Rất hữu ích cho các quy tắc "nên đúng nhưng chưa phải sống chết".error_if/warn_if: đặt ngưỡng số dòng vi phạm, ví dụ chấp nhận tối đa 5 dòng lỗi trước khi coi là error.where: chỉ test trên một tập con dữ liệu (ví dụ bỏ qua dữ liệu lịch sử đã biết là bẩn).limit: giới hạn số dòng vi phạm truy vấn kéo về (giảm tải khi debug).store_failures: lưu các dòng vi phạm vào một bảng riêng để soi lại thay vì chỉ đếm số dòng.
# minh hoạ cấu hình test
- name: email
data_tests:
- not_null:
config:
severity: warn # chỉ cảnh báo
where: "ngay_tao >= '2024-01-01'"
- unique:
config:
error_if: ">10" # coi là error nếu >10 dòng trùng
warn_if: ">0"
store_failures: true # lưu dòng lỗi vào bảng audit
Khi bật store_failures, các dòng vi phạm được ghi vào schema dbt_test__audit (đặt tên được), giúp bạn select xem chính xác bản ghi nào sai — thay vì chỉ biết "có 37 dòng lỗi".
Singular tests — logic tùy biến
Bốn generic test bao phủ các quy tắc phổ biến, nhưng nhiều quy tắc nghiệp vụ quá đặc thù để nhét vào một tham số YAML. Ví dụ: "tổng tiền hoàn không được vượt tổng tiền đơn", "mỗi khách hàng chỉ được có tối đa một đơn pending tại một thời điểm". Đây là đất của singular test.
Singular test là một file .sql đặt trong thư mục tests/. Quy tắc giống hệt generic test: câu SELECT trả về các dòng VI PHẠM; test fail nếu có bất kỳ dòng nào. Khác biệt là bạn tự do viết SQL tùy ý, dùng ref()/source(), join nhiều bảng, group by, having…
-- tests/assert_tien_hoan_khong_am.sql (minh hoạ)
-- Trả về các đơn có tổng thanh toán âm -> test fail nếu tồn tại
select
don_hang_id,
sum(so_tien) as tong_tien
from {{ ref('fct_thanh_toan') }}
group by don_hang_id
having sum(so_tien) < 0
Chú ý: không đặt dấu ; ở cuối, vì dbt sẽ bọc câu này vào một truy vấn lớn hơn để đếm dòng. Bạn có thể mô tả singular test trong YAML dưới key data_tests: để nó xuất hiện trong tài liệu, và cấu hình severity/store_failures tương tự generic test.
Package tests — dbt_utils và dbt_expectations
Không cần tự viết mọi thứ. Cộng đồng dbt đóng gói sẵn nhiều generic test qua package (khai báo trong packages.yml rồi dbt deps).
dbt_utils là package "dao Thụy Sĩ" với nhiều test đắt giá:
expression_is_true— kiểm tra một biểu thức SQL đúng trên mọi dòng (ví dụso_tien_sau_thue >= so_tien_truoc_thue).unique_combination_of_columns— tổ hợp nhiều cột là duy nhất (khóa chính ghép — điềuuniqueđơn cột không làm được).accepted_range— giá trị nằm trong khoảng min/max.not_null_proportion— tỷ lệ non-null đạt ngưỡng (chấp nhận một chút null).mutually_exclusive_ranges,relationships_where,recency,at_least_one…
# minh hoạ dbt_utils
models:
- name: fct_doanh_thu_thang
data_tests:
- dbt_utils.unique_combination_of_columns:
arguments:
combination_of_columns:
- thang
- san_pham_id
- dbt_utils.expression_is_true:
arguments:
expression: "doanh_thu = so_luong * don_gia"
dbt_expectations mang phong cách của Great Expectations vào dbt: các test biểu đạt kỳ vọng thống kê/phân phối (giá trị khớp regex, độ dài chuỗi trong khoảng, phân vị nằm trong ngưỡng…). Dùng khi bạn cần kiểm soát chất lượng ở mức khắt khe hơn là "null hay không null".
Unit tests — test LOGIC, không phải dữ liệu
Đây là điểm dễ nhầm nhất. Tất cả các test ở trên là data test: chúng chạy trên dữ liệu thật trong warehouse sau khi model đã build, và trả lời câu hỏi "dữ liệu hiện tại có sạch không?".
Unit test (có từ dbt 1.8) trả lời một câu hỏi hoàn toàn khác: "logic biến đổi của tôi có đúng không?". Bạn cấp cho model một tập input cố định, nhỏ, tự tạo (mock) và khai báo output kỳ vọng; dbt chạy logic SQL của model trên input đó và so sánh kết quả. Không cần dữ liệu thật, chạy trước khi materialize.
Unit test cực kỳ đáng giá cho logic phức tạp: regex kiểm email, phép tính ngày tháng, case when nhiều nhánh, window function, phân loại tầng khách hàng… Đó là những chỗ dễ sai âm thầm và data test không bắt được (vì data test chỉ kiểm dữ liệu đầu ra, không biết logic đáng lẽ phải ra gì).
# models/marts/_marts.yml (minh hoạ, dbt 1.8+)
unit_tests:
- name: test_phan_loai_email_hop_le
model: dim_khach_hang
given:
- input: ref('stg_khach_hang')
format: dict
rows:
- {email: "[email protected]", tld: "example.com"}
- {email: "[email protected]", tld: "unknown.com"}
- {email: "khonghople", tld: null}
- input: ref('top_level_domains')
format: dict
rows:
- {tld: "example.com"}
expect:
format: dict
rows:
- {email: "[email protected]", email_hop_le: true}
- {email: "[email protected]", email_hop_le: false}
- {email: "khonghople", email_hop_le: false}
given liệt kê input mock cho từng model thượng nguồn (chỉ cần các cột liên quan), expect là kết quả mong đợi. Format có thể là dict, csv hoặc sql. Chạy bằng dbt test --select test_phan_loai_email_hop_le.
Chạy test: dbt test và sức mạnh của dbt build
Có hai cách chính để thực thi test.
dbt test chạy toàn bộ test (data + unit) sau khi model đã tồn tại. Bạn có thể chọn lọc bằng cú pháp selector:
dbt test # tất cả test
dbt test --select fct_don_hang # test của một model
dbt test --select source:shop.orders # test của một source
dbt test --select "test_type:data" # chỉ data test
dbt test --select "test_type:unit" # chỉ unit test
Nhưng lệnh mạnh nhất là dbt build. Thay vì chạy hết model rồi mới test (như dbt run + dbt test), dbt build đi theo thứ tự DAG và xen kẽ run + test cho từng node: build model A → test A → nếu A đạt mới build model B phụ thuộc A → test B… Nếu test của A fail, mọi node hạ nguồn phụ thuộc A bị bỏ qua (skip), không lãng phí compute và quan trọng hơn là không để dữ liệu bẩn lan xuống.
dbt build cũng gộp cả seed và snapshot vào cùng trật tự DAG, nên trong môi trường CI/CD nó thường là lệnh duy nhất bạn cần (xem thêm ở bài Triển khai & CI).
Documentation — tài liệu tự sinh
Chất lượng dữ liệu chỉ có ích khi người khác hiểu dữ liệu là gì. dbt biến tài liệu thành một sản phẩm phụ gần như miễn phí: bạn đã khai báo model trong YAML để test rồi, chỉ cần thêm description.
# minh hoạ mô tả model & cột
models:
- name: fct_don_hang
description: >
Bảng fact mức đơn hàng. Mỗi dòng là một đơn hàng đã được làm sạch
và chuẩn hóa từ nguồn shop.orders. Đã loại đơn test nội bộ.
columns:
- name: don_hang_id
description: "Khóa chính. Duy nhất, không null."
- name: so_tien_vnd
description: "Tổng giá trị đơn, đơn vị VND (đã chia 100 từ nguồn cent)."
Sau đó:
dbt docs generate # sinh catalog + manifest (metadata model, cột, kiểu dữ liệu)
dbt docs serve # mở trang web tài liệu ở local
Trang docs bao gồm: mô tả từng model/cột, kiểu dữ liệu lấy tự động từ warehouse (kể cả cột bạn quên tài liệu vẫn hiện ra), câu SQL gốc và SQL đã compile, và — quan trọng nhất — lineage graph (DAG): một đồ thị tương tác cho thấy model này lấy dữ liệu từ đâu và feed cho ai.
Doc blocks — tránh lặp mô tả
Khi một đoạn mô tả (ví dụ định nghĩa "khách hàng active") được dùng ở nhiều model, đừng copy-paste. Định nghĩa doc block một lần trong file .md rồi tham chiếu bằng hàm doc().
{% docs dinh_nghia_khach_active %}
Khách hàng "active" là khách có ít nhất một đơn `completed`
trong vòng 90 ngày gần nhất tính đến ngày chạy.
{% enddocs %}
# tham chiếu doc block
- name: la_khach_active
description: '{{ doc("dinh_nghia_khach_active") }}'
Doc block hỗ trợ đầy đủ Markdown, và có block đặc biệt __overview__ để tùy biến trang chủ tài liệu của dự án.
Exposures — lineage kéo dài tới tận báo cáo
Lineage của dbt mặc định dừng ở model cuối cùng. Nhưng thực tế dữ liệu còn chảy tiếp ra dashboard Metabase, notebook, ứng dụng ML… Exposure cho phép khai báo các "đầu ra downstream" đó vào ngay trong dbt, để lineage graph nối tới tận báo cáo — và để bạn biết "nếu sửa fct_don_hang thì dashboard nào bị ảnh hưởng".
# models/_exposures.yml (minh hoạ)
exposures:
- name: dashboard_doanh_thu_tuan
label: "Doanh thu theo tuần"
type: dashboard # dashboard | notebook | analysis | ml | application
maturity: high # high | medium | low
url: https://bi.congty.vn/dashboards/12
description: "Dashboard doanh thu tuần cho ban lãnh đạo."
depends_on:
- ref('fct_don_hang')
- ref('dim_khach_hang')
owner:
name: Phong Data
email: [email protected]
Exposure hiện thành node riêng trong DAG và cho phép chọn lọc: dbt build -s +exposure:dashboard_doanh_thu_tuan sẽ build + test đúng những model mà dashboard đó phụ thuộc.
Use case thực tế — bộ test tối thiểu cho lớp marts
Khi lên production, đừng cầu toàn test mọi cột. Áp dụng bộ test tối thiểu sau cho từng loại bảng ở lớp marts thì đã chặn được đại đa số sự cố:
- Mọi model —
unique+not_nulltrên khóa chính (dù là khóa đơn hay dùngdbt_utils.unique_combination_of_columnscho khóa ghép). Đây là điều bắt buộc. - Bảng dimension — thêm
accepted_valuescho các cột phân loại (status, loại, vùng miền) để bắt giá trị lạ;not_nullcho các thuộc tính bắt buộc. - Bảng fact —
relationshipstừ mọi khóa ngoại về đúng dimension tương ứng (fact → dim). Đây là cách bắt lỗi mồ côi và fan-out. Thêm singular/expression_is_truecho các bất biến nghiệp vụ (tiền không âm, tổng con = tổng cha). - Ở lớp source — cấu hình
freshnessđể phát hiện dữ liệu nguồn ngừng cập nhật (xem Sources & snapshots), cộngunique/not_nulltrên khóa nghiệp vụ của nguồn. - Logic phức tạp — bổ sung unit test cho các model có
case whennhiều nhánh, regex, hay phép tính ngày, để bắt lỗi logic khi refactor. Macro tái sử dụng thì kiểm bằng cách này kết hợp với Jinja & macros. - Tài liệu —
descriptioncho model và ít nhất các cột quan trọng; khai báoexposurecho mỗi dashboard chính thức.
Trong CI, dùng đúng một lệnh dbt build để run + test theo DAG và dừng sớm khi có lỗi; đặt store_failures: true cho các test hay fail để debug nhanh.
Ghi nhớ
- Test trong dbt là câu SQL trả về dòng vi phạm: 0 dòng = pass. Nguyên lý này đúng cho cả generic lẫn singular test.
- Bốn generic test dựng sẵn:
unique,not_null,accepted_values,relationships. Bộunique + not_nulltrên khóa chính là bắt buộc với mọi model. - Singular test = file
.sqltrongtests/cho logic tùy biến; package test (dbt_utils,dbt_expectations) tiết kiệm công viết lại. - Data test kiểm dữ liệu thật sau build; unit test (1.8+) kiểm logic với input/expected mock trước build — hai việc khác nhau, dùng cả hai.
- Cấu hình test qua
config:severity(warn/error),error_if/warn_if,where,limit,store_failures. dbt build>runrồitest: nó xen kẽ run + test theo DAG và skip node hạ nguồn khi test fail, chặn dữ liệu bẩn lan xuống BI.- Tài liệu gần như miễn phí: thêm
descriptionvào YAML,dbt docs generate+dbt docs servecho ra trang docs + lineage graph; dùng doc blocks để DRY và exposures để kéo lineage tới tận dashboard. - Coi test và tài liệu là một phần của định nghĩa model — model chưa có test và mô tả thì chưa "xong".
Bài viết liên quan
CSV/JSON/Parquet/Avro, lưu trữ theo hàng vs cột, nén, và OLTP vs OLAP.
Data Engineering là gì, vai trò trong vòng đời dữ liệu, và bức tranh hệ sinh thái công cụ.
Vì sao xử lý phân tán, mô hình Spark (RDD/DataFrame), lazy evaluation, shuffle và tối ưu.
Lab: dựng Kafka + Kafka Connect + Debezium, cấu hình Oracle connector (LogMiner), snapshot & streaming, đọc message CDC.