Query yang berjalan tanpa error belum tentu benar. SUM yang salah menghitung NULL, LEFT JOIN yang kehilangan kategori kosong, atau GROUP BY yang menggabungkan baris yang seharusnya terpisah — semuanya menghasilkan output yang tampak meyakinkan padahal angkanya salah. Di aplikasi produksi, kesalahan seperti ini baru ketahuan berminggu-minggu kemudian lewat laporan keuangan yang tidak balance. Section ini membuka bagian pengujian dengan fondasinya: menguji query SQL dengan data uji yang deterministik dan hasil yang sudah dihitung manual, sehingga setiap perubahan pada query bisa diverifikasi dalam hitungan detik.

Apa Arti Query yang Benar #

Definisi kerja yang paling praktis: query benar bila hasilnya sama dengan himpunan baris ekspektasi yang kita tetapkan lebih dulu. Ekspektasi ditulis dari perhitungan manual — tangan, kalkulator, spreadsheet — bukan dari query lain yang mungkin ikut salah. Dengan cara ini, pengujian berfungsi ganda: sebagai penjaga kualitas sekaligus dokumentasi perilaku query.

Konsekuensinya, pengujian query butuh dua bahan: fixture data yang tetap dan deterministik, serta tabel ekspektasi yang dihitung dari fixture itu. Kedua bahan tersebut hidup berdampingan di file yang sama sehingga siapa pun bisa menjalankan ulang pengujian dan mendapat hasil identik — tidak ada ketergantungan pada jam server, urutan insert, atau data produksi yang terus berubah.

Merancang Data Uji yang Deterministik #

Fixture yang baik kecil (bisa dihitung manual) tetapi sengaja memuat kasus batas. Fixture berikut memakai skenario Toko Maju Jaya dengan lima produk yang masing-masing menguji satu perilaku:

-- Fixture uji: data kecil, tetap, dan sengaja memuat kasus batas.
-- Kategori 'aksesoris' sengaja tidak punya produk; satu harga NULL; satu harga 0.
CREATE TABLE kategori (
    nama_kategori text PRIMARY KEY
);

CREATE TABLE produk (
    id       integer PRIMARY KEY,
    nama     text NOT NULL,
    kategori text NOT NULL REFERENCES kategori,
    harga    integer,
    stok     integer NOT NULL
);

INSERT INTO kategori VALUES ('makanan'), ('minuman'), ('aksesoris'), ('lain-lain');

INSERT INTO produk (id, nama, kategori, harga, stok) VALUES
    (1, 'Roti Tawar',     'makanan',  15000,  10),
    (2, 'Selai Kacang',   'makanan',  28000,   4),
    (3, 'Teh Kotak',      'minuman',   6000,  24),
    (4, 'Susu Kotak',     'minuman',   NULL,  12),
    (5, 'Kantong Belanja','lain-lain',     0, 100);

SELECT id, nama, kategori, harga, stok FROM produk ORDER BY id;

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
INSERT 0 4
INSERT 0 5
 id |      nama       | kategori  | harga | stok 
----+-----------------+-----------+-------+------
  1 | Roti Tawar      | makanan   | 15000 |   10
  2 | Selai Kacang    | makanan   | 28000 |    4
  3 | Teh Kotak       | minuman   |  6000 |   24
  4 | Susu Kotak      | minuman   |       |   12
  5 | Kantong Belanja | lain-lain |     0 |  100
(5 rows)

Ada empat jebakan yang sengaja ditanam di data tersebut:

Kasus batasBaris fixturePerilaku yang diuji
Nilai NULLSusu Kotak (harga NULL)agregat mengabaikan NULL, join tetap memuat barisnya
Batas bawahKantong Belanja (harga 0)nilai 0 ikut dihitung AVG, tidak dianggap “kosong”
Himpunan kosongkategori aksesoris tanpa produkLEFT JOIN + COALESCE menghasilkan 0, bukan baris hilang
Filter tanpa kecocokankategori elektronik tidak adaquery mengembalikan nol baris tanpa error

Tanpa baris-baris ini, pengujian hanya memverifikasi jalur bahagia — data rapi, hasil rapi, dan bug tetap bersarang di kasus batas yang tidak pernah diuji.

Pola EXCEPT Dua Arah #

Inti pengujian himpunan hasil adalah pertanyaan sederhana: apakah hasil query dan ekspektasi berisi baris yang persis sama? Operator EXCEPT menjawabnya dengan mengembalikan baris yang ada di kiri tetapi tidak ada di kanan. Satu arah saja tidak cukup — query bisa punya baris lebih (false positive) atau kurang (false negative) — maka pola lengkapnya menjalankan EXCEPT dua arah: selisih kosong di kedua arah berarti kedua himpunan identik.

File berikut menguji laporan nilai stok per kategori. Ekspektasi dihitung manual: makanan = 15000×10 + 28000×4 = 262000; minuman = 6000×24 = 144000 (Susu Kotak dengan harga NULL tidak menyumbang); kategori tanpa produk bernilai 0:

sql uji-except.sql
-- Uji hasil query dengan EXCEPT dua arah.
-- Pola: (hasil EXCEPT ekspektasi) UNION ALL (ekspektasi EXCEPT hasil).
-- Hasil uji kosong berarti kedua himpunan baris identik.

-- 1) Query yang sedang diuji: nilai stok per kategori.
WITH hasil AS (
    SELECT k.nama_kategori,
           COALESCE(SUM(p.harga * p.stok), 0) AS nilai_stok
    FROM kategori k
    LEFT JOIN produk p ON p.kategori = k.nama_kategori
    GROUP BY k.nama_kategori
)
SELECT nama_kategori, nilai_stok FROM hasil ORDER BY nama_kategori;

-- 2) Ekspektasi dihitung manual dari data fixture (tangan, bukan query).
WITH hasil AS (
    SELECT k.nama_kategori,
           COALESCE(SUM(p.harga * p.stok), 0) AS nilai_stok
    FROM kategori k
    LEFT JOIN produk p ON p.kategori = k.nama_kategori
    GROUP BY k.nama_kategori
),
ekspektasi AS (
    VALUES ('aksesoris', 0::bigint),
           ('lain-lain', 0::bigint),
           ('makanan',   262000::bigint),
           ('minuman',   144000::bigint)
)
SELECT 'ada di hasil, tidak di ekspektasi' AS arah_selisih, t.* FROM (
    SELECT * FROM hasil     EXCEPT SELECT * FROM ekspektasi
) t
UNION ALL
SELECT 'ada di ekspektasi, tidak di hasil', t.* FROM (
    SELECT * FROM ekspektasi EXCEPT SELECT * FROM hasil
) t
ORDER BY 1, 2;

-- 3) Verdict ringkas untuk satu pandangan.
WITH hasil AS (
    SELECT k.nama_kategori,
           COALESCE(SUM(p.harga * p.stok), 0) AS nilai_stok
    FROM kategori k
    LEFT JOIN produk p ON p.kategori = k.nama_kategori
    GROUP BY k.nama_kategori
),
ekspektasi AS (
    VALUES ('aksesoris', 0::bigint),
           ('lain-lain', 0::bigint),
           ('makanan',   262000::bigint),
           ('minuman',   144000::bigint)
),
selisih AS (
    SELECT 1 FROM hasil     EXCEPT SELECT 1 FROM ekspektasi
    UNION ALL
    SELECT 1 FROM ekspektasi EXCEPT SELECT 1 FROM hasil
)
SELECT CASE WHEN count(*) = 0
            THEN 'LULUS: hasil query identik dengan ekspektasi'
            ELSE 'GAGAL: ' || count(*) || ' baris selisih'
       END AS status_uji
FROM selisih;

Output yang Dihasilkan:

text
 nama_kategori | nilai_stok 
---------------+------------
 aksesoris     |          0
 lain-lain     |          0
 makanan       |     262000
 minuman       |     144000
(4 rows)

 arah_selisih | nama_kategori | nilai_stok 
--------------+---------------+------------
(0 rows)

                  status_uji                  
----------------------------------------------
 LULUS: hasil query identik dengan ekspektasi
(1 row)

Bedah bagian pentingnya:

  1. CTE hasil — query yang sedang diuji, disalin apa adanya. Menempatkannya di CTE membuat pengujian tidak mengubah query aslinya sama sekali.
  2. CTE ekspektasi lewat VALUES — empat baris hasil hitung manual. Tipe kolom kedua dipatok ::bigint agar cocok dengan keluaran SUM(integer) tanpa pemindaian tipe implisit.
  3. SELECT * FROM hasil EXCEPT SELECT * FROM ekspektasi — baris yang dihasilkan query tetapi tidak diantisipasi. Arah kedua menangkap kebalikannya: baris yang diantisipasi tetapi tidak dihasilkan.
  4. UNION ALL menggabungkan kedua arah dengan label arah_selisih — output 0 rows adalah tanda lulus; setiap baris yang muncul adalah bukti kegagalan yang langsung terbaca.
  5. Pernyataan ketiga meringkas menjadi verdict — count(*) = 0 pada himpunan selisih diterjemahkan menjadi teks LULUS atau GAGAL: N baris selisih.

Pola ini juga bekerja untuk membuktikan kegagalan. Bila ekspektasi sengaja diubah salah — misalnya minuman ditulis 150000 — selisih dua arah langsung memuntahkan dua baris (nilai aktual 144000 dan ekspektasi salah 150000) dan verdict berubah menjadi GAGAL. Itulah yang kita inginkan dari pengujian: gagal dengan informasi, bukan gagal diam-diam.

Asersi Skalar dengan CASE WHEN #

Untuk angka tunggal — jumlah baris, rata-rata, total — pola CASE WHEN lebih ringkas daripada EXCEPT: bandingkan aktual dengan ekspektasi, lalu keluarkan teks status yang menyebutkan nilai aktual saat gagal:

sql uji-scalar.sql
-- Asersi skalar dengan CASE WHEN: satu angka dibandingkan satu ekspektasi,
-- pesan kegagalan menyebutkan nilai aktual agar penyebabnya langsung terbaca.

SELECT CASE
         WHEN (SELECT count(*) FROM produk) = 5
         THEN 'LULUS: jumlah produk 5'
         ELSE 'GAGAL: ekspektasi 5, aktual ' || (SELECT count(*) FROM produk)::text
       END AS uji_jumlah_baris;

SELECT CASE
         WHEN (SELECT count(harga) FROM produk) = 4
         THEN 'LULUS: count(harga) mengabaikan 1 nilai NULL'
         ELSE 'GAGAL: ekspektasi 4, aktual ' || (SELECT count(harga) FROM produk)::text
       END AS uji_count_kolom;

SELECT CASE
         WHEN (SELECT avg(harga) FROM produk) = 12250
         THEN 'LULUS: avg(harga) = 12250 (NULL diabaikan, harga 0 tetap dihitung)'
         ELSE 'GAGAL: ekspektasi 12250, aktual ' || (SELECT avg(harga) FROM produk)::text
       END AS uji_rata_rata;

Output yang Dihasilkan:

text
    uji_jumlah_baris    
------------------------
 LULUS: jumlah produk 5
(1 row)

               uji_count_kolom                
----------------------------------------------
 LULUS: count(harga) mengabaikan 1 nilai NULL
(1 row)

                           uji_rata_rata                            
--------------------------------------------------------------------
 LULUS: avg(harga) = 12250 (NULL diabaikan, harga 0 tetap dihitung)
(1 row)

Perhatikan pesan kegagalannya: ekspektasi 4, aktual .... Pesan yang menyebut angka aktual menghemat satu putaran penyelidikan — pembaca pengujian langsung tahu seberapa jauh hasil meleset, bukan hanya bahwa ada yang meleset. Tiga asersi di atas sekaligus menguji dua jebakan agregat klasik: count(harga) hanya menghitung baris dengan nilai terisi (4, bukan 5), dan avg(harga) mengabaikan NULL tetapi tetap memasukkan harga 0 ke perhitungan.

Menguji Kasus Batas #

File terakhir merangkum kasus-kasus batas fixture menjadi satu laporan. Setiap cabang UNION ALL adalah satu kasus dengan verdict sendiri, sehingga satu pandangan mata cukup untuk membaca status seluruhnya:

-- Kasus batas yang wajib hadir di setiap fixture uji:
-- hasil kosong, NULL, dan batas nilai (harga 0).
SELECT 'filter tanpa kecocokan menghasilkan himpunan kosong' AS kasus,
       CASE WHEN (SELECT count(*) FROM produk WHERE kategori = 'elektronik') = 0
            THEN 'LULUS' ELSE 'GAGAL: ekspektasi 0 baris'
       END AS status
UNION ALL
SELECT 'perbandingan harga = NULL tidak pernah cocok',
       CASE WHEN (SELECT count(*) FROM produk WHERE harga = NULL) = 0
            THEN 'LULUS' ELSE 'GAGAL: ekspektasi 0 baris'
       END
UNION ALL
SELECT 'predikat harga IS NOT NULL menemukan 4 baris',
       CASE WHEN (SELECT count(*) FROM produk WHERE harga IS NOT NULL) = 4
            THEN 'LULUS' ELSE 'GAGAL: ekspektasi 4 baris'
       END
UNION ALL
SELECT 'kategori tanpa produk menghasilkan nilai 0, bukan NULL',
       CASE WHEN (SELECT COALESCE(SUM(harga * stok), 0) FROM produk WHERE kategori = 'aksesoris') = 0
            THEN 'LULUS' ELSE 'GAGAL: ekspektasi 0'
       END;

Output yang Dihasilkan:

text
                         kasus                          | status 
--------------------------------------------------------+--------
 filter tanpa kecocokan menghasilkan himpunan kosong    | LULUS
 perbandingan harga = NULL tidak pernah cocok           | LULUS
 predikat harga IS NOT NULL menemukan 4 baris           | LULUS
 kategori tanpa produk menghasilkan nilai 0, bukan NULL | LULUS
(4 rows)

Empat kasus tersebut bukan pilihan acak. Hasil kosong menguji jalur “tidak ada data” yang sering memicu error di lapisan aplikasi; perbandingan = NULL yang selalu gagal adalah bug paling sering lolos ke produksi; predikat IS NOT NULL menjadi pembandingnya; dan kategori tanpa produk menguji keputusan desain COALESCE pada agregat. Setiap query nyata punya kasus batas versinya sendiri — tugas perancang fixture adalah memburunya sebelum pembaca melakukannya.

Query yang teruji hanyalah separuh cerita: query membaca data, tetapi yang menjaga data dari kekacauan adalah constraint. Apakah CHECK, UNIQUE, dan foreign key di skema Anda benar-benar menolak data yang tidak sah? Chapter berikutnya mengujinya satu per satu — termasuk nilai-nilai batas yang paling sering bocor.

Referensi #

Berkas Sumber

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

buat-fixture.sql

Unduh berkas
sql
-- Fixture uji: data kecil, tetap, dan sengaja memuat kasus batas.
-- Kategori 'aksesoris' sengaja tidak punya produk; satu harga NULL; satu harga 0.
CREATE TABLE kategori (
    nama_kategori text PRIMARY KEY
);

CREATE TABLE produk (
    id       integer PRIMARY KEY,
    nama     text NOT NULL,
    kategori text NOT NULL REFERENCES kategori,
    harga    integer,
    stok     integer NOT NULL
);

INSERT INTO kategori VALUES ('makanan'), ('minuman'), ('aksesoris'), ('lain-lain');

INSERT INTO produk (id, nama, kategori, harga, stok) VALUES
    (1, 'Roti Tawar',     'makanan',  15000,  10),
    (2, 'Selai Kacang',   'makanan',  28000,   4),
    (3, 'Teh Kotak',      'minuman',   6000,  24),
    (4, 'Susu Kotak',     'minuman',   NULL,  12),
    (5, 'Kantong Belanja','lain-lain',     0, 100);

SELECT id, nama, kategori, harga, stok FROM produk ORDER BY id;

uji-edge-case.sql

Unduh berkas
sql
-- Kasus batas yang wajib hadir di setiap fixture uji:
-- hasil kosong, NULL, dan batas nilai (harga 0).
SELECT 'filter tanpa kecocokan menghasilkan himpunan kosong' AS kasus,
       CASE WHEN (SELECT count(*) FROM produk WHERE kategori = 'elektronik') = 0
            THEN 'LULUS' ELSE 'GAGAL: ekspektasi 0 baris'
       END AS status
UNION ALL
SELECT 'perbandingan harga = NULL tidak pernah cocok',
       CASE WHEN (SELECT count(*) FROM produk WHERE harga = NULL) = 0
            THEN 'LULUS' ELSE 'GAGAL: ekspektasi 0 baris'
       END
UNION ALL
SELECT 'predikat harga IS NOT NULL menemukan 4 baris',
       CASE WHEN (SELECT count(*) FROM produk WHERE harga IS NOT NULL) = 4
            THEN 'LULUS' ELSE 'GAGAL: ekspektasi 4 baris'
       END
UNION ALL
SELECT 'kategori tanpa produk menghasilkan nilai 0, bukan NULL',
       CASE WHEN (SELECT COALESCE(SUM(harga * stok), 0) FROM produk WHERE kategori = 'aksesoris') = 0
            THEN 'LULUS' ELSE 'GAGAL: ekspektasi 0'
       END;

uji-except.sql

Unduh berkas
sql
-- Uji hasil query dengan EXCEPT dua arah.
-- Pola: (hasil EXCEPT ekspektasi) UNION ALL (ekspektasi EXCEPT hasil).
-- Hasil uji kosong berarti kedua himpunan baris identik.

-- 1) Query yang sedang diuji: nilai stok per kategori.
WITH hasil AS (
    SELECT k.nama_kategori,
           COALESCE(SUM(p.harga * p.stok), 0) AS nilai_stok
    FROM kategori k
    LEFT JOIN produk p ON p.kategori = k.nama_kategori
    GROUP BY k.nama_kategori
)
SELECT nama_kategori, nilai_stok FROM hasil ORDER BY nama_kategori;

-- 2) Ekspektasi dihitung manual dari data fixture (tangan, bukan query).
WITH hasil AS (
    SELECT k.nama_kategori,
           COALESCE(SUM(p.harga * p.stok), 0) AS nilai_stok
    FROM kategori k
    LEFT JOIN produk p ON p.kategori = k.nama_kategori
    GROUP BY k.nama_kategori
),
ekspektasi AS (
    VALUES ('aksesoris', 0::bigint),
           ('lain-lain', 0::bigint),
           ('makanan',   262000::bigint),
           ('minuman',   144000::bigint)
)
SELECT 'ada di hasil, tidak di ekspektasi' AS arah_selisih, t.* FROM (
    SELECT * FROM hasil     EXCEPT SELECT * FROM ekspektasi
) t
UNION ALL
SELECT 'ada di ekspektasi, tidak di hasil', t.* FROM (
    SELECT * FROM ekspektasi EXCEPT SELECT * FROM hasil
) t
ORDER BY 1, 2;

-- 3) Verdict ringkas untuk satu pandangan.
WITH hasil AS (
    SELECT k.nama_kategori,
           COALESCE(SUM(p.harga * p.stok), 0) AS nilai_stok
    FROM kategori k
    LEFT JOIN produk p ON p.kategori = k.nama_kategori
    GROUP BY k.nama_kategori
),
ekspektasi AS (
    VALUES ('aksesoris', 0::bigint),
           ('lain-lain', 0::bigint),
           ('makanan',   262000::bigint),
           ('minuman',   144000::bigint)
),
selisih AS (
    SELECT 1 FROM hasil     EXCEPT SELECT 1 FROM ekspektasi
    UNION ALL
    SELECT 1 FROM ekspektasi EXCEPT SELECT 1 FROM hasil
)
SELECT CASE WHEN count(*) = 0
            THEN 'LULUS: hasil query identik dengan ekspektasi'
            ELSE 'GAGAL: ' || count(*) || ' baris selisih'
       END AS status_uji
FROM selisih;

uji-scalar.sql

Unduh berkas
sql
-- Asersi skalar dengan CASE WHEN: satu angka dibandingkan satu ekspektasi,
-- pesan kegagalan menyebutkan nilai aktual agar penyebabnya langsung terbaca.

SELECT CASE
         WHEN (SELECT count(*) FROM produk) = 5
         THEN 'LULUS: jumlah produk 5'
         ELSE 'GAGAL: ekspektasi 5, aktual ' || (SELECT count(*) FROM produk)::text
       END AS uji_jumlah_baris;

SELECT CASE
         WHEN (SELECT count(harga) FROM produk) = 4
         THEN 'LULUS: count(harga) mengabaikan 1 nilai NULL'
         ELSE 'GAGAL: ekspektasi 4, aktual ' || (SELECT count(harga) FROM produk)::text
       END AS uji_count_kolom;

SELECT CASE
         WHEN (SELECT avg(harga) FROM produk) = 12250
         THEN 'LULUS: avg(harga) = 12250 (NULL diabaikan, harga 0 tetap dihitung)'
         ELSE 'GAGAL: ekspektasi 12250, aktual ' || (SELECT avg(harga) FROM produk)::text
       END AS uji_rata_rata;