Chapter Model Staging, Intermediate, dan Marts dan Snapshots telah membangun rangkaian model dbt TokoKita. Tabel yang terbentuk rapi belum berarti isinya benar: satu double-load bisa menggandakan GMV, satu status berkapital bisa lolos dari filter laporan, satu pelanggan tak dikenal bisa membuat join kehilangan baris. dbt menyediakan lapisan pertahanan khusus untuk kejadian seperti ini: dbt test, asersi yang dijalankan langsung pada data di dalam model. Chapter ini membedah tiga bentuknya — generic, singular, dan custom generic — lalu aturan severity dan momen menjalankannya.
Posisi dbt Test pada Peta Pengujian #
Section Data Engineering Testing sudah membangun metodologi umum: Unit Testing menguji logika fungsi transform dengan pytest, Integration Testing menguji sambungan antar stage, dan Pipeline Testing menyusun keduanya menjadi piramida. dbt test mengisi kotak yang berbeda: objek yang diuji adalah data di dalam model, bukan kode. Posisinya juga berbeda dari Data Validation yang menjalankan aturan pada data produksi saat runtime — dbt test dijalankan atas permintaan pada hasil model di warehouse. Ketiganya saling melengkapi: Great Expectations menjaga gerbang masuk, pytest menjaga logika, dan dbt test menjaga hasil akhir di warehouse.
| Jenis pengujian | Objek yang diuji | Alat | Momen dijalankan |
|---|---|---|---|
| Unit test | logika fungsi transform | pytest | setiap commit |
| dbt test | data di dalam model warehouse | dbt test | setelah build model |
| Data validation | data saat ingestion | Great Expectations | tiap run pipeline |
Empat Generic Test Bawaan #
Generic test adalah asersi siap pakai yang dideklarasikan pada kolom di properties file schema.yml. dbt membawa empat yang paling sering dipakai: not_null, unique, accepted_values, dan relationships. Berkas berikut memasang ketujuh test untuk fact_pesanan:
# Properties model dbt TokoKita: deklarasi data_tests per kolom.
version: 2
models:
- name: fact_pesanan
columns:
- name: id_pesanan
data_tests:
- unique
- not_null
- name: status
data_tests:
- accepted_values:
values: ["selesai", "dibatalkan", "dikembalikan"]
- name: sk_pelanggan
data_tests:
- not_null
- relationships:
to: ref("dim_pelanggan")
field: sk_pelanggan
- name: total
data_tests:
- not_null
- nilai_positifdbt tidak terpasang di lingkungan bab ini, jadi struktur berkasnya kita periksa dengan yaml.safe_load — parser yang sama yang dbt pakai untuk membaca properties file:
import yaml
doc = yaml.safe_load(open("ch1_schema.yml", encoding="utf-8"))
model = doc["models"][0]
print(f"file valid : ch1_schema.yml (yaml.safe_load sukses)")
print(f"model : {model['name']}")
for kolom in model["columns"]:
nama_test = ", ".join(
t if isinstance(t, str) else next(iter(t)) for t in kolom["data_tests"])
print(f"kolom {kolom['name']:<13} -> {len(kolom['data_tests'])} test: {nama_test}")Output yang Dihasilkan:
file valid : ch1_schema.yml (yaml.safe_load sukses)
model : fact_pesanan
kolom id_pesanan -> 2 test: unique, not_null
kolom status -> 1 test: accepted_values
kolom sk_pelanggan -> 2 test: not_null, relationships
kolom total -> 2 test: not_null, nilai_positifPerhatikan pola deklarasinya: test string pendek (unique) untuk asersi tanpa argumen, atau pemetaan berargumen (accepted_values menerima daftar nilai; relationships menerima to dan field). Nama nilai_positif pada kolom total bukan test bawaan — kita bangun sendiri di bagian custom generic test.
Inilah SQL yang dbt Jalankan #
Setiap generic test pada akhirnya menjadi satu query SQL yang mengembalikan baris gagal; dbt membungkusnya dengan select count(*) as failures from (...), dan test dinyatakan gagal bila hitungan tersebut lebih dari nol. dbt tidak terpasang di lingkungan ini, jadi kita jalankan pola SQL yang sama atas tabel kecil yang sengaja memuat empat cacat klasik:
import duckdb
con = duckdb.connect()
con.execute("""
CREATE TABLE dim_pelanggan (sk_pelanggan INTEGER, nama VARCHAR);
INSERT INTO dim_pelanggan VALUES (1, 'Budi'), (2, 'Siti'), (3, 'Dewi');
CREATE TABLE fact_pesanan (
id_pesanan VARCHAR, status VARCHAR, sk_pelanggan INTEGER, total BIGINT);
INSERT INTO fact_pesanan VALUES
('ORD-1001', 'selesai', 1, 460000),
('ORD-1002', 'dibatalkan', 2, 125000),
('ORD-1003', 'selesai', 1, 310000),
('ORD-1003', 'selesai', 1, 310000), -- duplikat double-load
('ORD-1005', 'Selesai', 3, 99000), -- status berkapital
('ORD-1006', 'selesai', 999, 150000), -- pelanggan tak dikenal
('ORD-1007', 'dikembalikan', 2, -50000); -- retur bertanda salah
""")
def jalankan(nama, sql):
"""Satu test = satu query; gagal bila ada baris kembali."""
gagal = con.execute(sql).fetchall()
print(f"--- {nama}: {'GAGAL' if gagal else 'lulus'} ({len(gagal)} baris gagal)")
for baris in gagal:
print(" ", baris)
jalankan("not_null(total)",
"SELECT id_pesanan, total FROM fact_pesanan WHERE total IS NULL")
jalankan("unique(id_pesanan)",
"SELECT id_pesanan, COUNT(*) AS jumlah FROM fact_pesanan "
"GROUP BY id_pesanan HAVING COUNT(*) > 1")
jalankan("accepted_values(status)",
"SELECT DISTINCT id_pesanan, status FROM fact_pesanan "
"WHERE status NOT IN ('selesai', 'dibatalkan', 'dikembalikan')")
jalankan("relationships(sk_pelanggan)",
"SELECT f.id_pesanan, f.sk_pelanggan FROM fact_pesanan f "
"LEFT JOIN dim_pelanggan d ON f.sk_pelanggan = d.sk_pelanggan "
"WHERE d.sk_pelanggan IS NULL")
jalankan("singular: retur_tanda_salah",
"SELECT id_pesanan, total FROM fact_pesanan WHERE total < 0")Output yang Dihasilkan:
--- not_null(total): lulus (0 baris gagal)
--- unique(id_pesanan): GAGAL (1 baris gagal)
('ORD-1003', 2)
--- accepted_values(status): GAGAL (1 baris gagal)
('ORD-1005', 'Selesai')
--- relationships(sk_pelanggan): GAGAL (1 baris gagal)
('ORD-1006', 999)
--- singular: retur_tanda_salah: GAGAL (1 baris gagal)
('ORD-1007', -50000)Mari kita telaah bagian-bagian penting skrip di atas:
- Baris 3-17 (pembuatan tabel):
fact_pesananmemuat empat cacat yang masing-masing memancing satu test gagal — pesananORD-1003terduplikasi, status'Selesai'berkapital,sk_pelanggan999 tidak ada didim_pelanggan, dan returORD-1007bernilai negatif. - Baris 20-25 (
jalankan): satu test adalah satu query; hasil kosong berarti lulus. Bagian inilah yang dbt otomatiskan — menyusun query, menghitung kegagalan, dan melaporkannya per test. - Baris 28-39 (empat generic test):
not_nullmenyaringIS NULL,uniquemengelompokkan lalu menyaring yang berjumlah lebih dari satu,accepted_valuesmemakaiNOT IN, danrelationshipsmemakai anti-joinLEFT JOIN ... WHERE IS NULL.
Singular Test untuk Aturan Bisnis #
Empat generic test tidak bisa menyatakan aturan seperti “retur tidak boleh menyimpan nilai negatif”. Untuk kebutuhan itu dbt menyediakan singular test: berkas SQL lepas di direktori tests/ yang isinya bebas, dan test lulus bila query mengembalikan nol baris. Berkas tests/retur_tanda_salah.sql adalah asersi kelima pada eksekusi di atas:
select id_pesanan, total
from {{ ref('fact_pesanan') }}
where total < 0Fungsi ref() memastikan dbt tahu test bergantung pada fact_pesanan, sehingga test otomatis berjalan setelah model dibangun.
Custom Generic Test untuk Aturan Berulang #
Bila satu aturan dipakai berulang — semua kolom rupiah harus positif — menyalin singular test ke banyak berkas menjadi beban. dbt mengizinkan kita membuat generic test sendiri lewat macro test:
{% test nilai_positif(model, column_name) %}
select *
from {{ model }}
where {{ column_name }} < 0
{% endtest %}Setelah macro tersimpan, nilai_positif dipakai persis seperti test bawaan — seperti yang muncul pada properties file di atas untuk kolom total.
Severity: warn vs error #
Tidak semua kegagalan setara. Test ber-severity warn tetap dijalankan dan dilaporkan tetapi tidak menggagalkan run — berguna untuk aturan yang definisinya belum disepakati bisnis atau anomali yang masih ditoleransi:
data_tests:
- accepted_values:
values: ["selesai", "dibatalkan", "dikembalikan"]
config:
severity: warnSeverity bawaan adalah error: pipeline berhenti dan merge diblokir. Gunakan warn dengan sengaja — bila semua test berstatus warn, tidak ada lagi gerbang yang benar-benar menjaga.
Kapan Test Dijalankan #
Perintah dbt build menjalankan model lalu test-nya dalam satu perintah. Tiga momen pelaksanaan yang lazim di TokoKita:
| Momen | Cakupan | Peran |
|---|---|---|
| Saat develop lokal | dbt build -s model_baru+ | umpan balik cepat sebelum push |
| Pre-merge di CI | seluruh test pada model terdampak | gerbang gabung kode |
| Terjadwal tiap malam | seluruh test pada data segar | menangkap data hulu yang berubah |
Baris ketiga paling sering dilupakan: kode model tidak berubah, tetapi data hulu bisa berubah semalam dan menumbuhkan kegagalan baru. Rincian jenis test dan konfigurasi severity tersedia di dokumentasi dbt serta referensi data test configs .
Test menjaga kebenaran data, tetapi orang baru yang membuka proyek dbt masih bertanya hal lain: kolom gmv itu dihitung bagaimana? Jawabannya hidup di dokumentasi — materi Chapter Dokumentasi Model Data.