QuickCheck

Properti, generator, dan shrinking pada pengujian berbasis properti.

Daftar isi

Unit test menguji kasus yang ditulis satu per satu, sedangkan pengujian berbasis properti (property-based testing) menyatakan sifat yang seharusnya berlaku untuk segenap masukan dan membiarkan pustaka mencari pelanggarannya. QuickCheck adalah pustaka yang mempopulerkan cara kerja ini pada Haskell, yaitu menghasilkan ratusan kasus secara acak, melaporkan pelanggaran pertama, lalu menyusutkannya menjadi contoh sekecil mungkin. Topik ini membahas penulisan properti, penanganan properti yang gagal, generator khusus, dan mekanisme shrinking.

QuickCheck bukan bagian dari pustaka standar, sehingga dipasang sekali melalui Cabal sebelum contoh-contohnya dijalankan. Setelah pemasangan selesai, modul Test.QuickCheck dapat diimpor dari program apa pun pada mesin tersebut.

bash
# tidak-dijalankan
# pasang QuickCheck ke lingkungan paket pengguna
cabal install --lib QuickCheck

Properti pertama#

Properti ditulis sebagai fungsi biasa yang mengembalikan Bool, dengan nama yang diawali prop_ mengikuti konvensi penamaan. QuickCheck menghasilkan argumennya melalui type class Arbitrary, menjalankan fungsinya, dan menghitung jumlah kasus yang lolos. Dua properti berikut menyatakan sifat yang seharusnya selalu benar untuk list Int apa pun.

haskell prop-balik.hs
-- Dua properti untuk fungsi list standar
import Test.QuickCheck
import Data.List (sort)

-- Membalik dua kali mengembalikan list semula
prop_balikDuaKali :: [Int] -> Bool
prop_balikDuaKali xs = reverse (reverse xs) == xs

-- Mengurutkan tidak mengubah jumlah elemen
prop_panjangTetap :: [Int] -> Bool
prop_panjangTetap xs = length (sort xs) == length xs

main :: IO ()
main = do
  quickCheck prop_balikDuaKali
  quickCheck prop_panjangTetap
text Keluaran
+++ OK, passed 100 tests.
+++ OK, passed 100 tests.

Keluaran mencatat bahwa masing-masing properti lolos terhadap seratus kasus yang dihasilkan secara acak. Jumlah seratus itu berasal dari pengaturan bawaan dan cukup untuk properti sederhana, sedangkan properti yang lebih mahal untuk dihitung dapat dijalankan dengan jumlah kasus yang lebih sedikit. Perhatikan bahwa tidak ada satu kasus pun yang ditulis tangan, karena yang dinyatakan adalah sifatnya, bukan contohnya. Properti prop_balikDuaKali juga menggambarkan bentuk yang lazim, yaitu operasi yang dilakukan lalu dibatalkan akan mengembalikan keadaan semula.

Properti yang gagal dan shrinking#

Pelajaran terpenting muncul ketika properti keliru. Properti berikut menyatakan bahwa penggandaan tidak pernah mengecilkan nilai, dan pernyataan itu sebenarnya salah untuk bilangan negatif maupun nilai yang meluap. Benih (seed) acak ditetapkan melalui replay agar kasus yang dilaporkan tetap sama setiap eksekusi, sehingga contoh pada topik ini dapat dibandingkan dengan mudah.

haskell prop-gagal.hs
-- Properti keliru yang dilaporkan QuickCheck dengan contoh tersusutkan
import Test.QuickCheck
import Test.QuickCheck.Random (mkQCGen)

prop_menggandakan :: Int -> Bool
prop_menggandakan n = n * 2 >= n

main :: IO ()
main =
  quickCheckWith stdArgs { replay = Just (mkQCGen 2024, 0) } prop_menggandakan
text Keluaran
*** Failed! Falsified (after 3 tests and 1 shrink):
-1

QuickCheck menemukan pelanggaran pada kasus ketiga, kemudian menyusutkannya selama satu langkah menjadi -1 sebelum berhenti. Angka -1 merupakan contoh pelanggar yang paling ringkas, karena -1 * 2 = -2 dan -2 memang lebih kecil dari -1, sedangkan penyusutan lebih jauh tidak menghasilkan pelanggar yang lebih kecil. Tanpa penambahan replay, benih acaknya berubah setiap eksekusi dan contoh pelanggar yang dilaporkan pun ikut berubah, meskipun kegagalan itu sendiri tetap ditemukan. Pada pekerjaan sehari-hari, keluaran semacam ini dijadikan unit test untuk kasus yang ditemukan, sehingga perbaikannya terkunci permanen.

Generator khusus#

Type class Arbitrary dapat ditulis sendiri untuk tipe domain, sehingga kasus yang dihasilkan mengikuti rentang yang bermakna. Tipe Nilai berikut hanya sah untuk angka 0 sampai 100, dan instansnya menggunakan choose untuk membangkitkan angka pada rentang itu.

haskell generator-nilai.hs
-- Generator khusus untuk tipe domain
import Test.QuickCheck

newtype Nilai = Nilai Int deriving (Eq, Ord, Show)

instance Arbitrary Nilai where
  arbitrary = Nilai <$> choose (0, 100)

-- Nilai yang dihasilkan generator selalu berada dalam rentang sah
prop_rentangNilai :: Nilai -> Bool
prop_rentangNilai (Nilai n) = 0 <= n && n <= 100

-- Generator juga dapat disisipkan langsung lewat forAll
prop_hariPiket :: Property
prop_hariPiket = forAll (choose (1, 7) :: Gen Int) (\n -> 1 <= n && n <= 7)

main :: IO ()
main = do
  quickCheck prop_rentangNilai
  quickCheck prop_hariPiket
text Keluaran
+++ OK, passed 100 tests.
+++ OK, passed 100 tests.

Instans Arbitrary Nilai menjamin setiap kasus berada pada rentang sah, sehingga properti tidak perlu membuang waktu menguji nilai seperti -5 yang memang mustahil terjadi. Alternatifnya berupa forAll, yaitu menyisipkan generator langsung pada satu properti tanpa menulis instans. Keduanya melayani kebutuhan yang berbeda, yaitu instans ditulis sekali untuk tipe yang digunakan di banyak properti, sedangkan forAll digunakan untuk generator khusus satu properti saja.

Isi generator juga dapat dilihat tanpa properti apa pun melalui fungsi generate dari Test.QuickCheck.Gen. Pemanggilan generate (vector 3 :: Gen [Int]) menghasilkan satu list tiga elemen dari generator bawaan, dan pemanggilan yang diulang menghasilkan list yang berbeda karena benihnya bergantung pada waktu. Cara ini berguna saat menulis instans Arbitrary baru, yaitu memastikan kasus yang dibangkitkan memang berada pada rentang yang diharapkan sebelum propertinya dijalankan.

Shrink secara langsung#

Fungsi shrink menghasilkan daftar kandidat yang lebih kecil dari sebuah nilai, dan QuickCheck menggunakannya untuk menelusuri pelanggaran sampai contoh terkecilnya. Daftar itu dapat dilihat langsung tanpa menjalankan properti apa pun.

haskell shrink-dasar.hs
-- Kandidat penyusutan yang dihasilkan fungsi shrink
import Test.QuickCheck

main :: IO ()
main = do
  print (shrink 12 :: [Int])
  print (shrink "bcb")
  print (shrink True)
text Keluaran
[0,6,9,11]
["","cb","bb","bc","acb","bab","bbb","bca"]
[False]

Kandidat untuk 12 berupa nilai-nilai yang lebih kecil dan berdekatan, sedangkan kandidat untuk "bcb" memuat string yang lebih pendek maupun karakter yang digeser satu langkah. Ketika properti gagal, QuickCheck menjelajahi daftar ini secara berulang, yaitu mencari kandidat yang masih melanggar, menyusutkan kandidat itu lagi, dan berhenti ketika tidak ada penyusutan yang melanggar. Hasil akhirnya berupa contoh pelanggar yang ringkas sehingga penyebabnya mudah ditebak, dan kemudahan menelusuri penyebab itulah keunggulan pengujian berbasis properti.

Kesalahan umum#

Kesalahan pertama adalah menulis properti yang selalu benar, misalnya xs == xs atau membandingkan hasil dengan dirinya sendiri. Properti semacam itu lolos seratus kasus tanpa pernah menguji apa pun, sehingga kekeliruannya hanya diketahui dengan membaca ulang pernyataannya, bukan dari keluaran yang tampak normal. Properti yang baik menyatakan hubungan antara masukan dan keluaran yang berpotensi salah.

Kesalahan kedua berupa memperlakukan satu eksekusi yang lolos sebagai bukti kebenaran. Seratus kasus acak bukan pembuktian matematis, melainkan pencarian pelanggaran yang belum berhasil, sehingga properti yang lolos tetap perlu dibaca ulang. Kesalahan ketiga berupa tidak menetapkan benih ketika keluaran eksekusi akan dibandingkan antarwaktu, sebab contoh pelanggar yang dilaporkan bergantung pada benihnya, keadaan yang sudah ditunjukkan pada program prop-gagal.hs.

Ringkasan#

  • Properti ditulis sebagai fungsi yang mengembalikan Bool, dan QuickCheck menguji seratus kasus acak secara bawaan.
  • Properti yang gagal dilaporkan beserta contoh tersusutkan yang ringkas, dan benih replay menetapkan kasus yang dilaporkan.
  • Generator dapat diatur melalui instans Arbitrary untuk tipe domain atau forAll untuk satu properti.
  • Fungsi shrink menghasilkan kandidat penyusutan, dan penelusurannya berhenti pada contoh pelanggar terkecil.

Referensi#