فرض کنید تابعی باید ۱۰۰٬۰۰۰ ریال را میان سه قسط تقسیم کند. تستهای مثالمحور برای ۹۰٬۰۰۰ و سه قسط، یا ۱۰۰٬۰۰۰ و یک قسط سبز میشوند؛ اما باقیماندهٔ تقسیم چه میشود؟ آیا مجموع سهمها همیشه برابر مبلغ اولیه است؟ اختلاف دو سهم حداکثر یک ریال میماند؟ برای ۱ ریال و دو سهم چه رخ میدهد؟ تست مبتنی بر ویژگی بهجای حدسزدن چند مثال، این قوانین را صریح میکند و موتور تولید داده تلاش میکند آنها را نقض کند.
Property-Based Testing یا PBT یک Loop مهندسی است: Domain/Generator → Property/Oracle → اجرای نمونههای متعدد → Counterexample → Shrinking → Seed/Path Replay → اصلاح و Regression. ارزش آن از «تصادفیبودن» نمیآید؛ از شفافشدن قرارداد، تنوع کنترلشدهٔ داده و کوچکشدن شکست میآید.
در این راهنما، ابتدا Property و Generator را دقیق تعریف میکنیم، سپس الگوهای Property، دامهای Oracle، Coverage/Distribution، Shrink و Reproducibility را میسازیم. در ادامه یک مثال واقعی و اجراشده با fast-check، سناریوهای فارسی/ایرانی، Pipeline و چکلیست ارائه میشود.
تست مبتنی بر ویژگی چیست؟
در PBT، Tester یا Developer مجموعهای از ورودیهای ثابت را بهتنهایی نمینویسد؛ Domain ورودی و قانونی را تعریف میکند که برای هر ورودی معتبرِ آن Domain باید برقرار باشد. Framework نمونهها را تولید و Property را ارزیابی میکند. اگر نقضی پیدا شد، معمولاً همان ورودی را به Counterexample سادهتری کاهش میدهد.
مقالهٔ اولیهٔ QuickCheck از Claessen و Hughes در سال ۲۰۰۰ این رویکرد را با Propertyهای تابعی، ورودی تصادفی و Generator سفارشی محبوب کرد. خانوادهٔ ابزارهای امروز همین ایده را با Shrinker، Replay، Stateful command و Integration با Test runnerهای رایج گسترش دادهاند.
تعریف عملی: PBT جستوجوی خودکار برای Counterexample یک گزارهٔ قابلآزمون، درون Domain و بودجهٔ تولید مشخص است.
عبارت «برای همهٔ ورودیها» در Property، نیت قرارداد را بیان میکند؛ اجرای محدود معمولاً همهٔ فضای ممکن را نمیپیماید. راهنمای رسمی fast-check نیز توضیح میدهد که Library از فضای ورودی نمونهبرداری میکند و Seed میتواند اجرا را تکرارپذیر کند. بنابراین Passشدن ۱۰۰ یا ۱۰٬۰۰۰ Run اثبات ریاضیِ Property نیست.
PBT با تست مثالمحور، BVA، Fuzzing و MBT چه تفاوتی دارد؟
| رویکرد | Tester چه چیزی تعریف میکند؟ | Oracle معمول | خروجی متمایز |
|---|---|---|---|
| Example-based | Input و Expected output مشخص | برابری نتیجه با مقدار مورد انتظار | مثال خوانا و هدفمند |
| Property-Based | Domain/Generator و قانون عمومی | Invariant/Relation/Model | Counterexample و Shrink |
| BVA/EP | مرز یا Partition نسبت به Test objective | Expected behavior هر Choice | پوشش طراحیشده و قابل ردیابی |
| Fuzzing | Corpus/Grammar/Mutation و Failure signal | Crash، Hang، Sanitizer یا Security violation | ورودیهای غیرمنتظره و Coverage feedback |
| Model-Based Testing | Model، State/Transition و Conformance | مدل مرجع و وضعیت مورد انتظار | Sequence/Path تولیدشده |
PBT رقیب تحلیل مقدار مرزی یا بخشبندی همارزی نیست. Partitionها و Boundaryها میتوانند طراحی Generator را هدایت کنند؛ PBT نیز ترکیبهای بیشتری از همان مدل ورودی میسازد. Fuzzing بیشتر مالک Crash/Attack-surface و Coverage-guided mutation است، درحالیکه PBT معمولاً مالک قانون دامنه و Counterexample قابلکوچکسازی است.
چرخهٔ اصلی PBT چگونه کار میکند؟
مستندات Core Blocks در fast-check سه جزء Arbitrary، Property و Runner را جدا میکند. در عمل چرخه را میتوان به هفت گام تبدیل کرد:
- Contract: Behavior، Domain، Preconditions و Forbidden state را روشن کنید.
- Generator/Arbitrary/Strategy: دادهٔ معتبر یا نامعتبرِ هدفمند بسازید.
- Property: Relation یا Invariant را با Oracle مستقل بیان کنید.
- Runner: Run budget، Seed، Timeout، Examples و Verbosity را تعیین کند.
- Generation: نمونهها با Distribution واقعی اجرا شوند.
- Shrink: Counterexample در مسیر Shrinker سادهتر شود.
- Replay/Regression: Seed/Path و مثال حداقلی بازتولید، Root cause اصلاح و Property حفظ شود.
ضعف در هر گام نتیجه را تحریف میکند: Generator ممکن است Risk را هرگز نسازد، Property ممکن است Tautology باشد، Shrinker ممکن است دادهٔ نامعتبر تحویل دهد و Test environment ناپایدار ممکن است Counterexample را Flaky کند.
Property Contract؛ قبل از کد چه بنویسیم؟
Property خوب یک جملهٔ شاعرانه مثل «سیستم همیشه درست کار کند» نیست. این کارت را پر کنید:
| فیلد | پرسش |
|---|---|
| Decision/Risk | نقض این Property کدام تصمیم یا ضرر را تحت تأثیر میگذارد؟ |
| Subject under test | تابع، Component، API، State machine یا Pipeline دقیق چیست؟ |
| Domain | کدام ورودیها معتبر، نامعتبر، ناشناخته یا خارج از Scope هستند؟ |
| Preconditions | چه چیزی باید قبل از Property برقرار باشد و چرا؟ |
| Relation | چه Invariant، Transformation یا Modelی باید برقرار بماند؟ |
| Oracle independence | Expected از منبعی مستقل میآید یا منطق Production را کپی کرده است؟ |
| Distribution | کدام Partition/Boundary/Combination باید دیده شود؟ |
| Shrink semantics | «سادهتر» برای Domain یعنی چه و چه قیودی باید حفظ شود؟ |
| Budget | Run/Time/Size/Sequence length در PR، Nightly و Release چقدر است؟ |
| Evidence | Seed، Path، Counterexample، Build و Environment چگونه ثبت میشوند؟ |
الگوهای Property؛ از کجا قانون پیدا کنیم؟
۱. Invariant یا پایستگی
بعضی کمیتها باید حفظ شوند: مجموع سهمهای یک مبلغ برابر مبلغ اولیه، موجودی کل Ledger پس از انتقال داخلی ثابت، تعداد آیتمها پس از Sort بدون حذف/تکرار و Permissionهای خروجی زیرمجموعهٔ Permissionهای مجاز. Invariant معمولاً از Business rule یا ساختار داده میآید.
۲. Round-trip و Inverse
اگر دو عملیات معکوساند، decode(encode(x)) = x یا decrypt(encrypt(x)) = x برای Domain مشخص. Lossy format، Canonicalization، Precision و Timezone را صریح کنید؛ Round-trip خام برای JSON وقتی ترتیب Key یا Unicode normalization تغییر میکند ممکن است Property غلطی باشد.
۳. Idempotence
اجرای دوباره نباید نتیجهٔ تازهای بسازد: normalize(normalize(x)) = normalize(x)، حذف Item حذفشده یا پردازش Callback با Idempotency key. Idempotence را با «همان Response» یکی نگیرید؛ شاید Status متفاوت اما State/اثر مالی یکسان باشد.
۴. Metamorphic Relation
Expected output دقیق نداریم، اما رابطهٔ چند اجرا را میدانیم: افزودن یک کالای مثبت نباید Total را کاهش دهد؛ Permuteکردن ورودی Sort نباید Multiset خروجی را تغییر دهد؛ تبدیل رقم فارسی/عربی/لاتینِ معادل باید مبلغ Canonical یکسان بدهد. Metamorphic property برای الگوریتمهای پیچیده و ML مفید است، به شرط اینکه Relation واقعاً از Contract بیاید.
۵. Differential یا Reference Model
نتیجهٔ پیادهسازی جدید را با Model ساده، نسخهٔ قبلی معتبر، پایگاه دادهٔ مرجع یا دو Library مستقل مقایسه کنید. اگر هر دو implementation از یک Bug/Dependency مشترک استفاده میکنند، استقلال Oracle از بین میرود. اختلاف نیز همیشه به معنی Bug محصول نیست؛ ممکن است Model ناقص باشد.
۶. Algebraic law با احتیاط
Commutativity، Associativity و Identity فقط وقتی Contract آنها را تضمین میکند. جمع عدد صحیح جابجاییپذیر است، اما Floating-point، String concatenation، Money allocation یا عملیات زماندار لزوماً نیست. Property جذاب اما نادرست، تیم را دنبال False failure میفرستد.
۷. Stateful/Command property
برای Cart، Wallet یا Session، Commandها و State model بسازید و بعد از هر Sequence، Precondition/Postcondition/Invariant را بسنجید. این قسمت به تست مبتنی بر مدل و تست انتقال حالت متصل میشود. PBT میتواند Sequence را تولید/Shrink کند؛ اعتبار مدل همچنان مسئولیت تیم است.
راهنمای Model-Based Testing در fast-check نشان میدهد Command arbitrary علاوه بر Seed و Path، برای Replay زنجیرههای Stateful به Replay path خودش نیاز دارد؛ Evidence پایداری باید متناسب با نوع Arbitrary باشد.
Propertyهای ضعیف که احساس امنیت کاذب میسازند
- No crash: «Exception نداد» صحت مبلغ، Authorization یا State را ثابت نمیکند.
- Same implementation twice: Expected با همان تابع Production محاسبه میشود.
- Tautology: مثل
result === resultیا بررسی Typeای که Compiler قبلاً تضمین کرده است. - Overbroad: «برای هر String باید موفق شود» درحالیکه Contract ورودی معتبر محدود است.
- Overspecified: ترتیب داخلی یا Formatting بیاهمیت را الزام میکند و Refactor را میشکند.
- Vacuous: شرط/Return زودهنگام باعث میشود Assertion روی اکثر دادهها اجرا نشود.
- One-sided: فقط Valid path را میسنجد و Invalid behavior/No-side-effect را رها میکند.
برای ارزیابی قدرت واقعی Property، Mutation Testing مفید است: آیا تغییر عمدیِ معنادار در Production code باعث Failure میشود؟ راهنمای تست جهش این نقش را پوشش میدهد؛ Mutation score جای Review معنایی Property را نمیگیرد.
Generator؛ مدل داده است، نه Random factory
مرجع Strategyهای Hypothesis نشان میدهد Generatorها فقط Primitive نیستند؛ میتوان آنها را Map، Combine، Filter و به Domain object تبدیل کرد. Generator خوب چهار چیز را همزمان مدل میکند:
- Validity: Constraintهای Schema و Business؛
- Diversity: Partition، Boundary، Size، Shape و Combination؛
- Probability: احتمال رسیدن به Riskهای مهم؛
- Shrinkability: مسیر رسیدن به Counterexample ساده و همچنان معتبر.
دادهٔ معتبر را By construction بسازید
اگر endDate ≥ startDate است، ابتدا start و Duration غیرمنفی تولید و end را بسازید؛ دو تاریخ مستقل تولید نکنید و ۹۹٫۹٪ را Reject نکنید. اگر Order باید حداقل یک Line داشته باشد، Generator collection را از ابتدا Non-empty تعریف کند. این کار هم Coverage را بهتر و هم Shrink را معنادارتر میکند.
Valid و Invalid را در دو Property جدا کنید
Property معتبر میپرسد «برای دادهٔ مطابق قرارداد چه قانونی برقرار است؟». Property نامعتبر میپرسد «برای نقض دقیق کدام Constraint چه Error/No-side-effectی رخ میدهد؟». مخلوطکردنشان با شرطهای فراوان، Failure را مبهم میکند.
Filter و Assume را بیقاعده مصرف نکنید
راهنمای رسمی Hypothesis برای map/filter/assume تفاوت Discard آگاهانه با Early return را روشن میکند: Early return ممکن است یک Case بررسینشده را Pass حساب کند. Filter سنگین نیز تولید را کند و Distribution را منحرف میکند. Constraint ساده را داخل Strategy و رابطهٔ واقعاً وابسته را با Composite/Dependent generator بسازید.
Distribution و Coverage را مشاهدهپذیر کنید
۱۰۰۰ Run هیچ معنایی ندارد اگر ۹۹۹ مورد Amount=۰ یا String ASCII کوتاه باشند و Risk واقعی هرگز دیده نشود. Dimensionهای مهم را Classify کنید:
- Partition: valid/invalid/empty/missing/duplicate؛
- Boundary: min، min+۱، nominal، max-۱، max، خارج از مرز؛
- Shape: collection خالی/یک/چند/بزرگ، nesting، duplicate، order؛
- Representation: لاتین/فارسی/عربی، NFC/NFD، RTL mark، whitespace؛
- State: new/pending/paid/refunded/cancelled؛
- Risk: timeout-before-commit/after-commit، retry، concurrency، permission؛
- Source: explicit example، generated، replay database یا seed rotation.
تابع statistics در fast-check Arbitrary را با Classifier نمونهبرداری میکند. آمار را برای Debug Generator و Coverage risk استفاده کنید، نه برای اثبات Uniformity یا Correctness. اگر یک Bucket مهم صفر است، numRuns را کورکورانه زیاد نکنید؛ Generator را اصلاح کنید.
Boundary و مثالهای صریح را داخل PBT حفظ کنید
Framework ممکن است Edge case تولید کند، اما «ممکن است» Commitment نیست. مثالهای تاریخی، Regulatory، Minimum/Maximum، Leap day، DST، صفر، NaN/Infinity در Domain مربوط و Counterexampleهای قبلی را Explicit نگه دارید. ترکیب سالم:
- چند Example-based test برای Business narrative و Expected دقیق؛
- Property test برای فضای وسیع و Relation عمومی؛
- Boundary/Partition coverage بهعنوان الزام Generator؛
- Regression example برای Counterexample پرریسک، در کنار Property اصلی.
PBT تست مثالمحور را حذف نمیکند؛ مثال به انسان توضیح میدهد و Property تنوع را جستوجو میکند.
Shrinking دقیقاً چه چیزی را تضمین میکند؟
Shrink پس از Failure، تغییرهای سادهکننده را امتحان میکند: عدد به صفر/مرز نزدیک، List کوتاهتر، String سادهتر، Object با بخشهای کمتر یا Command sequence کوتاهتر. هدف، Counterexample خواناتر است.
اما «Minimal» نسبی است: به ترتیب Shrink، ساختار Generator، بودجه و Stability property وابسته است. Framework معمولاً Global minimum ریاضی را تضمین نمیکند. یک Generator با map/filter نامناسب ممکن است Shrink ضعیف یا Counterexample نامعتبر بسازد.
چه چیزهایی Shrink را خراب میکنند؟
- Randomness یا Clock پنهان داخل Property؛
- وابستگی به State مشترک و ترتیب Testها؛
- Generatorی که پس از Transform، مسیر معکوس معنادار ندارد؛
- Precondition سنگین که اکثر Shrinkها را Reject میکند؛
- Timeout/Network ناپایدار که Failure mechanism را عوض میکند؛
- چند Assertion نامرتبط در یک Property؛ Shrinker بین Failureها جابهجا میشود.
راهنمای خواندن گزارش fast-check Seed، Path، Counterexample و تعداد Shrinkها را نشان میدهد. Counterexample را با همان Build/Config و بدون Shrink دوباره اجرا کنید تا Mechanism ثابت شود.
Seed، Path و Replay؛ چگونه Failure را بازتولید کنیم؟
Seed نقطهٔ شروع Generator است؛ Path مسیر رسیدن/کوچکسازی Case را ثبت میکند. برای Reproducibility این بسته را نگه دارید:
- Property/Test ID و نسخهٔ فایل؛
- Library و Runtime version؛
- Seed، Path/Replay path، numRuns و Size parameters؛
- Counterexample Serializeشده و Redactشده؛
- Artifact/Commit/Config/Timezone/Locale؛
- External dependency Stub/Sandbox version و State reset؛
- Failure stack، Oracle و First-failing run.
Seed همیشه بهتنهایی کافی نیست؛ تغییر Generator، Library version، draw order یا Environment میتواند Sequence را عوض کند. API رسمی Hypothesis نیز Reproduction decorator را وابسته به نسخه میداند و آن را ابزار موقت بازتولید معرفی میکند. Counterexample مهم را به یک Example پایدار و خوانا Promote کنید، اما Property را حذف نکنید.
مثال عملی fast-check؛ تقسیم مبلغ ریالی
تابع زیر مبلغ صحیح و غیرمنفی ریالی را میان تعداد مشخصی سهم تقسیم میکند. نسخهٔ معیوب باقیمانده را دور میریزد:
import assert from 'node:assert/strict';
import fc from 'fast-check';
function allocateBroken(amountRial, parts) {
const base = Math.floor(amountRial / parts);
return Array(parts).fill(base); // remainder is lost
}
const input = fc.tuple(
fc.integer({ min: 0, max: 1_000_000_000 }),
fc.integer({ min: 1, max: 100 }),
);
const conservesMoney = (allocator) =>
fc.property(input, ([amountRial, parts]) => {
const shares = allocator(amountRial, parts);
assert.equal(shares.length, parts);
assert.ok(shares.every(Number.isInteger));
assert.ok(shares.every((share) => share >= 0));
assert.equal(
shares.reduce((sum, share) => sum + share, 0),
amountRial,
);
assert.ok(Math.max(...shares) - Math.min(...shares) <= 1);
});
fc.assert(conservesMoney(allocateBroken), {
numRuns: 1_000,
seed: 20260811,
});
این کد در محیط واقعی Node و fast-check اجرا شد. Framework شکست را به Counterexample زیر Shrink کرد:
Counterexample: [[1, 2]]
seed: 20260811
path: "0:1:0:0:2:1"
یک ریال میان دو سهم، سادهترین Case محلی برای نشاندادن گمشدن Remainder است. اصلاح:
function allocate(amountRial, parts) {
const base = Math.floor(amountRial / parts);
const remainder = amountRial % parts;
return Array.from(
{ length: parts },
(_, index) => base + (index < remainder ? 1 : 0),
);
}
fc.assert(conservesMoney(allocate), {
numRuns: 10_000,
seed: 20260811,
});
نسخهٔ اصلاحی ۱۰٬۰۰۰ Run را گذراند. این Pass اثبات کامل نیست؛ فقط شواهدی است که Propertyهای طول، Integer/non-negative، پایستگی مبلغ و اختلاف حداکثر یک ریال در Domain تولیدشده نقض نشدند. Exampleهای Domain مثل مبلغ منفی، تعداد سهم صفر، سقف قانونی و سیاست اینکه کدام سهم Remainder را بگیرد باید جداگانه در Contract تعیین شوند.
سناریوهای PBT برای محصول ایرانی
Generator باید دادهٔ مصنوعی و بدون هویت واقعی بسازد؛ Counterexample و CI artifact ممکن است مدتها نگهداری شوند. برای انتخاب Synthetic/Masked data، Provision و Cleanup از راهنمای مدیریت دادهٔ تست استفاده کنید.
پول و تبدیل ریال/تومان
- Canonical amount همیشه Integer ریال و بدون Floating-point است.
- اگر UI تومان میگیرد، Round-trip فقط برای مقادیری تعریف شود که سیاست Rounding آنها مشخص است.
- جمع Lineها، تخفیف، مالیات و Shipping با Total و Ledger مستقل سازگار بماند.
- Permutation سبد نباید Total را تغییر دهد، مگر Rule صریحاً به ترتیب وابسته باشد.
- Retry یک Idempotency key نباید اثر مالی دوم بسازد.
رقم، متن فارسی و Unicode
- نرمالسازی رقمهای
۰۱۲۳،٠١٢٣و0123در Domain مجاز Idempotent باشد. - Canonicalization «ی/ی» و «ک/ک» مطابق Contract باشد؛ دادهٔ هویتی را بیاجازه تغییر ندهد.
- NFC/NFD، ZWNJ، RTL mark، Emoji و Whitespace نباید طول/Validation/Storage را ناسازگار کنند.
- Propertyهای Display و Canonical value جدا باشند؛ شباهت ظاهری Oracle داده نیست.
زمان تهران و UTC
Instant را از نمایش محلی جدا کنید. Propertyهای مفید: Parse/serialize باید Instant را حفظ کند؛ Sorting بر Instant مستقل از Format محلی باشد؛ تبدیل UTC→Tehran→UTC در Domain پشتیبانیشده همان Instant بدهد؛ Expiry روی مرز دقیق رفتار مشخص داشته باشد. تقویم جلالی، Offset و Zone rule را با Library/Version واقعی تست کنید، نه با افزودن ثابت ۳:۳۰.
ورودیهای وابسته و ساختارهای پیچیده
برای Order، Payment یا User، Primitiveهای مستقل معمولاً Object نامعتبر میسازند. Generator سلسلهمراتبی بسازید:
- Tenant و Currency/Locale policy؛
- User/Role متناسب با Tenant؛
- Catalog item و Price معتبر؛
- Cart lines با Quantity/Inventory constraints؛
- Discount سازگار با User/Item/Time؛
- Payment state و Idempotency key؛
- Expected model مستقل برای Total/Authorization/State.
Generator باید رابطهها را حفظ کند و Shrinker نیز Order کوچکتر اما معتبر بسازد. حذف کور یک Parent و باقیماندن Child، Counterexample نامعتبر تولید میکند.
PBT در Unit، API و Integration
Pure function بهترین نقطهٔ شروع است، نه سقف PBT. در API/Integration نیز میتوان Request، Contract object و Sequence تولید کرد، اما Cost و State را مهار کنید. راهنمای تست یکپارچهسازی مرز Component/Database/Queue/Sandbox را مشخص میکند.
| سطح | Property نمونه | ریسک اجرایی |
|---|---|---|
| Unit | Round-trip، Invariant، Algebraic law | Oracle تکراری یا Domain ناقص |
| Component | Schema→domain mapping و no-side-effect on reject | Stub با Production bug مشترک |
| API | Authorization، idempotency، canonicalization | داده/Rate limit و Cleanup |
| Database/Queue | Conservation، uniqueness، eventual invariant | Timing و State مشترک |
| System | Business state sequence و reconciliation | Run budget، Flake و Evidence |
در Integration، هر Generated case باید Run/Worker namespace، Reset/Cleanup و Timeout داشته باشد. Property نباید صرفاً منتظر «بالاخره درستشدن» بماند؛ Deadline و Eventual consistency contract لازم است.
Stateful PBT؛ از فرمان تا Sequence حداقلی
برای سیستم حالتدار، Command شامل Preconditions، اجرای واقعی، تغییر Model و Postcondition است. نمونهٔ Wallet:
- Commandها: Create، Deposit، Reserve، Capture، Release، Refund؛
- Precondition: Capture فقط برای Reservation فعال؛
- Invariant: Available + Reserved با Ledger reconcile شود؛
- Postcondition: Duplicate command با همان Idempotency key اثر دوم ندارد؛
- Shrink: Sequence طولانی به کوتاهترین زنجیرهای که Violation را حفظ میکند کاهش یابد.
Sequence کمشده ممکن است Root cause را آشکار کند، مثلاً Reserve → Timeout-after-commit → Retry → Capture. بااینحال Shrink باید Preconditions Commandها را حفظ کند؛ Sequence نامعتبر تشخیص مفیدی نیست.
Side effect، Concurrency و Nondeterminism
- Clock و UUID/Random را Inject کنید یا در Evidence ثبت کنید.
- Network/DB/Queue را با Correlation ID و Deterministic fault schedule کنترل کنید.
- Parallel caseها Namespace مستقل داشته باشند؛ Shared account Counterexample را آلوده میکند.
- Property مربوط به Concurrency باید Schedule یا Interleaving را بخشی از Input بداند.
- Failure اول را قبل از Retry/Shrink با Artifact ثبت کنید.
- اگر اجرای همان Counterexample گاهی Pass است، ابتدا Flakiness mechanism را جدا کنید؛ Seed را مقصر ندانید.
انتخاب ابزار PBT
| اکوسیستم | نمونه ابزار | PoC چه چیزی را بسنجد؟ |
|---|---|---|
| JavaScript/TypeScript | fast-check | Type integration، Arbitrary composition، Async/commands، Seed/Path report |
| Python | Hypothesis | Strategy inference/composition، Example database، Health checks، Replay |
| JVM | jqwik | JUnit integration، Edge cases، Statistics، Stateful testing، rerun |
| Haskell | QuickCheck/Hedgehog | Generator/Shrinker model، ecosystem fit و reporting |
| .NET | FsCheck | F#/C# ergonomics، custom Arbitrary و Test runner integration |
| Rust | proptest/quickcheck | Strategy، persistence، no_std/domain constraints و shrink |
راهنمای جاری jqwik نمونهای از قابلیتهایی است که در PoC باید ببینید: Edge-case generation، Result shrinking، Statistics، Rerun و Stateful testing. ابزار را با ستارهٔ GitHub انتخاب نکنید؛ یک Property واقعی محصول را در دو گزینه اجرا و کیفیت Counterexample، Replay، CI integration، Maintenance و سرعت را مقایسه کنید.
استراتژی CI؛ Run زیاد همیشه بهتر نیست
| Lane | محتوا | بودجهٔ نمونه | Failure policy |
|---|---|---|---|
| Commit/PR | Propertyهای Pure/fast + counterexample corpus | کم و پایدار، مثلاً ۱۰۰–۵۰۰ بر اساس زمان | Block با Seed/Path/Evidence |
| Nightly | Run/Size بیشتر، Seed rotation و Integration | Risk/time-based، نه عدد ثابت جهانی | Owner همان روز؛ عدم Retry مخفی |
| Release | Propertyهای مالی/مجوز + Boundaryهای صریح | Manifest نسخهدار | Gate بر Risk مشخص |
| Investigation | Targeted generator و replay Counterexample | تا تثبیت Mechanism | Promote به Regression |
Seed ثابت PR را پایدار میکند ولی اگر همیشه تنها همان Seed اجرا شود، Exploration متوقف میشود. ترکیب مناسب: Corpus شکستهای قبلی + چند مثال ثابت + Seed گزارششدهٔ متغیر در Lane عمیقتر. اصول انتخاب کاندیدا، هزینه و Feedback را با راهنمای اتوماسیون تست هماهنگ کنید.
Lifecycle یک Counterexample
- Failure خام، Seed/Path/Build و Artifact را ثبت کنید.
- همان Case را بدون جستوجوی تازه Replay کنید.
- مشخص کنید Failure از محصول، Property، Generator، Oracle، Environment یا Library است.
- Counterexample Shrunk را با Domain expert مرور کنید؛ آیا معتبر و مهم است؟
- Root cause را اصلاح و Property را دوباره اجرا کنید.
- برای Case مهم، یک Example خوانا اضافه کنید؛ Secret/PII را حذف کنید.
- Generator/Property را طوری بهبود دهید که خانوادهٔ Failure را پوشش دهد، نه فقط همان عدد را.
- Retest در Lane مناسب و Closure با Evidence انجام شود.
ذخیرهٔ همهٔ Seedها بدون Context، Corpus پرنویز میسازد. Artifact مفید باید Risk، Property ID، نسخه و علت نگهداری داشته باشد.
چگونه کیفیت Property و Generator را بسنجیم؟
- Coverage Bucketهای Risk و تعداد Bucketهای صفر؛
- Discard/Invalid-generation rate و Health-check signal؛
- زمان Generation/Execution/Shrink به تفکیک؛
- Reproduction rate همان Counterexample در Artifact ثابت؛
- Median/Distribution اندازهٔ Counterexample، نه رقابت برای کوچکترین عدد؛
- Mutationهای معناداری که Property میکشد/زنده میگذارد؛
- Findingهای Product در برابر Generator/Property/Environment defects؛
- درصد Counterexampleهای مهم تبدیلشده به Regression و Root-cause guard؛
- Flaky rate در First attempt، بدون پنهانکردن با Retry؛
- Execution cost در برابر Risk/Feedback lane.
«۱ میلیون Case پاس شد» بدون Domain، Distribution و Oracle عدد بازاریابی است. ممکن است همان Case ساده بارها تکرار شده یا Assertion تهی باشد.
خطاهای رایج در Property-Based Testing
- شروع از ابزار: Arbitrary نوشته میشود پیش از اینکه Contract و Risk روشن باشد.
- Random = coverage: Bucket مهم هرگز دیده نمیشود.
- Copy oracle: Expected همان الگوریتم Production را تکرار میکند.
- Filter storm: اکثر Caseها دور ریخته و Runهای ظاهری بیمعنا میشوند.
- Property همهکاره: چند Failure mechanism در یک Predicate، Shrink و تشخیص را مبهم میکند.
- اعتماد مطلق به Shrink: Counterexample محلی با Root cause یا Global minimum اشتباه گرفته میشود.
- ثبت Seed تنها: Path، نسخه، Build و Environment گم میشوند.
- حذف مثالها: Business narrative و Boundaryهای قراردادی ناپدید میشوند.
- Side effect بدون Reset: Caseها به ترتیب و State مشترک وابسته میشوند.
- numRuns بهعنوان KPI: تیم عدد را زیاد میکند ولی Property/Distribution ضعیف میماند.
- Quarantine بیپایان: Failureهای PBT با Retry سبز میشوند و Evidence اولیه از بین میرود.
- تولید دادهٔ واقعی: PII/مالی وارد Log، Counterexample و CI artifact میشود.
برنامهٔ ۳۰روزهٔ استقرار PBT
- هفتهٔ اول: یک Pure function پرریسک انتخاب، Contract Card، پنج Example و دو Property مستقل بنویسید؛ Generator bucketها را Sample کنید.
- هفتهٔ دوم: Shrink/Replay/Evidence را تثبیت، یک Bug عمدی و چند Mutation را اجرا، Counterexample را به Regression تبدیل کنید.
- هفتهٔ سوم: یک Domain object ایرانی شامل Money/Unicode/Time اضافه و Valid/Invalid Generatorها را جدا کنید.
- هفتهٔ چهارم: PR/Nightly lane، Seed policy، Corpus ownership، Metrics و Review checklist را تعریف؛ سپس دربارهٔ Scale تصمیم بگیرید.
شروع با Stateful distributed system معمولاً هزینهٔ یادگیری را زیاد میکند. ابتدا Generator/Property/Shrink را روی واحدی با Oracle روشن یاد بگیرید، بعد به API و Sequence بروید.
چکلیست بازبینی Property
- Risk و تصمیم مرتبط نامگذاری شدهاند.
- Domain، Valid/Invalid/Out-of-scope و Preconditions روشناند.
- Property Relation قابل توضیح و از Implementation مستقل است.
- مثال نقضشوندهٔ فرضی میتوان تصور کرد؛ Property Tautology نیست.
- Generator داده را By construction میسازد و Filter سنگین ندارد.
- Partition/Boundary/Unicode/Size/State bucketها قابل مشاهدهاند.
- Shrinker اعتبار Domain را حفظ میکند.
- Clock/Random/Network/State کنترل یا ثبت شدهاند.
- Seed، Path، version، Build و Counterexample در Failure موجودند.
- Counterexample بدون جستوجوی تازه Replay میشود.
- مثالهای تاریخی و مرزی کنار Property باقی ماندهاند.
- Valid و Invalid behavior Propertyهای جدا دارند.
- Side effect، Cleanup و No-partial-commit Oracle دارند.
- Run budget از Risk/Time آمده، نه KPI نمایشی.
- Mutation یا Fault injection قدرت Property را سنجیده است.
- PII/Secret در Generated data و Evidence وجود ندارد.
پرسشهای متداول دربارهٔ تست مبتنی بر ویژگی
آیا PBT همان تولید دادهٔ تصادفی است؟
خیر. Random/seeded generation فقط بخشی از کار است. PBT به Domain model، Property/Oracle، Distribution، Shrink و Replay نیاز دارد. تولید هزار String تصادفی بدون قانون معنادار، Property-Based Testing کامل نیست.
آیا Passشدن تعداد زیاد Run صحت برنامه را اثبات میکند؟
خیر. Runها فقط نمونههایی از فضای تعریفشدهاند. نتیجه به Generator، Distribution، Oracle، Budget و Environment وابسته است. اثبات رسمی، Exhaustive testing و PBT مفاهیم متفاوتیاند؛ بااینحال PBT میتواند Counterexampleهایی پیدا کند که مثالهای دستی از آنها عبور کردهاند.
Counterexample Shrunk همیشه Root cause یا کوچکترین ورودی است؟
نه. Shrinker یک Case سادهتر در مسیر و بودجهٔ خودش پیدا میکند. آن Case سرنخ تشخیص است، نه لزوماً Global minimum یا علت ریشهای. Tester هنوز باید Validity، Mechanism، State و Evidence را بررسی کند.
آیا Property-Based Testing جای Unit Test و تست مثالمحور را میگیرد؟
خیر. Exampleها Requirement و Narrative دقیق را خوانا میکنند؛ PBT قانون عمومی و تنوع داده را میسنجد. BVA/EP، Integration، Security، Performance و Exploratory Testing نیز Riskهای دیگری دارند. بهترین Portfolio این شواهد را ترکیب میکند.
از کدام بخش محصول شروع کنیم؟
یک تابع یا Component پرریسک با Domain محدود، Oracle مستقل و اجرای سریع انتخاب کنید: محاسبه Money، Parser/Serializer، Normalizer، Permission set یا State reducer. یک Property قوی با Generator قابلمشاهده بهتر از ده Property مبهم روی E2E ناپایدار است.
جمعبندی
تست مبتنی بر ویژگی هنر تولید عدد تصادفی نیست؛ هنر تبدیل قرارداد به گزارهای است که ماشین بتواند برای آن Counterexample بجوید. Generator فضای جستوجو را تعریف میکند، Property معنی Failure را، Distribution ریسک دیدهشده را، Shrinker قابلیت تشخیص را و Seed/Path امکان بازتولید را.
با یک Risk واقعی شروع کنید. Domain و Oracle را پیش از API ابزار بنویسید، Valid/Invalid را جدا، Bucketها را مشاهده، Counterexample را بازپخش و به Regression تبدیل کنید. PBT وقتی ارزشمند است که یک Failure کوچک و قابلتوضیح به تصمیم بهتر منجر شود؛ نه وقتی فقط تعداد Runها را بزرگ میکند.

