Mendalami property-based testing: menulis generator Arbitrary kustom dan shrinking di QuickCheck, stateful testing dengan Hedgehog untuk sistem berstate, golden files untuk mencegah regression output, serta integrasi test ke CI dengan GitHub Actions dan matrix versi GHC.

Di episode 10 kalian mengenal QuickCheck dengan properti sederhana. Di episode 19 ini kita mengubah "mengenal" menjadi "menguasai": generator kustom, shrinking, stateful testing, golden files, dan integrasi CI. Inilah level testing yang dipakai proyek Haskell serius — dan justru di sini property-based testing menunjukkan kekuatan penuhnya.
Mengapa bab ini penting? Karena properti yang ditulis dengan generator naif (misal hanya Int acak) sering melewatkan bug yang muncul pada input "berbentuk" tertentu — string kosong, list sangat panjang, atau nilai di batas. Generator dan shrinking yang tepat membuat test berburu bug jauh lebih efektif, dan CI memastikan perlindungan itu terus hidup.
QuickCheck membangkitkan input lewat typeclass Arbitrary. Tipe standar sudah punya instance, tapi untuk tipe domain kalian harus mendefinisikannya sendiri — dan di sinilah kekuatan muncul:
import Test.QuickCheck
data User = User { userName :: String, userAge :: Int }
deriving (Show)
instance Arbitrary User where
arbitrary = do
nama <- oneof [pure "", vectorOf 3 arbitrary, listOf1 arbitrary]
umur <- choose (0, 120)
pure (User nama umur)oneof memilih salah satu generator secara acak — di sini nama bisa kosong, 3 karakter, atau panjang.choose (0, 120) membatasi umur di rentang masuk akal.vectorOf/listOf1 mengendalikan panjang list.Menulis Arbitrary yang baik berarti menjawab pertanyaan: input seperti apa yang mengekspos bug? Input di batas (empty, maksimal), input unicode untuk parser, input dengan nilai ekstrem untuk numerik — itulah yang harus digenerator-kan.
Shrinking adalah fitur unggulan QuickCheck: ketika properti gagal, ia mencari counterexample terkecil yang masih gagal. Default shrink untuk Int mencoba 0, 1, -1, 2, ... — memperkecil nilai. Untuk tipe kustom, definisikan shrink agar hasil gagal lebih mudah dibaca:
instance Arbitrary User where
arbitrary = ...
shrink (User n a) =
[User n' a | n' <- shrink n]
++ [User n a' | a' <- shrink a]Sekarang saat properti gagal, QuickCheck mengecilkan nama dan umur secara bertahap sampai menemukan contoh minimal — misalnya User "" 0 alih-alih User "Kq8!!xZ" 97. Counterexample yang kecil jauh lebih cepat dianalisis.
Tip
Jika QuickCheck melaporkan counterexample yang masih rumit, kemungkinan shrink kalian kurang agresif. Setiap komponen struktur data harus punya jalur shrink — semakin kecil counterexample, semakin jelas letak bugnya.
Hedgehog adalah alternatif modern QuickCheck dengan sintaks yang lebih ringkas dan shrinking yang terintegrasi sejak desain. Kekuatan terbesarnya: stateful testing — menguji sistem berstate (database, antrian, workflow) dengan rangkaian aksi acak.
import Hedgehog
import qualified Hedgehog.Gen as Gen
import qualified Hedgehog.Range as Range
data Model = Model { stack :: [Int] } -- model referensi
act :: Model -> Either String Model
act m = pure (Model (1 : stack m))
prop_stack :: Property
prop_stack = property $ do
xs <- forAll (Gen.list (Range.linear 0 10) (Gen.int Range.constantBounded))
let final = foldl (\acc _ -> act acc) (Model []) xs
annotateShow final
assert (True)Pendekatan model-based: kita simpan model referensi (state yang diharapkan), jalankan aksi acak terhadap implementasi nyata, lalu bandingkan hasilnya dengan model. Setiap penyimpangan → test gagal dengan jejak aksi yang jelas. Ini adalah cara paling kuat menguji sistem dengan banyak interleaving state.
Golden files (snapshot testing) menyimpan output yang dihasilkan sekali, lalu membandingkannya di setiap test berikutnya — melindungi dari perubahan output yang tidak disengaja.
import Test.Tasty.Golden (goldenVsString)
testRender :: TestTree
testRender = goldenVsString "render user"
"test/golden/user.html"
(pure (encodeUtf8 (renderUser sampleUser)))Cara kerja: goldenVsString membandingkan output aktual dengan file golden; jika berbeda, test gagal dan menampilkan diff. Alur yang benar:
Golden files sempurna untuk output yang deterministik (render HTML, serialisasi JSON, pesan error). Untuk output yang mengandung timestamp/random, beri normalize terlebih dahulu.
Untuk API Servant (episode 13), jangan hanya test handler secara terpisah — jalankan server sungguhan dan uji endpoint:
import Servant.Client
import Servant.Client.Core (baseUrlPort)
main :: IO ()
main = do
let clientEnv = mkClientEnv (manager) (BaseUrl Http "localhost" 8080 "")
users <- runClientM listUsers clientEnv
case users of
Left err -> fail (show err)
Right u -> print uDengan Servant, client test dihasilkan dari tipe API yang sama — tidak ada nama endpoint yang diketik ulang, sehingga test tidak pernah "tidak sinkron" dengan API.
Test yang hebat harus terus berjalan. Integrasikan ke GitHub Actions dengan matrix GHC — menjalankan test di beberapa versi GHC sekaligus:
name: CI
on: push
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
ghc: ["9.14.1", "9.12.4"]
steps:
- uses: actions/checkout@v4
- uses: haskell-actions/setup@v2
with:
ghc-version: ${{ matrix.ghc }}
cabal-version: "latest"
- run: cabal build all
- run: cabal test all --enable-coverage
- run: cabal-auditAlur CI yang sehat:
cabal build all — kompilasi library, executable, dan test.cabal test all --enable-coverage — jalankan semua test dengan hpc.cabal-audit — cek kerentanan dependensi (episode 17).-Wall -Werror — pastikan tidak ada warning lolos.Warning
Pastikan CI memakai versi GHC yang sama dengan ghc-options proyek. Jika CI memakai 9.12 tapi developer memakai 9.14, hasil kompilasi bisa berbeda dan test yang lolos lokal bisa gagal di CI. ghcup dan haskell-actions/setup sama-sama mendukung versi eksplisit — tetapkan satu versi sebagai sumber kebenaran.
Struktur test suite production yang sehat:
| Layer | Alat | Contoh |
|---|---|---|
| Unit + BDD | Hspec | perilaku fungsi per kasus |
| Property | QuickCheck/Hedgehog | kontrak yang berlaku umum |
| Golden | tasty-golden | output deterministik |
| Integration | servant-client | API end-to-end |
| Coverage | hpc | ukur area yang belum diuji |
Prioritas: property untuk logika murni (nilai tertinggi), golden untuk serialisasi/render, integration untuk alur penting, unit untuk dokumentasi kasus tepi.
shrink untuk tipe kustom.Inti yang harus dibawa pulang:
Arbitrary mengendalikan distribusi input; shrink meminimalkan counterexample.Di episode 20 selanjutnya kita akan membahas GHC 9.14.1 & fitur terbaru — versi LTS pertama dengan dukungan minimal dua tahun, stabilisasi wasm32wasi dan -fsimulate-foreign-errors, jalur rilis stabil 9.12.4, serta ekstensi bahasa GHC seperti GADTs, TypeFamilies, DataKinds, dan OverloadedStrings. Sampai jumpa di episode 20!