100 Kali Run Tes Itu Bohong: Cara Sebenarnya Menentukan Ukuran Tes Berbasis Properti
Jika Anda menjalankan property-based test dengan 100 contoh default, Anda mendapatkan yang terburuk dari kedua dunia. CI Anda lebih lambat dari yang seharusnya, dan Anda masih belum menangkap bug yang penting.
Angkanya tidak ajaib. Sebagian besar library, termasuk Hypothesis, default ke 100 karena itu angka bulat yang terasa aman. Tapi “terasa aman” bukanlah strategi pengujian.
Apa yang Sebenarnya Dijanjikan oleh Property-Based Testing
Property-based testing membalik skrip pada unit test. Alih-alih menulis input dan output yang diharapkan secara manual, Anda mendefinisikan sebuah property. Sebuah aturan yang harus selalu berlaku. Framework menghasilkan input untuk merusaknya.
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_reversing_a_list_twice_gives_the_original(lst):
assert lst == list(reversed(list(reversed(lst))))
Framework menjalankan fungsi ini berkali-kali dengan daftar integer acak. Jika menemukan counterexample, ia melakukan shrinking pada input ke versi terkecil yang masih gagal. Daftar 47 elemen yang memicu bug tidak berguna untuk debugging. Daftar 3 elemen adalah emas.
Ini powerful. Ini juga probabilistik. Property-based testing tidak dapat membuktikan correctness. Ia hanya dapat meningkatkan kepercayaan Anda bahwa bug tidak ada, atau menemukan bug jika memang ada. Sifat probabilistik itulah yang membuat jumlah run sangat penting.
Mengapa 100 Itu Sewenang-wenang
Mari jujur tentang dari mana asal 100. Di Hypothesis, itu adalah default yang dipilih pada 2015 karena merupakan angka bulat yang bagus yang menangkap sebagian besar bug tanpa membuat tes menjadi sangat lambat. Itu adalah kompromi sosial, bukan kompromi statistik.
Probabilitas menemukan bug bergantung pada dua hal. Seberapa umum bug tersebut di ruang input, dan berapa banyak sampel yang Anda ambil. Jika bug hanya terpicu ketika input adalah palindrom dengan panjang lebih dari 20, dan palindrom adalah 0,01% dari semua daftar, 100 run memberi Anda peluang sekitar 1% untuk menangkapnya. Itu bukan tes. Itu tiket lotre.
Sebagian besar bug tidak sejarang itu. Banyak property yang rusak pada daftar kosong, elemen tunggal, atau duplikat sederhana. Generator yang well-tuned menangkapnya dengan cepat. Tapi default 100 mengasumsikan generator Anda sempurna dan bug Anda dangkal. Kedua asumsi itu salah.
Apa yang Sebenarnya Anda Dapatkan dari Jumlah Run, Secara Statistik
Jika kita memodelkan penemuan bug sebagai sampling with replacement dari ruang input di mana bug memiliki probabilitas p untuk muncul, probabilitas melewatkan bug setelah n run adalah (1 - p)^n.
Untuk p = 0,01, 100 run memberi Anda peluang 37% untuk melewatkan bug. Untuk p = 0,001, 100 run memberi Anda peluang 90% untuk melewatkannya. Untuk mendapatkan confidence 99% menangkap bug 0,1%, Anda membutuhkan sekitar 4.600 run.
import math
def runs_for_confidence(p, confidence=0.99):
"""Returns the runs needed to catch a bug with probability `p`
at the given confidence level."""
return math.ceil(math.log(1 - confidence) / math.log(1 - p))
print(runs_for_confidence(0.01)) # 459
print(runs_for_confidence(0.001)) # 4603
print(runs_for_confidence(0.0001)) # 46050
Ini adalah bagian yang membuat orang tidak nyaman. Jika Anda ingin confidence tinggi pada bug yang jarang, Anda membutuhkan puluhan ribu run. Tidak ada yang mau menunggu selama itu di CI.
Shrinking Mengubah Persamaan Biaya
Default 100 run ditetapkan sebelum shrinking sebagus sekarang. Framework property-based testing modern tidak hanya menemukan bug. Mereka menemukan minimal bug.
Artinya Anda bisa berpikir dalam hal budget, bukan hanya count. Jika Anda menjalankan 1.000 contoh dan menemukan bug pada run ke-847, shrinking mungkin membutuhkan 200 hingga 300 eksekusi lagi untuk meminimalkan counterexample. Total biayanya adalah 1.100 run atau lebih untuk satu bug. Tapi jika Anda menjalankan 10.000 contoh dan tidak menemukan apa pun, Anda menghabiskan 10.000 run untuk ketenangan pikiran.
Triknya adalah memisahkan discovery dari validation. Jalankan suite kecil dan cepat di CI untuk feedback langsung. Jalankan suite yang lebih besar dan lebih lambat semalaman atau pada branch release untuk confidence yang lebih dalam.
from hypothesis import given, settings, strategies as st
import json
# Fast feedback in CI
@given(st.dictionaries(st.text(), st.integers()))
@settings(max_examples=100)
def test_json_roundtrip_fast(d):
assert json.loads(json.dumps(d)) == d
# Deeper confidence on main
@given(st.dictionaries(st.text(), st.integers()))
@settings(max_examples=5000, deadline=None)
def test_json_roundtrip_thorough(d):
assert json.loads(json.dumps(d)) == d
Ini bukan hanya tentang kecepatan. Ini tentang information density. Tes 100 run yang lulus memberi tahu Anda hampir tidak ada apa-apa. Tes 5.000 run yang lulus memberi tahu Anda sedikit lebih banyak. Tes 100 run yang gagal memberi tahu Anda persis di mana harus mencari.
Cara Kami Membagi Property-Based Test ke dalam Tier
Berdasarkan pengalaman kami, pendekatan terbaik adalah berhenti memperlakukan semua property sebagai setara. Kami membaginya ke dalam tiga tier.
Fast property berjalan pada setiap pull request. Ini adalah yang mekanis. Round-trip serialization, idempotency dari deduplication, invariant dasar pada data structure. Kami menjalankan 100 hingga 200 contoh. Mereka selesai dalam waktu kurang dari satu detik.
Deep property berjalan pada setiap merge ke main. Ini menargetkan state machine yang kompleks, pipeline pemrosesan event, dan apa pun dengan combinatorial explosion. Kami menjalankan 2.000 hingga 10.000 contoh. Mereka membutuhkan waktu menit, bukan jam.
Exploratory property dijalankan secara manual sebelum release. Ini adalah yang mana kami memutar max_examples ke 50.000 atau lebih dan membiarkan mesin bekerja sementara kami meninjau changelog. Kami telah menemukan race condition dan edge case integer overflow dengan cara ini yang tidak akan pernah tertangkap oleh unit testing sebanyak apa pun.
Apa yang Harus Dilakukan Alih-alih Menebak
Berhentilah memperlakukan max_examples sebagai tombol yang Anda atur sekali dan lupakan. Perlakukan itu sebagai konfigurasi yang milik property, bukan framework.
Tanyakan tiga pertanyaan untuk setiap property yang Anda tulis.
Seberapa mahal tes ini untuk dijalankan? Jika setiap contoh membutuhkan 50ms, 10.000 run adalah 8 menit. Jika membutuhkan 5ms, itu kurang dari satu menit.
Seberapa buruk bug tersebut jika kami melewatkannya? Bug formatting pada pesan log tidak sama dengan bug data corruption pada pipeline pembayaran.
Seberapa jarang kondisi pemicunya? Jika bug hanya muncul pada tahun kabisat, atau ketika dua UUID bertabrakan, atau tepat di INT_MAX, Anda membutuhkan lebih banyak run atau generator yang lebih smart.
Generator yang lebih smart hampir selalu mengalahkan lebih banyak run. Jika Anda menguji parser JSON, jangan menghasilkan string acak dan berharap mereka ter-parse. Hasilkan objek yang valid lalu mutasikan mereka.
from hypothesis import given, settings, strategies as st
import json
# Bad: most random strings aren't valid JSON
@settings(max_examples=10000)
@given(st.text())
def test_parse_json_bad(s):
try:
json.loads(s)
except json.JSONDecodeError:
pass # Most inputs hit this immediately
# Good: generate valid objects, then edge cases
@settings(max_examples=500)
@given(st.dictionaries(st.text(), st.integers()))
def test_parse_json_good(d):
assert json.loads(json.dumps(d)) == d
500 run dengan generator yang bagus mengalahkan 10.000 run dengan generator yang buruk. Setiap saat.
Pertanyaan Umum tentang Menentukan Ukuran Property-Based Test
Bukankah lebih banyak run selalu berarti coverage yang lebih baik?
Tidak persis. Property-based testing tidak memiliki metric coverage dalam arti tradisional. Lebih banyak run meningkatkan probabilitas menemukan bug, tapi diminishing returns datang dengan cepat. Menggandakan dari 100 ke 200 run itu berarti. Menggandakan dari 10.000 ke 20.000 jarang.
Bagaimana dengan fuzzing? Bukankah itu hanya property-based testing dengan jutaan run?
Fuzzing berdekatan tapi berbeda. Fuzzer biasanya menjalankan jutaan input tanpa pemahaman semantik terhadap domain. Property-based testing menggunakan structured generator dan shrinking. Anda bisa menganggap PBT sebagai smart fuzzing, atau fuzzing sebagai brute-force PBT. Kalkulasi jumlah run berbeda karena biaya per run dan informasi per run tidak sama.
Haruskah saya mengatur max_examples lebih tinggi untuk CI atau lebih rendah?
Lebih tinggi untuk CI, lebih rendah untuk pengembangan lokal. Laptop Anda untuk kecepatan. CI Anda untuk confidence. Gunakan settings profile atau environment variable untuk beralih di antara keduanya.
import os
from hypothesis import settings
CI = os.environ.get("CI", "false").lower() == "true"
settings.register_profile("ci", max_examples=5000, deadline=None)
settings.register_profile("dev", max_examples=100)
settings.load_profile("ci" if CI else "dev")
Bagaimana saya tahu apakah generator saya cukup bagus?
Jalankan tes Anda dengan max_examples yang diatur sangat tinggi, katakanlah 50.000, dan perhatikan coverage report. Jika ada branch yang hilang, generator Anda tidak mengeksekusinya. Perbaiki generator sebelum Anda menurunkan jumlah run.
Berhenti Mencari Jumlah Run yang Sempurna dan Mulailah Mengukur
Tidak ada angka yang benar secara universal untuk jumlah run tes property-based testing. Hanya ada angka yang benar untuk property Anda, generator Anda, anggaran CI Anda, dan biaya bug yang Anda coba cegah.
Mulailah dengan 100 jika Anda harus. Tapi naikkan untuk property yang menjaga critical path, dan turunkan untuk property yang hanya sanity check. Ukur berapa lama tes Anda berjalan. Profil generator Anda. Dan ingat: property-based test yang lulus 100 kali bukanlah bukti. Itu hanya evidence.
Jika Anda ingin lebih dalam, dokumentasi Hypothesis tentang test statistics dan targeted property-based testing patut dibaca. CLI hypothesis dapat menunjukkan persis contoh mana yang tes Anda habiskan waktunya. Itulah tempat pertama yang harus dilihat ketika Anda memutuskan apakah akan memutar tombol ke atas atau ke bawah.