Testing Generatif

Menguji properti secara generatif dengan test.check yang menghasilkan data dari spec.

Daftar isi

Generative testing, pengujian generatif, menguji pernyataan yang berlaku untuk seluruh wilayah data alih-alih beberapa contoh yang disusun manual. Pustaka test.check menghasilkan ratusan data uji secara acak, menjalankan property, yaitu pernyataan sifat yang diharapkan berlaku untuk semua data, lalu memangkas setiap kasus gagal menjadi bentuk terkecilnya. Topik ini membahas generator dan parameter ukuran, pendefinisian properti dengan prop/for-all, eksekusi dengan quick-check beserta seed, pemangkasan kasus gagal, serta spec sebagai sumber generator. Contohnya menguji sifat penjumlahan terhadap pembalikan urutan pada data skor ujian.

Menyiapkan pustaka test.check#

test.check bukan bagian dari pustaka inti Clojure, sehingga dependensinya dideklarasikan pada berkas deps.edn proyek. Clojure CLI membaca berkas tersebut setiap kali clojure -M dijalankan dari direktori proyek, dan dependensi berikut dipatok pada versi 1.1.1 agar keluaran pada tulisan ini dapat direproduksi.

edn deps.edn
{:deps {org.clojure/test.check {:mvn/version "1.1.1"}}}

Simpan berkas di atas di direktori proyek, lalu jalankan seluruh contoh topik ini dari direktori yang sama sehingga dependensinya ikut terbaca. Karena versi tercatat pada deps.edn, kode pengujian tidak perlu mendeklarasikan versi apa pun, dan peningkatan versi cukup dilakukan pada satu tempat. Pola yang sama berlaku untuk semua proyek Clojure berbasis Clojure CLI yang dibahas pada topik Perkakas Proyek.

Generator dan ukuran#

Generator adalah pembangkit nilai yang menerima satu parameter ukuran dan menghasilkan data yang besarnya mengikuti ukuran tersebut. Generator dasar seperti gen/nat menghasilkan bilangan bulat non-negatif, sedangkan gen/vector menggabungkan generator lain menjadi vector yang panjangnya dibatasi ukuran. Ukuran inilah yang membuat data uji tumbuh bertahap dari nilai kecil menuju nilai besar seiring banyaknya pengujian. Fungsi gen/sample memanggil generator berkali-kali dengan ukuran menaik mulai dari nol, sedangkan gen/generate memanggilnya satu kali dengan ukuran yang diberikan.

clojure Contoh: sample dan generate dengan ukuran
(require '[clojure.test.check.generators :as gen])

(def gen-deret
  (gen/sized (fn [ukuran] (gen/return (vec (range ukuran))))))

(println "sample 4         :" (gen/sample gen-deret 4))
(println "generate ukuran 6:" (gen/generate gen-deret 6))
text Keluaran
sample 4         : ([] [0] [0 1] [0 1 2])
generate ukuran 6: [0 1 2 3 4 5]

Keluaran contoh ini sengaja dapat direproduksi. Generator gen/sized menerima fungsi yang mengubah ukuran menjadi generator, dan gen/return menghasilkan satu nilai tetap, sehingga isi deret hanya ditentukan ukurannya. Generator sungguhan seperti gen/nat atau gen/vector menggunakan keacakan, sehingga REPL menampilkan data berbeda pada setiap sesi. Pada contoh-contoh berikutnya generator acak itulah yang digunakan, dan yang ditampilkan hanyalah hasil pemeriksaan, bukan data mentahnya.

Properti dan quick-check#

Properti didefinisikan dengan prop/for-all, yang mengikat nama ke sebuah generator lalu menerima pernyataan boolean atas data hasilnya. Properti berikut menyatakan bahwa menjumlahkan vector lalu membalik urutannya tidak pernah mengubah total, sifat yang berlaku untuk vector bilangan apa pun dan bukan untuk satu contoh tertentu. Eksekusinya dilakukan oleh quick-check dari namespace clojure.test.check dengan jumlah pengujian sebagai argumen pertama.

clojure Contoh: properti total tetap setelah pembalikan urutan
(require '[clojure.test.check :as tc]
         '[clojure.test.check.generators :as gen]
         '[clojure.test.check.properties :as prop])

(def prop-total-tetap
  (prop/for-all [v (gen/vector gen/nat)]
    (= (reduce + v) (reduce + (reverse v)))))

(def hasil
  (tc/quick-check 100 prop-total-tetap :seed 42))

(prn (select-keys hasil [:result :pass? :num-tests :seed]))
text Keluaran
{:result true, :pass? true, :num-tests 100, :seed 42}

Hasil quick-check berupa map. Kunci :result menyatakan tidak ada pernyataan yang terbantahkan, :pass? menandai keberhasilan keseluruhan, dan :num-tests menyebut seratus data uji yang dijalankan. Argumen :seed 42 mematok seed, angka awal keacakan, sehingga rangkaian data ujinya sama pada setiap eksekusi dan keluaran di atas dapat direproduksi. Kunci :time-elapsed-ms juga ada pada hasilnya, tetapi tidak ikut dicetak karena nilainya bergantung kecepatan mesin. Apabila properti ingin dijalankan oleh test runner clojure.test, properti yang sama cukup dibungkus dengan defspec dari namespace clojure.test.check.clojure-test, sedangkan mekanisme eksekusinya tetap quick-check yang sama.

Pemangkasan kasus gagal#

Ketika properti terbantahkan, test.check tidak berhenti pada data gagal pertama, melainkan melakukan shrinking, pemangkasan data gagal menjadi bentuk terkecil yang masih gagal. Kemampuan ini menentukan nilai diagnostik pengujian generatif, karena kasus terkecil hampir selalu lebih mudah diperiksa daripada vector berisi puluhan bilangan. Properti berikut sengaja dibuat gagal, yaitu setiap elemen vector harus genap, sehingga kehadiran satu bilangan ganjil saja sudah membatahkan seluruh properti.

clojure Contoh: kasus gagal dipangkas menjadi terkecil
(require '[clojure.test.check :as tc]
         '[clojure.test.check.generators :as gen]
         '[clojure.test.check.properties :as prop])

(def prop-semua-genap
  (prop/for-all [v (gen/vector gen/nat)]
    (every? even? v)))

(let [res (tc/quick-check 100 prop-semua-genap :seed 42)]
  (println "result  :" (:result res))
  (println "smallest:" (-> res :shrunk :smallest)))
text Keluaran
result  : false
smallest: [[1]]

Keluaran memperlihatkan :result false dan :smallest berupa vector berisi satu bilangan, yaitu 1. Data gagal aslinya kemungkinan berisi banyak elemen, tetapi proses pemangkasan menyusutkannya berulang kali sampai tidak ada elemen yang bisa dibuang tanpa menghilangkan kegagalan. Dengan seed yang dipatok, kasus terkecil tersebut sama pada setiap eksekusi. Kasus terkecil inilah yang sebaiknya dilaporkan, karena membuktikan properti salah dengan contoh sesederhana mungkin.

Generator dari spec#

spec dan test.check saling terhubung. Fungsi s/gen mengambil spec yang sudah didefinisikan dan menghasilkan generator dari deskripsinya, sehingga properti tidak perlu mendefinisikan generator dua kali untuk data yang sudah punya spec. Fungsi s/exercise yang diperkenalkan pada topik spec.alpha akhirnya dapat dieksekusi di sini karena test.check sudah berada di classpath. Kedua penggunaan tersebut diperlihatkan sekaligus pada contoh berikut, lengkap dengan override generator tetap agar keluarannya dapat direproduksi. Sesuai kontrak opsi override s/exercise, peta override memetakan nama spec ke fungsi tanpa argumen yang menghasilkan generator.

clojure Contoh: s/exercise dan generator dari s/gen
(require '[clojure.spec.alpha :as s]
         '[clojure.test.check :as tc]
         '[clojure.test.check.generators :as gen]
         '[clojure.test.check.properties :as prop])

(s/def :penilaian/nilai (s/int-in 0 100))

(def gen-nilai-tetap
  #(gen/sized (fn [ukuran] (gen/return (nth [55 70 88] (mod ukuran 3))))))

(prn (s/exercise :penilaian/nilai 3 {:penilaian/nilai gen-nilai-tetap}))

(def prop-sesuai-spec
  (prop/for-all [n (s/gen :penilaian/nilai)]
    (s/valid? :penilaian/nilai n)))

(prn (select-keys (tc/quick-check 100 prop-sesuai-spec :seed 42)
                  [:result :pass? :num-tests :seed]))
text Keluaran
([55 55] [70 70] [88 88])
{:result true, :pass? true, :num-tests 100, :seed 42}

Baris pertama adalah keluaran s/exercise: tiga pasangan berupa contoh dan bentuk conform-nya, dihasilkan oleh generator tetap yang isinya mengikuti ukuran. Baris kedua menunjukkan properti yang generatornya berasal dari s/gen, dan pernyataannya menggunakan s/valid? atas spec yang sama. Setiap data yang dihasilkan generator spec selalu lolos spec-nya sendiri, sehingga properti ini lulus seratus pengujian. Pola tersebut bermanfaat ketika spec menjadi kontrak data bersama dan generatornya hanya turunan dari kontrak itu.

Kesalahan umum#

  • Menyalin logika implementasi ke dalam properti, misalnya membandingkan dua kali perhitungan fungsi yang sama. Properti tersebut lulus atau gagal bersama implementasinya sehingga tidak menguji apa pun. Rumuskan sifat yang berdiri sendiri, misalnya invarian jumlah, hasil round-trip, atau batas rentang.
  • Menjalankan quick-check tanpa :seed lalu kebingungan ketika kasus gagal tidak muncul lagi pada eksekusi berikutnya. Rangkaian data acak berubah setiap eksekusi. Perbaikannya dengan membaca :seed dari hasil yang gagal, lalu mengulang eksekusi dengan seed tersebut.
  • Menganggap seratus pengujian yang lulus berarti properti terbukti. quick-check menguji sampel, bukan seluruh wilayah data. Tingkatkan :num-tests untuk domain yang risikonya tinggi dan lengkapi propertinya dengan batas-batas yang diketahui.

Ringkasan#

  • test.check menguji property atas data yang dihasilkan generator, dengan ukuran yang mengatur besaran data dari kecil ke besar.
  • prop/for-all mendefinisikan properti dan quick-check mengeksekusinya, dengan :seed yang dipatok agar hasil dapat direproduksi.
  • Kasus gagal dipangkas secara otomatis menjadi :smallest, contoh terkecil yang masih membantahkan properti.
  • s/gen menghasilkan generator dari spec dan s/exercise menghasilkan pasangan contoh beserta conform-nya, sehingga spec dan pengujian generatif menggunakan deskripsi yang sama.

Referensi#