Beberapa bulan lalu saya menulis fungsi kecil untuk mengubah sen menjadi string harga. Semua unit test hijau. Lalu ada user yang mengetik angka negatif, dan invoice menampilkan -1.95 padahal seharusnya -0.05. Bug itu diam-diam sudah ada sejak commit pertama. Masalahnya bukan di logika saya saja — unit test yang saya tulis hanya memeriksa kasus-kasus yang sudah saya duga.
Unit Test Biasa Hanya Menguji Kasus yang Kamu Duga
Cara kerja unit test konvensional sederhana: kamu memilih beberapa input, memanggil fungsi, lalu membandingkan hasilnya. Tiap input itu hasil tebakan. Kamu menulis 105, 0, dan 999 karena terlihat masuk akal. Input yang tidak kamu pikirkan — angka negatif, string kosong, karakter unicode aneh — tidak pernah diuji sampai akhirnya meledak di produksi.
def format_price(cents: int) -> str:
return f"{cents // 100}.{cents % 100:02d}"
def test_positive():
assert format_price(105) == "1.05"
def test_zero():
assert format_price(0) == "0.00"
Kedua tes di atas lolos. 105 // 100 adalah 1, 105 % 100 adalah 5, hasilnya 1.05. Benar. Tapi coba format_price(-5): Python menghitung -5 // 100 = -1 dan -5 % 100 = 95, jadi hasilnya -1.95. Salah total, dan tes kamu tidak akan pernah mengetahuinya.
Property-Based Testing: Tulis Aturan, Bukan Daftar Kasus
Property-based testing membalik pendekatannya. Alih-alih menulis input satu per satu, kamu menulis property — aturan yang harus selalu benar, berapa pun inputnya. Library bernama Hypothesis lalu membangkitkan ratusan input acak untuk mencoba mematahkan aturan itu. Kalau kamu sudah nyaman dengan pytest, Hypothesis menempel persis di atasnya tanpa mengubah alur kerja kamu.
pip install hypothesis
Mari mulai dari properti yang paling sederhana. Membalik sebuah list dua kali harus mengembalikan list aslinya. Ini benar untuk list kosong, list satu elemen, list jutaan elemen, list berisi angka negatif — semuanya. Kamu tidak perlu menebak kasusnya.
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_reverse_twice_sama_dengan_asli(xs):
assert list(reversed(list(reversed(xs)))) == xs
Hypothesis menjalankan 100 contoh secara default, bukan cuma satu. Setiap dijalankan, input-nya acak tapi bisa direproduksi karena seed-nya ikut tercatat di output kegagalan.
Strategies: Mesin yang Membuatkan Data untuk Kamu
Bagian st.lists(st.integers()) di atas adalah strategy — deskripsi tentang seperti apa data yang valid. Hypothesis menyediakan banyak strategy siap pakai, dan kamu bisa menyusunnya seperti lego.
import hypothesis.strategies as st
st.integers(min_value=0, max_value=10_000)
st.text()
st.text(alphabet="abc", max_size=5)
st.lists(st.integers(), min_size=1)
st.dictionaries(st.text(), st.integers())
st.sampled_from(["GET", "POST", "PUT"])
st.one_of(st.integers(), st.text())
st.tuples(st.integers(), st.text())
Menangkap Bug Pembulatan dengan Satu Baris Aturan
Kembali ke format_price. Aturannya: hasil fungsi harus selalu sama dengan nilai desimal sebenarnya, dibulatkan dua digit. Tulis sebagai properti, bukan sebagai kasus.
from hypothesis import given, strategies as st
from price import format_price
@given(st.integers())
def test_format_price_sesuai_float(cents):
assert format_price(cents) == f"{cents / 100:.2f}"
Jalankan dengan pytest dan Hypothesis langsung melaporkan kegagalan. Yang menarik adalah shrinking: begitu menemukan input yang gagal, Hypothesis mengecilkannya sampai ke contoh terkecil yang masih gagal. Kamu tidak akan mendapat angka acak raksasa seperti -983745112, melainkan sesuatu seperti cents = -1 — input paling kecil yang memicu bug, tepat untuk kamu jadikan kasus uji permanen.
Perbaiki Fungsi dan Jalankan Ulang
def format_price(cents: int) -> str:
sign = "-" if cents < 0 else ""
cents = abs(cents)
return f"{sign}{cents // 100}.{cents % 100:02d}"
Dengan versi ini, angka negatif dipisahkan tandanya dulu, sisanya dihitung seperti biasa. Jalankan ulang properti yang sama — sekarang ratusan contoh acak lolos semua, termasuk angka negatif yang sebelumnya tidak pernah kamu duga.
Gunakan @example(0) dan @example(-1) untuk memaksa Hypothesis selalu menguji kasus tepi tertentu di samping contoh acaknya. Berguna untuk mendokumentasikan kasus yang pernah bikin kamu menangis.
Jalankan Bareng pytest Tanpa Ribet
Hypothesis terintegrasi penuh dengan pytest. Tulis tes seperti biasa, jalankan pytest, dan properti ikut dieksekusi sebagai test function. Kalau fungsi yang kamu uji memanggil API eksternal atau database, isolasi dulu dependensinya dengan mocking supaya Hypothesis tidak menembak layanan luar ratusan kali.
# test_price.py
from hypothesis import given, settings, strategies as st
from price import format_price
@given(st.integers())
@settings(max_examples=500)
def test_format_price_sesuai_float(cents):
assert format_price(cents) == f"{cents / 100:.2f}"
pytest test_price.py -v
Property-based testing melengkapi unit test, bukan menggantikannya. Tulis tetap contoh kasus untuk dokumentasi dan pembacaan cepat, lalu tambahkan properti untuk memburu kasus tepi yang tidak terpikirkan.
Ambil satu fungsi murni dari proyek kamu — fungsi encoding, fungsi sort, fungsi format tanggal — lalu coba tuliskan propertinya. Kalau dalam lima menit Hypothesis sudah menemukan input yang mematahkannya, kamu baru saja menyelamatkan satu insiden produksi.