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 pengujianObjek yang diujiAlatMomen dijalankan
Unit testlogika fungsi transformpytestsetiap commit
dbt testdata di dalam model warehousedbt testsetelah build model
Data validationdata saat ingestionGreat Expectationstiap 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:

yaml
# 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_positif

dbt 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:

python
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:

text
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_positif

Perhatikan 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:

python
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:

text
--- 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:

  1. Baris 3-17 (pembuatan tabel): fact_pesanan memuat empat cacat yang masing-masing memancing satu test gagal — pesanan ORD-1003 terduplikasi, status 'Selesai' berkapital, sk_pelanggan 999 tidak ada di dim_pelanggan, dan retur ORD-1007 bernilai negatif.
  2. 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.
  3. Baris 28-39 (empat generic test): not_null menyaring IS NULL, unique mengelompokkan lalu menyaring yang berjumlah lebih dari satu, accepted_values memakai NOT IN, dan relationships memakai anti-join LEFT 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:

sql
select id_pesanan, total
from {{ ref('fact_pesanan') }}
where total < 0

Fungsi 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:

sql
{% 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:

yaml
data_tests:
  - accepted_values:
      values: ["selesai", "dibatalkan", "dikembalikan"]
      config:
        severity: warn

Severity 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:

MomenCakupanPeran
Saat develop lokaldbt build -s model_baru+umpan balik cepat sebelum push
Pre-merge di CIseluruh test pada model terdampakgerbang gabung kode
Terjadwal tiap malamseluruh test pada data segarmenangkap 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.