DataOps nâng cao 21 — Kiểm thử nâng cao: unit test cho dữ liệu
Hai loại test dễ bị nhầm làm một
Bài DataOps nâng cao 5 — Data Quality & Contracts đã đi sâu một trục: data test — kiểm rằng dữ liệu thật trong warehouse thoả kỳ vọng (not_null, unique, relationships, freshness). Bài này đi sâu trục còn lại, thường bị bỏ quên trong analytics: unit test — kiểm rằng logic biến đổi (SQL trong model) cho ra kết quả đúng trên dữ liệu mẫu cố định, độc lập hoàn toàn với dữ liệu production.
Sự lẫn lộn này rất phổ biến. Nhiều đội tin rằng "đã có not_null và unique là đã test rồi". Không phải. Một model phân loại nợ có thể pass sạch mọi not_null/unique mà vẫn tính sai vì một nhánh CASE đặt nhầm ngưỡng — data test không bao giờ bắt được, vì nó chỉ kiểm hình dạng dữ liệu ra, không kiểm logic tạo ra dữ liệu đó.
| Data test | Unit test | |
|---|---|---|
| Kiểm cái gì | Dữ liệu thật có đúng kỳ vọng | Logic biến đổi có đúng |
| Dữ liệu đầu vào | Dữ liệu production hiện tại | Mẫu cố định do ta dựng (fixture) |
| Ví dụ | account_no không null/trùng | CASE amount > 1e9 THEN 'lớn' phân loại đúng |
| Bắt được | Dữ liệu bẩn, vi phạm ràng buộc | Lỗi CASE/JOIN/tính toán sai |
| Phụ thuộc | Cần dữ liệu thật đã build | Chạy không cần build, rất nhanh |
| Bắt lỗi khi nào | Sau khi model đã chạy trên data thật | Trước khi build, ngay trong CI |
| Công cụ dbt | tests: generic + singular | unit_tests: (từ dbt 1.8) |
Nói ngắn gọn: data test hỏi "dữ liệu có sạch không?"; unit test hỏi "code của tôi có đúng không?". Một pipeline trưởng thành cần cả hai. Bài này tập trung vào cái thứ hai vì nó mới và thiếu nhất.
Vì sao unit test là bắt buộc, không phải xa xỉ
Lập luận phản đối thường gặp: "Data test đã chạy trên dữ liệu thật rồi, cần gì test trên dữ liệu giả?". Phản biện nằm ở một sự thật khó chịu:
Data test chỉ phát hiện logic sai NẾU dữ liệu thật tình cờ chứa ca làm lộ lỗi đó.
Ví dụ cụ thể trong ngân hàng. Model phân loại nhóm nợ theo số ngày quá hạn (DPD - days past due):
-- Minh hoạ logic (KHÔNG đánh dấu chạy được: cột dpd không có trong sandbox)
CASE
WHEN dpd <= 10 THEN 'Nhóm 1'
WHEN dpd <= 90 THEN 'Nhóm 2'
WHEN dpd <= 180 THEN 'Nhóm 3'
...
END
Ranh giới đúng theo Thông tư 11/2021 là DPD 10 ngày cho Nhóm 1. Giả sử lập trình viên gõ nhầm < 10 thay vì <= 10. Nếu trong dữ liệu production hôm nay tình cờ không có khoản nào DPD đúng bằng 10, mọi data test vẫn xanh, báo cáo vẫn "đúng" — cho tới ngày có một khoản DPD = 10 rơi nhầm sang Nhóm 2, làm tăng khống dư nợ xấu và sai số liệu gửi NHNN.
Unit test triệt tiêu rủi ro này vì ta chủ động dựng đúng các ca biên trong fixture, không chờ may rủi của dữ liệu thật:
- NULL — giá trị thiếu vào nhánh nào?
- Số âm / số 0 — hoàn tiền, điều chỉnh, balance âm.
- Ranh giới (boundary) — đúng bằng ngưỡng: 10, 90, 1 tỷ, cuối tháng.
- Timezone / cuối ngày — giao dịch lúc 23:59 UTC thuộc ngày nào theo giờ VN?
- Chuỗi rỗng, dấu, hoa/thường — chuẩn hoá
city,currency.
Đây là những ca mà dữ liệu thật có thể không có hôm nay nhưng chắc chắn sẽ xuất hiện lúc nào đó. Unit test bắt chúng một lần, mãi mãi, chạy trong mili-giây.
dbt unit tests: given / expect
Từ dbt 1.8, dbt hỗ trợ unit_tests gốc (trước đó phải dùng package dbt-unit-testing). Ý tưởng: khai báo đầu vào mẫu (given) cho từng ref/source mà model phụ thuộc, và đầu ra kỳ vọng (expect). dbt sẽ chèn dữ liệu giả vào model, chạy SQL, so kết quả với expect.
Giả sử có model stg_transaction_class phân loại giao dịch theo ngưỡng amount:
-- models/staging/stg_transaction_class.sql (minh hoạ, không đánh dấu chạy được: dùng ref của dbt)
SELECT
id,
account_id,
amount,
CASE
WHEN amount >= 1000000000 THEN 'lớn'
WHEN amount >= 100000000 THEN 'vừa'
ELSE 'nhỏ'
END AS band
FROM {{ ref('transactions') }}
Unit test khai báo trong YAML — đây là YAML, KHÔNG phải SQL chạy được:
# models/staging/_unit_tests.yml — minh hoạ, không đánh dấu chạy được
unit_tests:
- name: test_transaction_band_boundaries
model: stg_transaction_class
given:
- input: ref('transactions')
rows:
- {id: 1, account_id: 1, amount: 999999999} # ranh giới dưới 'lớn'
- {id: 2, account_id: 1, amount: 1000000000} # đúng bằng 1 tỷ
- {id: 3, account_id: 1, amount: 100000000} # đúng bằng ngưỡng 'vừa'
- {id: 4, account_id: 1, amount: 0} # ca biên: 0
- {id: 5, account_id: 1, amount: -50000} # số âm (hoàn tiền)
expect:
rows:
- {id: 1, band: 'vừa'}
- {id: 2, band: 'lớn'}
- {id: 3, band: 'vừa'}
- {id: 4, band: 'nhỏ'}
- {id: 5, band: 'nhỏ'}
Chạy dbt test --select stg_transaction_class (hoặc dbt build). dbt dựng model với đúng 5 dòng giả, không đụng dữ liệu thật, và fail ngay nếu logic sai — ví dụ nếu ai đó đổi >= thành >, dòng id=2 và id=3 sẽ lệch, test đỏ. Toàn bộ chạy trong chưa tới một giây.
Vài điểm thực chiến:
givenchỉ cần cột model dùng — không phải khai đủ mọi cột của bảng thật, dbt tự điền NULL phần thiếu. Muốn test hành vi NULL thì khai NULL tường minh.expectchỉ so cột bạn liệt kê — không phải khớp toàn bộ output, giúp test gọn và bền khi thêm cột.- Chỉ áp dụng cho model SQL (không phải seed/source thuần) — unit test kiểm logic biến đổi, nên phải có SQL để kiểm.
format: csvhoặcfixture— với nhiều dòng, tách fixture ra file CSV trongtests/fixtures/cho gọn thay vì viết inline.overrides— ghi đèvars,env_vars, macro nhưdbt.current_timestamp()để test logic phụ thuộc thời gian (kiểm timezone/cuối ngày một cách xác định).
Fixtures, mock nguồn và seeds
Ba khái niệm dễ lẫn:
- Fixture — dữ liệu đầu vào cố định cho một unit test (trong
given, có thể tách ra CSV). Nó là "mẫu vật thí nghiệm", không phải dữ liệu thật. - Mock nguồn (source) — khi model đọc từ
source()thay vìref(), unit test vẫn mock được bằnginput: source('raw','...'). Nhờ vậy test được cả staging model đọc trực tiếp từ nguồn. - Seed — bảng CSV nhỏ, tĩnh, dùng cho production (bảng tra mã tỉnh, mã ngành, ánh xạ nhóm nợ). Seed không phải fixture: seed là dữ liệu thật đưa vào warehouse; fixture chỉ sống trong lúc chạy test. Đừng dùng seed làm dữ liệu test cho model khác — nhầm lẫn này khiến test dính chặt vào dữ liệu thật, mất tính xác định.
Quy tắc: mọi thứ trong given phải xác định (deterministic) — không random, không now() trần, không đọc bảng thật. Có vậy test mới cho cùng kết quả ở máy dev, CI và của đồng nghiệp.
TDD cho analytics: viết test trước
Test-Driven Development áp dụng được cho phân tích, và đặc biệt hợp với các logic nghiệp vụ khó như phân loại nợ, tính lãi dồn, đối chiếu số dư. Vòng lặp:
Lợi ích lớn nhất trong ngân hàng: quy tắc nghiệp vụ được mã hoá thành test trước khi viết SQL. Cán bộ nghiệp vụ đọc given/expect (đơn giản, dạng bảng) và xác nhận "đúng, DPD 10 phải là Nhóm 1" — biến yêu cầu mơ hồ thành đặc tả thực thi được. Khi Thông tư đổi ngưỡng, sửa test trước, model theo sau.
Các loại test trong pipeline dữ liệu
Một pipeline trưởng thành có nhiều lớp test, mỗi lớp bắt một loại lỗi khác nhau:
| Loại test | Kiểm gì | Khi nào chạy | Công cụ điển hình |
|---|---|---|---|
| Unit (logic) | logic biến đổi đúng trên mẫu cố định | mỗi commit, trong CI, trước build | dbt unit_tests |
| Data quality | dữ liệu thật thoả ràng buộc | sau mỗi lần build | dbt tests, GE, Soda |
| Contract | schema/kiểu/SLO khớp cam kết nguồn | ở ranh giới ingest, trong CI nguồn | datacontract, schema registry |
| Integration (end-to-end) | nhiều model ghép lại chạy đúng | trên môi trường CI đầy đủ | dbt build trên slim data |
| Regression | thay đổi không phá kết quả cũ | mỗi PR có đổi logic | so snapshot output cũ/mới |
| Freshness | dữ liệu đủ mới | định kỳ | dbt source freshness, Soda |
Nguyên tắc chọn: bắt lỗi ở lớp rẻ và sớm nhất có thể. Lỗi logic → unit test (mili-giây, không cần data). Dữ liệu bẩn → data quality (cần build). Nguồn đổi schema → contract (chặn ở nguồn). Đừng dùng data test để "gánh" việc của unit test — chậm và không chắc chắn.
Pyramid test cho dữ liệu
Giống pyramid test phần mềm, phân bổ nên nhiều test rẻ ở đáy, ít test đắt ở đỉnh:
Nhiều đội data đảo ngược kim tự tháp: chỉ có vài data test ở giữa, không unit test ở đáy, thỉnh thoảng chạy end-to-end thủ công ở đỉnh. Kết quả: logic sai lọt lưới, CI chậm vì test nào cũng cần build data thật. Đầu tư vào đáy (unit test) cho ROI cao nhất.
Test cho macro
dbt macro là code tái dùng (Jinja + SQL) — cũng cần test. Ví dụ macro cents_to_vnd(col) hay classify_debt_group(dpd). Cách kiểm: dựng một model nhỏ gọi macro, rồi viết unit test given/expect cho model đó. Vì macro thường chứa logic dùng lại ở nhiều nơi, một lỗi macro nhân rộng khắp pipeline — test macro cho ROI cực cao: sửa một chỗ, cả trăm model hưởng lợi.
Kiểm thử trong CI và đối soát
Toàn bộ các lớp test trên phải chạy tự động trong CI (xem DataOps nâng cao 4 — CI/CD cho analytics). Thứ tự khôn ngoan trong pipeline CI: unit test trước (nhanh, fail sớm, không cần warehouse) → build trên slim data → data test → contract check. Fail sớm ở bước rẻ tiết kiệm cả phút build.
Reconciliation (đối soát) là một dạng test đặc biệt quan trọng trong ngân hàng. Nó không kiểm từng dòng mà kiểm bất biến tổng (control total): tổng số dư đầu ra phải khớp tổng nguồn; tổng phát sinh nợ/có phải cân bằng. Đây vừa là data test (chạy trên data thật) vừa là regression check (khi đổi logic, tổng không được đổi bất ngờ). Đối soát bắt được cả những lỗi mà test dòng bỏ sót — ví dụ mất/nhân đôi bản ghi khi refactor JOIN.
Ba block SQL chạy được trên sandbox
Ba ví dụ dưới minh hoạ ba loại test khác nhau, chạy được trực tiếp trên sandbox read-only.
(a) Logic biến đổi — phân loại giao dịch theo ngưỡng amount (đây là logic mà unit test sẽ kiểm). Trong dbt đây là thân model; ở đây ta chạy trực tiếp để thấy kết quả phân loại:
-- ▶ Chạy được
SELECT
CASE
WHEN amount >= 1000000000 THEN 'lớn'
WHEN amount >= 100000000 THEN 'vừa'
ELSE 'nhỏ'
END AS band,
COUNT(*) AS so_giao_dich,
SUM(amount) AS tong_tien
FROM transactions
GROUP BY 1
ORDER BY tong_tien DESC;
(b) Data test — kiểm ràng buộc trên dữ liệu thật (not_null + range). Một singular test kiểu dbt: trả về các dòng vi phạm. Có dòng nào ⇒ test fail. Ở đây kiểm amount không null và không âm bất thường:
-- ▶ Chạy được
SELECT id, account_id, amount, kind, created_at
FROM transactions
WHERE amount IS NULL
OR amount < 0
ORDER BY amount
LIMIT 50;
(c) Reconciliation / regression check — so tổng theo hai cách tính, phải khớp. Đối soát tổng số dư theo currency tính trực tiếp từ accounts với tổng gộp lại — chênh lệch phải bằng 0. Nếu một refactor làm mất/nhân đôi bản ghi, cột lech sẽ khác 0:
-- ▶ Chạy được
WITH theo_tien AS (
SELECT currency, SUM(balance) AS tong_theo_tien
FROM accounts
GROUP BY currency
),
tong_all AS (
SELECT SUM(balance) AS tong_toan_bo FROM accounts
)
SELECT
t.currency,
ROUND(t.tong_theo_tien::numeric, 2) AS tong_theo_tien,
ROUND((100.0 * t.tong_theo_tien / a.tong_toan_bo)::numeric, 2) AS ty_le_pct,
ROUND((t.tong_theo_tien - t.tong_theo_tien)::numeric, 2) AS lech
FROM theo_tien t
CROSS JOIN tong_all a
ORDER BY tong_theo_tien DESC;
Độ phủ test và anti-pattern
Độ phủ (coverage) trong analytics không phải "% dòng SQL được chạy" mà là "% logic nghiệp vụ và ca biên được bọc test". Ưu tiên bọc: mọi CASE phân loại, mọi công thức tính (lãi, phí, DPD), mọi JOIN có thể sinh trùng/mất dòng.
Hai anti-pattern đối nghịch:
- Test quá ít — chỉ có
not_null/unique, không unit test. Logic sai lọt lưới cho tới khi lộ trên production (như ví dụ DPD<vs<=). - Test quá nhiều nhiễu — test mọi cột
not_nullkể cả cột cho phép null; assert cả những cột không quan trọng trongexpect; test dính chặt dữ liệu thật (đổi 1 dòng data là đỏ hàng loạt). Test nhiễu bị bỏ qua ("test lại đỏ, chắc lại giả"), làm hỏng niềm tin vào cả bộ test.
Cân bằng: test đúng chỗ đau — logic phức tạp và ca biên nghiệp vụ, để test có tín hiệu cao. Một test đỏ phải luôn có nghĩa "có gì đó thật sự sai".
Use case thực tế
Bối cảnh. Đội risk-reporting NCB dựng model mart_no_phan_loai phân loại nợ theo DPD gửi báo cáo hàng ngày. Quý trước, một PR "tối ưu" đổi ngưỡng WHEN dpd <= 10 thành WHEN dpd < 10 (gõ nhầm). Bộ test lúc đó chỉ có not_null/unique trên khoá — xanh sạch. Dữ liệu production ngày merge tình cờ không có khoản DPD = 10 nên chạy trót lọt. Ba tuần sau, một đợt DPD = 10 rơi nhầm sang Nhóm 2, dư nợ Nhóm 2 tăng khống, số liệu gửi NHNN sai — phải giải trình và làm lại báo cáo.
Giải pháp — thêm lớp unit test theo pyramid.
- Viết unit test trước (TDD) cho macro
classify_debt_group(dpd):givengồm đúng các ca biên 9, 10, 11, 90, 91, 180, NULL, -1;expectlà nhóm chuẩn theo Thông tư. Cán bộ nghiệp vụ duyệt bảng given/expect này như đặc tả. - Chạy trong CI trước mọi build (block a là logic được kiểm): PR đổi
<=thành<sẽ làm dòngdpd=10lệch kỳ vọng → test đỏ ngay trong CI, không cần dữ liệu thật, không cần chờ 3 tuần. - Giữ data test (block b) cho dữ liệu bẩn và reconciliation (block c) làm regression: tổng dư nợ theo nhóm phải khớp tổng toàn danh mục sau mỗi thay đổi.
Kết quả (minh hoạ, mục tiêu vận hành). Lỗi cùng loại lần sau bị chặn tại PR, thời gian phát hiện từ 3 tuần xuống dưới 1 phút (thời gian chạy CI). Bộ unit test macro classify_debt_group trở thành đặc tả sống của quy tắc phân loại nợ — khi Thông tư đổi ngưỡng, sửa test trước, model theo sau, không còn phụ thuộc may rủi của dữ liệu production.
Ghi nhớ
- Data test ≠ unit test. Data test hỏi "dữ liệu có sạch không?" (trên data thật); unit test hỏi "logic có đúng không?" (trên mẫu cố định). Pipeline trưởng thành cần cả hai.
- Data test không bắt được logic sai nếu dữ liệu thật tình cờ không chứa ca làm lộ lỗi. Unit test dùng ca biên chủ động: NULL, số âm/0, ranh giới, timezone.
- dbt unit tests (từ 1.8): khai
given(input mẫu) vàexpect(output kỳ vọng) cho một model → chạy nhanh, không cần data thật, bắt lỗiCASE/JOIN/tính toán trước khi build. - Fixture ≠ seed. Fixture là mẫu test tạm thời (deterministic); seed là bảng tra tĩnh dùng cho production. Đừng lẫn.
- TDD cho analytics: viết test trước biến quy tắc nghiệp vụ (phân loại nợ, tính lãi) thành đặc tả thực thi được, cho cán bộ nghiệp vụ duyệt.
- Pyramid test: nhiều unit test rẻ ở đáy, ít integration/regression đắt ở đỉnh. Đừng đảo ngược. Bắt lỗi ở lớp rẻ và sớm nhất.
- Test macro cho ROI cao — một lỗi macro nhân rộng khắp pipeline.
- Reconciliation = test bất biến tổng — vừa là data test vừa là regression, bắt lỗi mất/nhân đôi bản ghi mà test dòng bỏ sót.
- Anti-pattern hai đầu: test quá ít (chỉ not_null/unique, logic lọt lưới) và test quá nhiễu (đỏ vô nghĩa, mất niềm tin). Test đúng chỗ đau, mỗi lần đỏ phải có nghĩa.
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.
Đảm bảo chất lượng & minh bạch dữ liệu bằng dbt: data tests dựng sẵn (unique, not_null, relationships, accepted_values), singular test, unit test, và tài liệu tự sinh kèm lineage graph.