Function dan trigger adalah kode yang berjalan tersembunyi di dalam server — pemanggilnya bukan manusia, melainkan SELECT dan INSERT yang tidak tahu sedang mengeksekusi logika apa. Kesalahan di objek ini punya daya ledak khusus: function hitung_diskon yang salah menghitung merusak setiap transaksi yang lewat, dan trigger yang menyala di event yang salah merusak data diam-diam. Pengujian harus mencakup empat permukaan: keluaran untuk masukan normal, kasus batas, jalur RAISE EXCEPTION, dan efek samping trigger. Chapter ini menguji semuanya pada satu set objek yang realistis, lalu menutup dengan pola paling produktif di seluruh section ini: test harness data-driven.

Objek yang Akan Diuji #

Fixture berikut memuat tabel pesanan, function hitung_diskon, dan dua trigger — satu trigger audit yang mencatat perubahan status, satu trigger penyaring yang membuang pesanan bermasalah sebelum tersimpan:

-- Objek yang akan diuji: function hitung_diskon + dua trigger pada pesanan.
CREATE TABLE pesanan (
    id        integer PRIMARY KEY,
    pelanggan text    NOT NULL,
    total     numeric NOT NULL,
    status    text    NOT NULL DEFAULT 'baru'
);

-- Tabel audit; kolom waktu sengaja tidak ikut di-SELECT pada contoh uji
-- karena nilainya berubah tiap eksekusi (tidak deterministik).
CREATE TABLE audit_pesanan (
    id_pesanan  integer NOT NULL,
    aksi        text    NOT NULL,
    status_lama text,
    status_baru text    NOT NULL,
    waktu       timestamptz NOT NULL DEFAULT now()
);

-- Function: hitung harga setelah diskon; diskon di luar 0..100 -> exception.
CREATE FUNCTION hitung_diskon(brutto numeric, persen_diskon numeric)
RETURNS numeric
LANGUAGE plpgsql
AS $$
BEGIN
    IF brutto IS NULL OR persen_diskon IS NULL THEN
        RETURN NULL;
    END IF;
    IF persen_diskon < 0 OR persen_diskon > 100 THEN
        RAISE EXCEPTION 'persen diskon % di luar rentang 0..100', persen_diskon
            USING ERRCODE = 'TMJ01';
    END IF;
    RETURN round(brutto * (100 - persen_diskon) / 100, 2);
END $$;

-- Trigger 1: audit perubahan status (INSERT atau UPDATE kolom status saja).
CREATE FUNCTION catat_audit() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
    INSERT INTO audit_pesanan (id_pesanan, aksi, status_lama, status_baru)
    VALUES (NEW.id,
            TG_OP,
            CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE OLD.status END,
            NEW.status);
    RETURN NEW;
END $$;

CREATE TRIGGER audit_status
AFTER INSERT OR UPDATE OF status ON pesanan
FOR EACH ROW EXECUTE FUNCTION catat_audit();

-- Trigger 2: tolak pesanan bermasalah (total <= 0) secara diam-diam.
CREATE FUNCTION saring_pesanan() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
    IF NEW.total <= 0 THEN
        RETURN NULL;  -- baris tidak pernah sampai ke tabel
    END IF;
    RETURN NEW;
END $$;

CREATE TRIGGER saring_total
BEFORE INSERT ON pesanan
FOR EACH ROW EXECUTE FUNCTION saring_pesanan();

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
CREATE FUNCTION
CREATE FUNCTION
CREATE TRIGGER
CREATE FUNCTION
CREATE TRIGGER

Tiga perilaku yang nanti diuji satu per satu: hitung_diskon mengembalikan numeric hasil perhitungan dan melempar exception untuk diskon di luar rentang 0..100; trigger audit_status hanya menyala untuk INSERT atau UPDATE kolom status; dan trigger saring_total mengembalikan NULL untuk total tidak positif sehingga barisnya tidak pernah tersimpan. Dasar trigger itu sendiri sudah dibahas di chapter Konsep Trigger .

Masukan Diketahui, Keluaran Diketahui #

Pengujian function dimulai dari bentuk paling sederhana: panggil dengan masukan yang hasilnya bisa dihitung tangan, bandingkan dengan CASE WHEN. Pola ini sama dengan asersi skalar di chapter pertama — hanya objeknya kini function, bukan query:

-- Uji function: masukan diketahui -> keluaran diketahui.
-- Cakup: kasus normal, batas bawah/atas, pembulatan, dan NULL.
SELECT CASE
         WHEN hitung_diskon(100000, 10) = 90000
         THEN 'LULUS: 100000 dengan diskon 10% menjadi 90000'
         ELSE 'GAGAL: aktual ' || hitung_diskon(100000, 10)::text
       END AS uji_normal;

SELECT CASE
         WHEN hitung_diskon(100000, 0) = 100000
         THEN 'LULUS: batas bawah diskon 0 -> harga tidak berubah'
         ELSE 'GAGAL: aktual ' || hitung_diskon(100000, 0)::text
       END AS uji_batas_bawah;

SELECT CASE
         WHEN hitung_diskon(100000, 100) = 0
         THEN 'LULUS: batas atas diskon 100 -> gratis'
         ELSE 'GAGAL: aktual ' || hitung_diskon(100000, 100)::text
       END AS uji_batas_atas;

SELECT CASE
         WHEN hitung_diskon(99999, 12.5) = 87499.13
         THEN 'LULUS: pembulatan dua desimal (87499.125 -> 87499.13)'
         ELSE 'GAGAL: aktual ' || hitung_diskon(99999, 12.5)::text
       END AS uji_pembulatan;

SELECT CASE
         WHEN hitung_diskon(100000, NULL) IS NULL
         THEN 'LULUS: persen NULL menghasilkan NULL, bukan error'
         ELSE 'GAGAL: seharusnya NULL'
       END AS uji_null;

Output yang Dihasilkan:

text
                  uji_normal                   
-----------------------------------------------
 LULUS: 100000 dengan diskon 10% menjadi 90000
(1 row)

                  uji_batas_bawah                   
----------------------------------------------------
 LULUS: batas bawah diskon 0 -> harga tidak berubah
(1 row)

             uji_batas_atas             
----------------------------------------
 LULUS: batas atas diskon 100 -> gratis
(1 row)

                    uji_pembulatan                     
-------------------------------------------------------
 LULUS: pembulatan dua desimal (87499.125 -> 87499.13)
(1 row)

                     uji_null                      
---------------------------------------------------
 LULUS: persen NULL menghasilkan NULL, bukan error
(1 row)

Lima asersi tersebut sengaja memilih kasus yang berbeda-beda karakternya: satu kasus normal (diskon 10%), dua batas rentang (diskon 0 dan 100 — keduanya sah), satu kasus pembulatan (87499.125 yang harus naik menjadi 87499.13), dan satu kasus NULL. Kasus NULL penting untuk function yang dipakai di kolom nullable: keputusan “NULL masuk, NULL keluar” adalah kontrak yang harus dijaga, bukan kebetulan yang terjadi.

Menguji Jalur Exception #

Function yang tugasnya memvalidasi masukan hanya teruji bila jalur tolaknya benar-benar diuji — function validasi yang tidak pernah melempar error justru gagal total. Masalahnya: SELECT hitung_diskon(100000, 150) yang melempar exception akan menghentikan file uji. Solusinya adalah menangkap error di dalam DO block, pola yang dikenalkan di chapter Exception Handling :

-- Uji jalur exception: function HARUS melempar error untuk masukan di luar
-- rentang. Error ditangkap DO block agar satu file bisa menguji dua kasus.
CREATE TABLE hasil_exception (
    kasus      text,
    tertangkap boolean,
    pesan      text
);

DO $$
DECLARE
    v_pesan text;
BEGIN
    BEGIN
        PERFORM hitung_diskon(100000, 150);
        INSERT INTO hasil_exception VALUES ('diskon 150', false, 'tidak error (bug!)');
    EXCEPTION WHEN SQLSTATE 'TMJ01' THEN
        GET STACKED DIAGNOSTICS v_pesan = MESSAGE_TEXT;
        INSERT INTO hasil_exception VALUES ('diskon 150', true, v_pesan);
    END;

    BEGIN
        PERFORM hitung_diskon(100000, -5);
        INSERT INTO hasil_exception VALUES ('diskon -5', false, 'tidak error (bug!)');
    EXCEPTION WHEN SQLSTATE 'TMJ01' THEN
        GET STACKED DIAGNOSTICS v_pesan = MESSAGE_TEXT;
        INSERT INTO hasil_exception VALUES ('diskon -5', true, v_pesan);
    END;
END $$;

SELECT kasus, tertangkap, pesan FROM hasil_exception ORDER BY kasus;

SELECT CASE
         WHEN (SELECT count(*) FROM hasil_exception WHERE tertangkap) = 2
         THEN 'LULUS: kedua masukan tidak sah sama-sama melempar exception'
         ELSE 'GAGAL: ada masukan tidak sah yang lolos'
       END AS uji_exception;

Output yang Dihasilkan:

text
CREATE TABLE
DO
   kasus    | tertangkap |                  pesan                   
------------+------------+------------------------------------------
 diskon -5  | t          | persen diskon -5 di luar rentang 0..100
 diskon 150 | t          | persen diskon 150 di luar rentang 0..100
(2 rows)

                        uji_exception                        
-------------------------------------------------------------
 LULUS: kedua masukan tidak sah sama-sama melempar exception
(1 row)

Kedua masukan tidak sah (150 dan -5) tertangkap dengan SQLSTATE 'TMJ01' — kode custom yang dipasang function lewat USING ERRCODE. Menangkap berdasarkan kode membuat uji ini presisi: bila function kelak diubah melempar error lain (misalnya kode default P0001), uji ini otomatis gagal dan perubahan kontrak itu tertangkap. Baris 'tidak error (bug!)' hanya akan muncul bila INSERT pengujian lolos — yaitu tepat saat function kehilangan kemampuannya menolak.

Menguji Efek Samping Trigger #

Trigger tidak punya nilai kembalian yang bisa dibandingkan — yang bisa diverifikasi adalah efek sampingnya di tabel lain. Uji trigger audit menjawab tiga pertanyaan berurutan: apakah INSERT menghasilkan baris audit, apakah UPDATE kolom status menambah baris audit, dan — sama pentingnya — apakah UPDATE kolom lain dibiarkan tidak menyala:

-- Uji trigger audit: efek samping terverifikasi, dan trigger hanya menyala
-- untuk event yang benar (UPDATE kolom status, bukan kolom lain).
INSERT INTO pesanan (id, pelanggan, total, status)
VALUES (1, 'Siti Rahayu', 250000, 'baru');

-- Setelah INSERT: harus ada tepat satu baris audit untuk pesanan 1.
SELECT id_pesanan, aksi, status_lama, status_baru
FROM audit_pesanan
ORDER BY id_pesanan, aksi;

-- UPDATE kolom status: memicu trigger, baris audit bertambah satu.
UPDATE pesanan SET status = 'dibayar' WHERE id = 1;

SELECT id_pesanan, aksi, status_lama, status_baru
FROM audit_pesanan
ORDER BY id_pesanan, aksi;

-- UPDATE kolom LAIN (total): trigger UPDATE OF status tidak boleh menyala.
UPDATE pesanan SET total = 260000 WHERE id = 1;

SELECT CASE
         WHEN (SELECT count(*) FROM audit_pesanan) = 2
         THEN 'LULUS: INSERT dan UPDATE status tercatat; UPDATE total diabaikan'
         ELSE 'GAGAL: ekspektasi 2 baris audit, aktual '
              || (SELECT count(*) FROM audit_pesanan)::text
       END AS uji_event_trigger;

Output yang Dihasilkan:

text
INSERT 0 1
 id_pesanan |  aksi  | status_lama | status_baru 
------------+--------+-------------+-------------
          1 | INSERT |             | baru
(1 row)

UPDATE 1
 id_pesanan |  aksi  | status_lama | status_baru 
------------+--------+-------------+-------------
          1 | INSERT |             | baru
          1 | UPDATE | baru        | dibayar
(2 rows)

UPDATE 1
                        uji_event_trigger                         
------------------------------------------------------------------
 LULUS: INSERT dan UPDATE status tercatat; UPDATE total diabaikan
(1 row)

Dua SELECT pertama memperlihatkan pertumbuhan tabel audit: satu baris INSERT (dengan status_lama kosong — trigger tahu ini insert), lalu satu baris UPDATE dengan status_lama terisi. Pertanyaan ketiga dijawab lewat hitungan: setelah UPDATE pesanan SET total = 260000, jumlah baris audit tetap 2 — klausa UPDATE OF status membuat trigger hanya menyala saat kolom yang disebut ikut berubah. Tanpa uji ini, trigger yang salah tulis AFTER UPDATE (tanpa OF) akan mencatat setiap perubahan minor dan menggembungkan tabel audit di produksi.

RETURN NULL: Baris yang Dilewati Diam-Diam #

Trigger BEFORE yang mengembalikan NULL membatalkan operasi tanpa error — perilaku yang berguna untuk menyaring data kotor, tetapi berbahaya bila tidak teruji karena tidak ada pesan apa pun yang memberi tahu barisnya hilang:

-- Uji trigger BEFORE dengan RETURN NULL: baris yang ditolak tidak tersimpan
-- dan tidak meninggalkan jejak audit sama sekali.
INSERT INTO pesanan (id, pelanggan, total, status)
VALUES (2, 'Budi Santoso', 175000, 'baru');

-- total negatif: trigger saring_total mengembalikan NULL -> baris dilewati.
INSERT INTO pesanan (id, pelanggan, total, status)
VALUES (3, 'Data Uji Kotor', -5000, 'baru');

SELECT id, pelanggan, total, status FROM pesanan ORDER BY id;

SELECT CASE
         WHEN (SELECT count(*) FROM pesanan) = 2
         THEN 'LULUS: pesanan 3 dengan total negatif tidak pernah tersimpan'
         ELSE 'GAGAL: ekspektasi 2 baris, aktual '
              || (SELECT count(*) FROM pesanan)::text
       END AS uji_skip_pesan;

SELECT CASE
         WHEN (SELECT count(*) FROM audit_pesanan WHERE id_pesanan = 3) = 0
         THEN 'LULUS: baris yang dilewati tidak meninggalkan jejak audit'
         ELSE 'GAGAL: ada baris audit untuk pesanan yang tidak tersimpan'
       END AS uji_skip_audit;

Output yang Dihasilkan:

text
INSERT 0 1
INSERT 0 0
 id |  pelanggan   | total  | status  
----+--------------+--------+---------
  1 | Siti Rahayu  | 260000 | dibayar
  2 | Budi Santoso | 175000 | baru
(2 rows)

                        uji_skip_pesan                        
--------------------------------------------------------------
 LULUS: pesanan 3 dengan total negatif tidak pernah tersimpan
(1 row)

                      uji_skip_audit                       
-----------------------------------------------------------
 LULUS: baris yang dilewati tidak meninggalkan jejak audit
(1 row)

Bukti terkuatnya ada di tag psql: INSERT 0 1 untuk pesanan Budi, lalu INSERT 0 0 untuk pesanan dengan total negatif — nol baris tersimpan, tanpa error, tanpa peringatan. Dua asersi penutup memastikan pesanan kotor itu tidak ada di pesanan dan tidak meninggalkan jejak audit — kombinasi yang penting: bila baris yang dilewati ternyata tetap tercatat di audit, laporan audit Anda menghitung pesanan yang tidak pernah ada.

Test Harness Data-Driven #

Pola-pola di atas bagus untuk satu-dua kasus, tetapi function nyata punya lusinan kombinasi masukan. Menulis satu SELECT CASE per kasus membuat file uji membengkak tanpa nilai tambah. Solusinya: pindahkan kasus uji ke tabel, lalu jalankan function untuk semuanya dengan satu query — inilah test harness data-driven:

sql uji-harness.sql
-- Test harness data-driven: tabel kasus uji berisi (masukan, ekspektasi),
-- satu query menjalankan function untuk SEMUA kasus dan melaporkan selisih.
-- Baris terakhir sengaja diberi ekspektasi salah untuk memperagakan deteksi.
CREATE TABLE kasus_uji (
    kasus      text PRIMARY KEY,
    brutto     numeric,
    persen     numeric,
    ekspektasi numeric
);

INSERT INTO kasus_uji (kasus, brutto, persen, ekspektasi) VALUES
    ('batas-atas-diskon',   100000, 100,   0),
    ('batas-bawah-diskon',  100000,   0,   100000),
    ('brutto-NULL',         NULL,    10,   NULL),
    ('ekspektasi-salah',    100000,  25,   74000),
    ('normal',              100000,  10,   90000),
    ('pembulatan',           99999, 12.5, 87499.13),
    ('persen-NULL',         100000, NULL, NULL);

SELECT k.kasus,
       CASE
         WHEN hitung_diskon(k.brutto, k.persen) IS NOT DISTINCT FROM k.ekspektasi
         THEN 'LULUS'
         ELSE 'GAGAL: aktual '
              || COALESCE(hitung_diskon(k.brutto, k.persen)::text, 'NULL')
              || ', ekspektasi ' || COALESCE(k.ekspektasi::text, 'NULL')
       END AS status
FROM kasus_uji k
ORDER BY k.kasus;

-- Ringkasan: satu angka untuk pipeline CI yang membaca.
SELECT CASE
         WHEN count(*) FILTER (WHERE status = 'GAGAL') = 0
         THEN 'LULUS: semua kasus lolos'
         ELSE 'GAGAL: ' || count(*) FILTER (WHERE status = 'GAGAL')
              || ' dari ' || count(*) || ' kasus gagal'
       END AS ringkasan
FROM (
    SELECT CASE
             WHEN hitung_diskon(k.brutto, k.persen) IS NOT DISTINCT FROM k.ekspektasi
             THEN 'LULUS' ELSE 'GAGAL'
           END AS status
    FROM kasus_uji k
) laporan;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 7
       kasus        |                  status                  
--------------------+------------------------------------------
 batas-atas-diskon  | LULUS
 batas-bawah-diskon | LULUS
 brutto-NULL        | LULUS
 ekspektasi-salah   | GAGAL: aktual 75000.00, ekspektasi 74000
 normal             | LULUS
 pembulatan         | LULUS
 persen-NULL        | LULUS
(7 rows)

          ringkasan          
-----------------------------
 GAGAL: 1 dari 7 kasus gagal
(1 row)

Bedah komponen kuncinya:

  1. Tabel kasus_uji berisi (masukan, ekspektasi) — menambah kasus uji baru cukup satu INSERT, tanpa menyentuh logika pengujian. Ekspektasi dan masukan hidup berdampingan sehingga tabelnya terbaca seperti spesifikasi.
  2. IS NOT DISTINCT FROM, bukan = — perbandingan = terhadap ekspektasi NULL selalu menghasilkan NULL (tidak lulus, tidak gagal — menggantung). IS NOT DISTINCT FROM memperlakukan NULL sebagai nilai yang bisa dicocokkan, sehingga kasus NULL teruji seperti kasus lain.
  3. Pesan GAGAL membawa nilai aktual dan ekspektasi — lihat baris ekspektasi-salah: aktual 75000.00, ekspektasi 74000. Kasus ini sengaja dipasang salah untuk membuktikan harness benar-benar mendeteksi kegagalan; tanpa kemampuan gagal, pengujian apa pun hanya teater.
  4. Ringkasan satu angka — query kedua merangkum N dari M kasus gagal, format yang siap dibaca pipeline CI: satu string, keputusan lulus/tidak langsung terbaca.

Pola harness ini — kasus di tabel, eksekusi massal, laporan ringkas — adalah benih dari regression suite yang sesungguhnya: kumpulan uji yang dijalankan ulang otomatis setiap kali skema atau kode berubah. Chapter berikutnya membangun suite itu lengkap dengan pustaka asersi, skrip runner, dan golden file.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

buat-objek-uji.sql

Unduh berkas
sql
-- Objek yang akan diuji: function hitung_diskon + dua trigger pada pesanan.
CREATE TABLE pesanan (
    id        integer PRIMARY KEY,
    pelanggan text    NOT NULL,
    total     numeric NOT NULL,
    status    text    NOT NULL DEFAULT 'baru'
);

-- Tabel audit; kolom waktu sengaja tidak ikut di-SELECT pada contoh uji
-- karena nilainya berubah tiap eksekusi (tidak deterministik).
CREATE TABLE audit_pesanan (
    id_pesanan  integer NOT NULL,
    aksi        text    NOT NULL,
    status_lama text,
    status_baru text    NOT NULL,
    waktu       timestamptz NOT NULL DEFAULT now()
);

-- Function: hitung harga setelah diskon; diskon di luar 0..100 -> exception.
CREATE FUNCTION hitung_diskon(brutto numeric, persen_diskon numeric)
RETURNS numeric
LANGUAGE plpgsql
AS $$
BEGIN
    IF brutto IS NULL OR persen_diskon IS NULL THEN
        RETURN NULL;
    END IF;
    IF persen_diskon < 0 OR persen_diskon > 100 THEN
        RAISE EXCEPTION 'persen diskon % di luar rentang 0..100', persen_diskon
            USING ERRCODE = 'TMJ01';
    END IF;
    RETURN round(brutto * (100 - persen_diskon) / 100, 2);
END $$;

-- Trigger 1: audit perubahan status (INSERT atau UPDATE kolom status saja).
CREATE FUNCTION catat_audit() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
    INSERT INTO audit_pesanan (id_pesanan, aksi, status_lama, status_baru)
    VALUES (NEW.id,
            TG_OP,
            CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE OLD.status END,
            NEW.status);
    RETURN NEW;
END $$;

CREATE TRIGGER audit_status
AFTER INSERT OR UPDATE OF status ON pesanan
FOR EACH ROW EXECUTE FUNCTION catat_audit();

-- Trigger 2: tolak pesanan bermasalah (total <= 0) secara diam-diam.
CREATE FUNCTION saring_pesanan() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
    IF NEW.total <= 0 THEN
        RETURN NULL;  -- baris tidak pernah sampai ke tabel
    END IF;
    RETURN NEW;
END $$;

CREATE TRIGGER saring_total
BEFORE INSERT ON pesanan
FOR EACH ROW EXECUTE FUNCTION saring_pesanan();

uji-exception.sql

Unduh berkas
sql
-- Uji jalur exception: function HARUS melempar error untuk masukan di luar
-- rentang. Error ditangkap DO block agar satu file bisa menguji dua kasus.
CREATE TABLE hasil_exception (
    kasus      text,
    tertangkap boolean,
    pesan      text
);

DO $$
DECLARE
    v_pesan text;
BEGIN
    BEGIN
        PERFORM hitung_diskon(100000, 150);
        INSERT INTO hasil_exception VALUES ('diskon 150', false, 'tidak error (bug!)');
    EXCEPTION WHEN SQLSTATE 'TMJ01' THEN
        GET STACKED DIAGNOSTICS v_pesan = MESSAGE_TEXT;
        INSERT INTO hasil_exception VALUES ('diskon 150', true, v_pesan);
    END;

    BEGIN
        PERFORM hitung_diskon(100000, -5);
        INSERT INTO hasil_exception VALUES ('diskon -5', false, 'tidak error (bug!)');
    EXCEPTION WHEN SQLSTATE 'TMJ01' THEN
        GET STACKED DIAGNOSTICS v_pesan = MESSAGE_TEXT;
        INSERT INTO hasil_exception VALUES ('diskon -5', true, v_pesan);
    END;
END $$;

SELECT kasus, tertangkap, pesan FROM hasil_exception ORDER BY kasus;

SELECT CASE
         WHEN (SELECT count(*) FROM hasil_exception WHERE tertangkap) = 2
         THEN 'LULUS: kedua masukan tidak sah sama-sama melempar exception'
         ELSE 'GAGAL: ada masukan tidak sah yang lolos'
       END AS uji_exception;

uji-function.sql

Unduh berkas
sql
-- Uji function: masukan diketahui -> keluaran diketahui.
-- Cakup: kasus normal, batas bawah/atas, pembulatan, dan NULL.
SELECT CASE
         WHEN hitung_diskon(100000, 10) = 90000
         THEN 'LULUS: 100000 dengan diskon 10% menjadi 90000'
         ELSE 'GAGAL: aktual ' || hitung_diskon(100000, 10)::text
       END AS uji_normal;

SELECT CASE
         WHEN hitung_diskon(100000, 0) = 100000
         THEN 'LULUS: batas bawah diskon 0 -> harga tidak berubah'
         ELSE 'GAGAL: aktual ' || hitung_diskon(100000, 0)::text
       END AS uji_batas_bawah;

SELECT CASE
         WHEN hitung_diskon(100000, 100) = 0
         THEN 'LULUS: batas atas diskon 100 -> gratis'
         ELSE 'GAGAL: aktual ' || hitung_diskon(100000, 100)::text
       END AS uji_batas_atas;

SELECT CASE
         WHEN hitung_diskon(99999, 12.5) = 87499.13
         THEN 'LULUS: pembulatan dua desimal (87499.125 -> 87499.13)'
         ELSE 'GAGAL: aktual ' || hitung_diskon(99999, 12.5)::text
       END AS uji_pembulatan;

SELECT CASE
         WHEN hitung_diskon(100000, NULL) IS NULL
         THEN 'LULUS: persen NULL menghasilkan NULL, bukan error'
         ELSE 'GAGAL: seharusnya NULL'
       END AS uji_null;

uji-harness.sql

Unduh berkas
sql
-- Test harness data-driven: tabel kasus uji berisi (masukan, ekspektasi),
-- satu query menjalankan function untuk SEMUA kasus dan melaporkan selisih.
-- Baris terakhir sengaja diberi ekspektasi salah untuk memperagakan deteksi.
CREATE TABLE kasus_uji (
    kasus      text PRIMARY KEY,
    brutto     numeric,
    persen     numeric,
    ekspektasi numeric
);

INSERT INTO kasus_uji (kasus, brutto, persen, ekspektasi) VALUES
    ('batas-atas-diskon',   100000, 100,   0),
    ('batas-bawah-diskon',  100000,   0,   100000),
    ('brutto-NULL',         NULL,    10,   NULL),
    ('ekspektasi-salah',    100000,  25,   74000),
    ('normal',              100000,  10,   90000),
    ('pembulatan',           99999, 12.5, 87499.13),
    ('persen-NULL',         100000, NULL, NULL);

SELECT k.kasus,
       CASE
         WHEN hitung_diskon(k.brutto, k.persen) IS NOT DISTINCT FROM k.ekspektasi
         THEN 'LULUS'
         ELSE 'GAGAL: aktual '
              || COALESCE(hitung_diskon(k.brutto, k.persen)::text, 'NULL')
              || ', ekspektasi ' || COALESCE(k.ekspektasi::text, 'NULL')
       END AS status
FROM kasus_uji k
ORDER BY k.kasus;

-- Ringkasan: satu angka untuk pipeline CI yang membaca.
SELECT CASE
         WHEN count(*) FILTER (WHERE status = 'GAGAL') = 0
         THEN 'LULUS: semua kasus lolos'
         ELSE 'GAGAL: ' || count(*) FILTER (WHERE status = 'GAGAL')
              || ' dari ' || count(*) || ' kasus gagal'
       END AS ringkasan
FROM (
    SELECT CASE
             WHEN hitung_diskon(k.brutto, k.persen) IS NOT DISTINCT FROM k.ekspektasi
             THEN 'LULUS' ELSE 'GAGAL'
           END AS status
    FROM kasus_uji k
) laporan;

uji-trigger-audit.sql

Unduh berkas
sql
-- Uji trigger audit: efek samping terverifikasi, dan trigger hanya menyala
-- untuk event yang benar (UPDATE kolom status, bukan kolom lain).
INSERT INTO pesanan (id, pelanggan, total, status)
VALUES (1, 'Siti Rahayu', 250000, 'baru');

-- Setelah INSERT: harus ada tepat satu baris audit untuk pesanan 1.
SELECT id_pesanan, aksi, status_lama, status_baru
FROM audit_pesanan
ORDER BY id_pesanan, aksi;

-- UPDATE kolom status: memicu trigger, baris audit bertambah satu.
UPDATE pesanan SET status = 'dibayar' WHERE id = 1;

SELECT id_pesanan, aksi, status_lama, status_baru
FROM audit_pesanan
ORDER BY id_pesanan, aksi;

-- UPDATE kolom LAIN (total): trigger UPDATE OF status tidak boleh menyala.
UPDATE pesanan SET total = 260000 WHERE id = 1;

SELECT CASE
         WHEN (SELECT count(*) FROM audit_pesanan) = 2
         THEN 'LULUS: INSERT dan UPDATE status tercatat; UPDATE total diabaikan'
         ELSE 'GAGAL: ekspektasi 2 baris audit, aktual '
              || (SELECT count(*) FROM audit_pesanan)::text
       END AS uji_event_trigger;

uji-trigger-skip.sql

Unduh berkas
sql
-- Uji trigger BEFORE dengan RETURN NULL: baris yang ditolak tidak tersimpan
-- dan tidak meninggalkan jejak audit sama sekali.
INSERT INTO pesanan (id, pelanggan, total, status)
VALUES (2, 'Budi Santoso', 175000, 'baru');

-- total negatif: trigger saring_total mengembalikan NULL -> baris dilewati.
INSERT INTO pesanan (id, pelanggan, total, status)
VALUES (3, 'Data Uji Kotor', -5000, 'baru');

SELECT id, pelanggan, total, status FROM pesanan ORDER BY id;

SELECT CASE
         WHEN (SELECT count(*) FROM pesanan) = 2
         THEN 'LULUS: pesanan 3 dengan total negatif tidak pernah tersimpan'
         ELSE 'GAGAL: ekspektasi 2 baris, aktual '
              || (SELECT count(*) FROM pesanan)::text
       END AS uji_skip_pesan;

SELECT CASE
         WHEN (SELECT count(*) FROM audit_pesanan WHERE id_pesanan = 3) = 0
         THEN 'LULUS: baris yang dilewati tidak meninggalkan jejak audit'
         ELSE 'GAGAL: ada baris audit untuk pesanan yang tidak tersimpan'
       END AS uji_skip_audit;