Pengujian

Asersi dan test harness sederhana untuk kode Lua proyek.

Daftar isi

Topik ini membangun pengujian untuk mesin konfigurasi Pencatat, lengkap dengan kerangka uji (test harness) yang dibuat sendiri. Tujuan pengujian bukan sekadar membuktikan kode berjalan, melainkan mengunci perilaku fail-safe mesin, yaitu pelanggaran dilaporkan, nilai bawaan digunakan, dan error terstruktur dikembalikan, sehingga perubahan berikutnya pada mesin dapat diverifikasi ulang dengan cepat.

Kenapa kerangka uji dibuat sendiri#

Lua tidak menyertakan kerangka pengujian dalam pustaka standarnya, dan proyek tersemat menambah alasan yang lebih kuat untuk tidak memasang dependensi eksternal. Skrip yang berjalan di dalam host sering kali dilarang memuat pustaka pihak ketiga, sebagaimana skrip Redis yang hanya dapat memanggil API yang disediakan hostnya. Kerangka uji untuk proyek semacam ini pun harus berupa kode Lua polos yang berjalan dengan penerjemah mandiri maupun di dalam sandbox host.

Kerangka yang dibuat sendiri juga cukup kecil untuk dibaca sekaligus. Asersi standar Lua, yaitu assert(nilai, pesan), mengangkat error bila nilai yang diperiksa bernilai nil atau false, sehingga bentuknya berguna untuk pemeriksaan sederhana. Namun, kerangka proyek memerlukan perilaku yang lebih spesifik, yaitu asersi kesamaan yang mampu membandingkan table per kunci dan melaporkan kunci yang berbeda tanpa menampilkan table mentah. Kebutuhan inilah yang mendorong pembuatan asersi lokal.

Kerangka harness#

Berkas uji-mesin.lua memuat mesin, mendefinisikan kerangkanya, lalu menjalankan seluruh kasus uji di akhir berkas. Salinan mesin.lua versi terbaru menyertai berkas ini agar pengujian berdiri sendiri.

lua uji-mesin.lua
-- uji-mesin.lua
-- Test harness sederhana untuk mesin konfigurasi aplikasi Pencatat.

local mesin = dofile("mesin.lua")

local jumlah = 0
local lulus = 0
local gagal = 0

-- Menjalankan satu kasus uji secara terlindungi dan mencatat statusnya.
local function uji(nama, fungsi_uji)
  jumlah = jumlah + 1
  local ok, pesan = pcall(fungsi_uji)
  if ok then
    lulus = lulus + 1
    print("LULUS  " .. nama)
  else
    gagal = gagal + 1
    print("GAGAL  " .. nama)
    print("       " .. tostring(pesan))
  end
end

-- Asersi lokal, yaitu mengangkat error level 0 bila kedua nilai berbeda.
-- Table sederhana dibandingkan per kunci secara berulang.
local function asersi_sama(nilai, harapan)
  if type(nilai) == "table" and type(harapan) == "table" then
    for kunci, isi in pairs(harapan) do
      asersi_sama(nilai[kunci], isi)
    end
    for kunci in pairs(nilai) do
      if harapan[kunci] == nil then
        error("kunci " .. tostring(kunci) .. " tidak diharapkan", 0)
      end
    end
    return
  end
  if nilai ~= harapan then
    error(string.format("nilai %s seharusnya %s",
      tostring(nilai), tostring(harapan)), 0)
  end
end

-- Kode kegagalan pada teks pelanggaran pertama dari daftar terurut.
local function kode_pertama(pelanggaran)
  return pelanggaran[1]:match("^[A-Z_]+")
end

-- Kasus uji: seluruh kunci sah diterima apa adanya.
uji("konfigurasi normal penuh", function()
  local final, pelanggaran = mesin.muat_konfigurasi({
    tema = { nama = "terang", warna = "#f7f7f7", ukuran_teks = 16 },
    aturan = { batas_panjang = 5000, pola_berkas = "catatan%d+" },
    konten = { label = "Catatan Harian" },
  })
  asersi_sama(#pelanggaran, 0)
  asersi_sama(final, {
    tema = { nama = "terang", warna = "#f7f7f7", ukuran_teks = 16 },
    aturan = { batas_panjang = 5000, pola_berkas = "catatan%d+" },
    konten = { label = "Catatan Harian" },
  })
end)

-- Kasus uji: kunci di dalam grup tema yang tidak ada pada skema dilaporkan.
uji("kunci tema tidak dikenal", function()
  local final, pelanggaran = mesin.muat_konfigurasi({
    tema = { nama = "gelap", latar = "#222222" },
  })
  asersi_sama(#pelanggaran, 1)
  asersi_sama(kode_pertama(pelanggaran), "KUNCI_TAK_DIKENAL")
  asersi_sama(final.tema.nama, "gelap")
end)

-- Kasus uji: nilai di bawah batas bawah digantikan default.
uji("ukuran_teks terlalu kecil", function()
  local final, pelanggaran = mesin.muat_konfigurasi({
    tema = { ukuran_teks = 5 },
  })
  asersi_sama(#pelanggaran, 1)
  asersi_sama(kode_pertama(pelanggaran), "NILAI_DI_LUAR_BATAS")
  asersi_sama(final.tema.ukuran_teks, 14)
end)

-- Kasus uji: tipe yang salah digantikan default.
uji("tipe salah pada batas_panjang", function()
  local final, pelanggaran = mesin.muat_konfigurasi({
    aturan = { batas_panjang = "seribu" },
  })
  asersi_sama(#pelanggaran, 1)
  asersi_sama(kode_pertama(pelanggaran), "TIPE_SALAH")
  asersi_sama(final.aturan.batas_panjang, 1000)
end)

-- Kasus uji: kandidat kosong menghasilkan seluruh nilai default.
uji("kandidat kosong", function()
  local final, pelanggaran = mesin.muat_konfigurasi({})
  asersi_sama(#pelanggaran, 0)
  asersi_sama(final, {
    tema = { nama = "gelap", warna = "#1e1e2e", ukuran_teks = 14 },
    aturan = { batas_panjang = 1000, pola_berkas = "catatan%d+" },
    konten = { label = "Catatan" },
  })
end)

-- Kasus uji: muat_aman menangkap kandidat nil dan melaporkan error terstruktur.
uji("muat_aman menangkap kandidat nil", function()
  local sukses, hasil = mesin.muat_aman(nil)
  asersi_sama(sukses, false)
  asersi_sama(hasil.kode, "KANDIDAT_TIDAK_SAH")
  asersi_sama(type(hasil.pesan), "string")
end)

print(string.format("ringkasan: %d lulus, %d gagal dari %d uji",
  lulus, gagal, jumlah))
os.exit(gagal == 0 and 0 or 1)
text Keluaran
LULUS  konfigurasi normal penuh
LULUS  kunci tema tidak dikenal
LULUS  ukuran_teks terlalu kecil
LULUS  tipe salah pada batas_panjang
LULUS  kandidat kosong
LULUS  muat_aman menangkap kandidat nil
ringkasan: 6 lulus, 0 gagal dari 6 uji
lua mesin.lua
-- mesin.lua
-- Inti mesin konfigurasi aplikasi Pencatat.
-- Versi ini menambahkan muat_aman, yaitu pelaksanaan terlindungi di tepi
-- lapisan skrip. Perilaku muat_konfigurasi dan tampilkan tidak berubah.

local mesin = {}

-- Skema konfigurasi, yaitu tiga grup kunci sah beserta tipe, default, dan batas.
local SKEMA = {
  tema = {
    nama        = { tipe = "string",  default = "gelap" },
    warna       = { tipe = "string",  default = "#1e1e2e" },
    ukuran_teks = { tipe = "integer", default = 14, minimum = 8, maksimum = 32 },
  },
  aturan = {
    batas_panjang = { tipe = "integer", default = 1000, minimum = 1, maksimum = 100000 },
    pola_berkas   = { tipe = "string",  default = "catatan%d+" },
  },
  konten = {
    label = { tipe = "string", default = "Catatan" },
  },
}

-- Nama tipe yang ditampilkan pada pesan pelanggaran.
local function nama_tipe(tipe)
  if tipe == "integer" then
    return "bilangan bulat"
  end
  return tipe
end

-- Nama tipe aktual dari sebuah nilai untuk keperluan pesan pelanggaran.
local function nama_tipe_nilai(nilai)
  local t = type(nilai)
  if t == "number" then
    return math.type(nilai) == "integer" and "bilangan bulat" or "bilangan pecahan"
  end
  return t
end

-- Memeriksa kesesuaian tipe nilai terhadap aturan skemanya.
local function tipe_sesuai(aturan, nilai)
  if aturan.tipe == "integer" then
    return type(nilai) == "number" and math.type(nilai) == "integer"
  end
  return type(nilai) == aturan.tipe
end

-- Memvalidasi satu nilai dan mengembalikan teks pelanggaran atau nil.
local function validasi_kunci(jalur, aturan, nilai)
  if not tipe_sesuai(aturan, nilai) then
    return string.format(
      "TIPE_SALAH: kunci %s seharusnya bertipe %s, tetapi bertipe %s",
      jalur, nama_tipe(aturan.tipe), nama_tipe_nilai(nilai)
    )
  end
  if aturan.tipe == "integer" and (nilai < aturan.minimum or nilai > aturan.maksimum) then
    return string.format(
      "NILAI_DI_LUAR_BATAS: kunci %s seharusnya bernilai antara %d dan %d",
      jalur, aturan.minimum, aturan.maksimum
    )
  end
  return nil
end

-- Menggabungkan nilai default skema dengan kandidat konfigurasi.
-- Kandidat tidak pernah dimodifikasi, hasilnya selalu table baru.
-- Kandidat yang bukan table mengangkat error terstruktur dengan level 0.
function mesin.muat_konfigurasi(kandidat)
  if type(kandidat) ~= "table" then
    error({ kode = "KANDIDAT_TIDAK_SAH",
      pesan = string.format(
        "kandidat konfigurasi seharusnya table, tetapi bertipe %s", type(kandidat)
      ) }, 0)
  end
  local final = {}
  local pelanggaran = {}
  for nama_grup, skema_grup in pairs(SKEMA) do
    final[nama_grup] = {}
    local grup = kandidat[nama_grup]
    if grup ~= nil and type(grup) ~= "table" then
      table.insert(pelanggaran, string.format(
        "TIPE_SALAH: grup %s seharusnya table, tetapi bertipe %s",
        nama_grup, nama_tipe_nilai(grup)))
      grup = nil
    end
    for nama_kunci, aturan in pairs(skema_grup) do
      local nilai = nil
      if grup ~= nil then
        nilai = grup[nama_kunci]
      end
      if nilai == nil then
        final[nama_grup][nama_kunci] = aturan.default
      else
        local temuan = validasi_kunci(nama_grup .. "." .. nama_kunci, aturan, nilai)
        if temuan == nil then
          final[nama_grup][nama_kunci] = nilai
        else
          table.insert(pelanggaran, temuan)
          final[nama_grup][nama_kunci] = aturan.default
        end
      end
    end
  end
  for nama_grup, grup in pairs(kandidat) do
    if SKEMA[nama_grup] == nil then
      table.insert(pelanggaran, string.format(
        "KUNCI_TAK_DIKENAL: grup %s tidak dikenal", nama_grup))
    elseif type(grup) == "table" then
      for nama_kunci in pairs(grup) do
        if SKEMA[nama_grup][nama_kunci] == nil then
          table.insert(pelanggaran, string.format(
            "KUNCI_TAK_DIKENAL: kunci %s.%s tidak dikenal", nama_grup, nama_kunci))
        end
      end
    end
  end
  table.sort(pelanggaran)
  return final, pelanggaran
end

-- Membungkus muat_konfigurasi dengan pcall agar error tidak menyebar ke host.
-- Bila sukses, hasil berupa table konfigurasi final, sedangkan bila gagal
-- hasil berupa table error terstruktur { kode = "...", pesan = "..." }.
function mesin.muat_aman(kandidat)
  local ok, hasil = pcall(mesin.muat_konfigurasi, kandidat)
  if ok then
    return true, hasil
  end
  if type(hasil) == "table" and hasil.kode ~= nil then
    return false, hasil
  end
  return false, { kode = "ERROR_TAK_TERDUGA", pesan = tostring(hasil) }
end

-- Mencetak laporan ringkas konfigurasi final dengan urutan yang tetap.
function mesin.tampilkan(final)
  print(string.format("%-22s %s", "tema.nama", final.tema.nama))
  print(string.format("%-22s %s", "tema.warna", final.tema.warna))
  print(string.format("%-22s %s", "tema.ukuran_teks", final.tema.ukuran_teks))
  print(string.format("%-22s %s", "aturan.batas_panjang", final.aturan.batas_panjang))
  print(string.format("%-22s %s", "aturan.pola_berkas", final.aturan.pola_berkas))
  print(string.format("%-22s %s", "konten.label", final.konten.label))
end

return mesin

Tiga bagian kerangka menentukan bentuk pengujian. Fungsi uji(nama, fungsi_uji) menjalankan satu kasus di dalam pcall, menaikkan penghitungnya, dan mencetak status LULUS atau GAGAL beserta pesan errornya. Fungsi asersi_sama(nilai, harapan) membandingkan nilai tunggal maupun table sederhana per kunci secara berulang dan mengangkat error level 0, sehingga pesannya tidak memuat prefiks posisi berkas yang tidak stabil. Fungsi kode_pertama(pelanggaran) mengambil kode kegagalan dari teks pelanggaran pertama yang terurut, karena mesin menjamin urutan daftar pelanggarannya melalui table.sort.

Baris terakhir berkas menggunakan os.exit(gagal == 0 and 0 or 1), sehingga kode keluar pengujian menyatakan keberhasilannya secara eksplisit. Konvensi ini memungkinkan pengujian dijalankan oleh alat otomatis, sebagaimana kode keluar menjadi kontrak standar alat uji pada umumnya.

Kasus uji yang dipilih#

Enam kasus uji menjaga perilaku yang paling menentukan. Kasus konfigurasi normal penuh memastikan kandidat sah diterima apa adanya tanpa satu pun pelanggaran. Kasus kunci tema tidak dikenal memastikan kunci di luar skema dilaporkan dengan kode KUNCI_TAK_DIKENAL dan tidak menggagalkan kunci lainnya. Kasus ukuran_teks terlalu kecil dan tipe salah pada batas_panjang mengunci dua kategori pelanggaran, yaitu NILAI_DI_LUAR_BATAS dan TIPE_SALAH, beserta penggantian nilainya dengan nilai bawaan. Kasus kandidat kosong menegaskan bahwa table kosong menghasilkan seluruh nilai bawaan tanpa pelanggaran. Kasus muat_aman menangkap kandidat nil menguji jalur kegagalan yang mengangkat error terstruktur, yaitu status false dengan kode KANDIDAT_TIDAK_SAH.

Pemilihan kasus mengikuti prinsip menguji kontrak, bukan menguji implementasi. Setiap kasus menyatakan perilaku yang dijanjikan mesin kepada penggunanya, sehingga implementasi mesin boleh ditulis ulang selama kasusnya tetap lulus.

Menjalankan pengujian#

Pengujian dijalankan dengan satu perintah berikut, dan keluarannya persis seperti blok Keluaran di bawahnya.

bash
# jalankan seluruh kasus uji mesin
lua uji-mesin.lua
text Keluaran
LULUS  konfigurasi normal penuh
LULUS  kunci tema tidak dikenal
LULUS  ukuran_teks terlalu kecil
LULUS  tipe salah pada batas_panjang
LULUS  kandidat kosong
LULUS  muat_aman menangkap kandidat nil
ringkasan: 6 lulus, 0 gagal dari 6 uji

Keluaran menunjukkan enam kasus lulus tanpa kegagalan, disusul ringkasan yang memuat jumlah lulus, jumlah gagal, dan total kasus. Bila salah satu kasus gagal, baris GAGAL beserta pesan errornya muncul sebelum ringkasan, dan kode keluar berkas berubah menjadi bukan nol. Cara membaca keluaran semacam ini cepat, karena ringkasan baris terakhirnya sudah cukup untuk menilai kondisi mesin secara keseluruhan.

Kesalahan umum#

Kesalahan pertama adalah menguji detail implementasi alih-alih kontrak. Kasus uji yang membandingkan urutan iterasi internal atau bentuk pesan yang tidak dijanjikan akan rusak setiap kali implementasi ditulis ulang, meskipun perilaku yang dijanjikan tidak berubah. Kasus uji yang baik memeriksa nilai balik, kode kegagalan, dan efek samping yang terdokumentasi, sehingga implementasi boleh berubah tanpa menggagalkan pengujian yang sah.

Kesalahan kedua adalah asersi yang selalu lulus. Pemeriksaan berbentuk assert(nilai) pada table memang selalu berhasil karena table apa pun bernilai benar, sehingga kasus yang seharusnya membandingkan isi table malah tidak memeriksa apa pun. Asersi lokal asersi_sama pada kerangka ini ada justru untuk menghindari jebakan tersebut, dan setiap asersi harus menyatakan nilai harapan yang konkret.

Kesalahan ketiga adalah kasus uji yang berjalan tanpa pelindung. Satu kasus yang mengangkat error di luar pcall menghentikan seluruh berkas pengujian, sehingga kasus setelahnya tidak pernah dijalankan dan jumlah kasus yang benar-benar diuji berkurang diam-diam. Fungsi uji pada kerangka ini membungkus setiap kasus dengan pcall untuk alasan tersebut, dan kasus baru selalu ditambahkan melalui fungsi itu, bukan dengan pemanggilan langsung.

Ringkasan#

  • Kerangka uji proyek dibuat sendiri dalam satu berkas Lua polos, karena proyek tersemat tidak boleh bergantung pada pustaka pengujian eksternal.
  • Fungsi uji membungkus setiap kasus dengan pcall, asersi lokal membandingkan nilai dan table per kunci dengan error level 0, dan kode_pertama menguji kode kegagalan dari daftar pelanggaran terurut.
  • Enam kasus uji mengunci kontrak mesin, yaitu kandidat sah diterima, pelanggaran dilaporkan dengan kodenya, nilai bawaan digunakan, dan error terstruktur dikembalikan oleh muat_aman.
  • os.exit menyatakan keberhasilan pengujian melalui kode keluar, sehingga pengujian dapat dijalankan oleh alat otomatis.

Referensi#