DataOps nâng cao 21 — Kiểm thử nâng cao: unit test cho dữ liệu

13 thg 7, 2026 2 lượt xem
#testing
#data-engineering
#dataops
#dbt-unit-tests
#tdd

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_nullunique 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 testUnit test
Kiểm cái gìDữ liệu thật có đúng kỳ vọngLogic biến đổi có đúng
Dữ liệu đầu vàoDữ liệu production hiện tạiMẫu cố định do ta dựng (fixture)
Ví dụaccount_no không null/trùngCASE amount > 1e9 THEN 'lớn' phân loại đúng
Bắt đượcDữ liệu bẩn, vi phạm ràng buộcLỗi CASE/JOIN/tính toán sai
Phụ thuộcCần dữ liệu thật đã buildChạy không cần build, rất nhanh
Bắt lỗi khi nàoSau khi model đã chạy trên data thậtTrước khi build, ngay trong CI
Công cụ dbttests: generic + singularunit_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:

  • given chỉ 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.
  • expect chỉ 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: csv hoặc fixture — với nhiều dòng, tách fixture ra file CSV trong tests/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ằng input: 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 testKiểm gìKhi nào chạyCông cụ điển hình
Unit (logic)logic biến đổi đúng trên mẫu cố địnhmỗi commit, trong CI, trước builddbt unit_tests
Data qualitydữ liệu thật thoả ràng buộcsau mỗi lần builddbt tests, GE, Soda
Contractschema/kiểu/SLO khớp cam kết nguồnở ranh giới ingest, trong CI nguồndatacontract, schema registry
Integration (end-to-end)nhiều model ghép lại chạy đúngtrên môi trường CI đầy đủdbt build trên slim data
Regressionthay đổi không phá kết quả cũmỗi PR có đổi logicso snapshot output cũ/mới
Freshnessdữ 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_null kể cả cột cho phép null; assert cả những cột không quan trọng trong expect; 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.

  1. Viết unit test trước (TDD) cho macro classify_debt_group(dpd): given gồm đúng các ca biên 9, 10, 11, 90, 91, 180, NULL, -1; expect là 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ả.
  2. Chạy trong CI trước mọi build (block a là logic được kiểm): PR đổi <= thành < sẽ làm dòng dpd=10 lệ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.
  3. 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ỗi CASE/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.

13 thg 7, 2026 8

Data Engineering là gì, vai trò trong vòng đời dữ liệu, và bức tranh hệ sinh thái công cụ.

13 thg 7, 2026 5

Vì sao xử lý phân tán, mô hình Spark (RDD/DataFrame), lazy evaluation, shuffle và tối ưu.

13 thg 7, 2026 5

Đả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.

13 thg 7, 2026 5