هیچ لایهٔ تستی بهتنهایی booking-platform را قابلاعتماد نمیکند. Unit برای تصمیم محلی، Integration برای transaction و database، Contract برای سازگاری مصرفکننده، E2E برای مسیر کاربر، Property برای invariant، Mutation برای قدرت Oracle و Visual برای ظاهر و تعامل مناسب است.
## اهداف یادگیری
میتوانی برای یک ریسک لایهٔ مناسب انتخاب کنی، property idempotent rescheduling بنویسی، مسیر Playwright را با assertion رفتاری طراحی کنی و تفاوت coverage با evidence را توضیح دهی.
## جایگاه در چرخه
پس از Red و reproduction، اکنون Verification را لایهبندی میکنیم تا هر Slice بازخورد متناسب بگیرد. قرارداد S1 تا S3 فصل ۵ حفظ میشود: پاسخ canonical، tenant context از credential، replay durable و conflict همزمان.
## هفت نوع آزمون
**Unit** وابستگیها را حذف میکند و برای policy cancellation یا fingerprint سریع است. **Integration** چند component واقعی و database را برای transaction، audit و constraint میسنجد. **Contract** شکل و معنای response را بین API و consumer حفظ میکند. **E2E** مسیر واقعی کاربر از browser تا backend را بررسی میکند، اما کندتر و پرهزینهتر است.
**Property-based** بهجای چند مثال، invariant را روی ورودیهای تولیدشده میسنجد. **Mutation** تست را با تغییر عمدی implementation به چالش میکشد. **Visual** screenshot، layout و accessibility state را در browser مقایسه میکند؛ pixel diff جای assertion business نیست.
مثال بد
برای reschedule فقط snapshot component و ۱۰۰٪ line coverage ثبت شود. این evidence نمیگوید دو retry یک mutation ساختهاند، consumer قدیمی contract را میفهمد یا database جلوی double-booking را میگیرد.
مثال اصلاحشده: property idempotency
برای key و fingerprint یکسان:
fc.assert(fc.asyncProperty(validReschedule(), async input => {
const first = await api.reschedule(input);
const second = await api.reschedule(input);
expect(second).toEqual(first);
expect(await store.mutationCount(input.appointmentId)).toBe(1);
expect(await audit.count({ appointmentId: input.appointmentId })).toBe(1);
}));Property باید دقیق باشد: همان `tenantId + actorId + operation` و fingerprint برابر replay میشود؛ fingerprint متفاوت با key reuse باید `409 IDEMPOTENCY_KEY_REUSED` بدهد. اگر property فقط `second.status === 200` کند، mutation تکراری پنهان میماند.
## مثال Playwright
test("owner reschedules an appointment once", async ({ page }) => {
await page.goto("/appointments/apt-41");
await page.getByLabel("زمان آغاز").fill("2026-09-12T10:00");
await page.getByRole("button", { name: "تأیید تغییر" }).click();
await expect(page.getByRole("status")).toHaveText("زمان ذخیره شد");
await expect(page.getByTestId("appointment-version")).toHaveText("42");
});E2E باید با fixture tenant و clock کنترلشده اجرا شود و network response یا backend assertion مکمل داشته باشد. فقط screenshot سبز کافی نیست؛ کاربر ممکن است پیام موفقیت ببیند اما audit دو بار ساخته شده باشد.
## سناریوی booking-platform و ماتریس انتخاب
برای cancellation window: Unit policy. برای race protected write: Integration با barrier. برای `{id,startAt,endAt,version}`: Contract. برای owner flow: Playwright E2E. برای replay: Property + Integration. برای حذف شرط conflict: Mutation. برای calendar card: Visual + accessibility assertion. این انتخاب بر اساس failure mode است، نه محبوبیت ابزار.
مقایسهٔ ابزار باید محدود و تاریخدار باشد: Vitest runner و mocking را فراهم میکند، Playwright browser automation و trace دارد، Stryker mutant میسازد؛ هیچکدام proof عمومی کیفیت نیستند. نسخه و فرمان را در Evidence ثبت کن.
## آزمایشگاه گامبهگام
**ورودی:** acceptance examples، API contract، fixture tenant و browser test environment.
۱. جدول ریسک بساز: ریسک، لایه، Oracle، هزینه و فرمان. یک ریسک را به چند لایهٔ بیدلیل تکرار نکن.
۲. unit و integration را اجرا کن:
pnpm exec vitest run apps/api/src/modules/appointments۳. property را با seed ثبتشده اجرا کن تا failure قابل بازتولید باشد:
pnpm exec vitest run reschedule.property.test.ts --reporter=verbose۴. E2E را با Playwright و browser project اجرا کن:
pnpm exec playwright test apps/web/tests/e2e/reschedule.spec.ts --trace=on**خروجی مورد انتظار:** unit سریع سبز، integration یک mutation/audit، property replay invariant، E2E پیام و version، و trace قابل بازبینی.
**Recovery:** اگر E2E flaky شد، ابتدا selector، clock و fixture isolation را بررسی کن؛ assertion را حذف نکن. اگر property counterexample داد، seed و کوچکترین input را ذخیره و به regression example تبدیل کن. اگر visual diff به علت font محیط است، baseline محیط را pin کن یا تفاوت را مستند کن؛ threshold را بیدلیل باز نکن.
## چگونه لایهها را به یک زنجیرهٔ شواهد تبدیل کنیم
لایههای آزمون مجموعهای از badgeها نیستند؛ هرکدام باید یک سؤال مشخص را پاسخ دهند. برای هر ریسک، ابتدا failure mode را بنویس: چه چیزی ممکن است خراب شود، اثر آن چیست، و کدام مشاهده ثابت میکند که خرابی رخ نداده است. سپس نزدیکترین تست ارزان را انتخاب کن و فقط اگر آن تست مرزهای لازم را نمیبیند، لایهٔ بعدی را اضافه کن. این روش از آن جلوگیری میکند که یک E2E بزرگ را برای همهٔ policyها اجرا کنیم و هنگام شکست ندانیم مشکل از UI، شبکه، auth یا database بوده است.
برای مثال، «deadline در مرز دقیق ۲۴ ساعت» یک سؤال محلی است و Unit با Instantهای ثابت پاسخ خوبی میدهد. «در برابر دو درخواست همزمان فقط یک slot رزرو شود» به transaction و constraint وابسته است و Integration لازم دارد. «consumer قدیمی کد خطا را بفهمد» سؤال Contract است. «مالک بتواند از صفحهٔ appointment تغییر را ثبت کند» مسیر E2E میخواهد. این تستها جای هم را نمیگیرند؛ هرکدام بخشی از زنجیره را میپوشانند و محدودیت خود را دارند.
طراحی property بدون پنهان کردن counterexample
در property-based testing، generator بخشی از specification عملی است. generator باید statusهای معتبر و نامعتبر، فاصلههای زمانی صفر و منفی، timezoneهای متفاوت، keyهای تکراری و fingerprintهای متفاوت را تولید کند. اگر فقط ورودی happy path بسازی، سبز شدن property معنای کمی دارد. هر failure باید با seed، نسخهٔ generator و کوچکترین counterexample ذخیره شود. تبدیل counterexample به regression test مهم است، چون property در اجرای بعدی ممکن است با تغییر generator همان نمونه را تولید نکند.
برای replay، property را به دو invariant جدا تقسیم کن: پاسخ replay با همان fingerprint باید canonical و همسان باشد؛ state durable، mutation count و audit count نیز باید ثابت بماند. سپس fingerprint متفاوت با همان idempotency key را آزمایش کن و انتظار `409 IDEMPOTENCY_KEY_REUSED` داشته باش. اگر فقط status را مقایسه کنی، ممکن است درخواست دوم پاسخ مشابه بدهد ولی در پشت صحنه رکورد یا notification دیگری بسازد. این همان جایی است که Property و Integration باید همدیگر را تکمیل کنند.
Contract و E2E را از هم اشتباه نگیریم
Contract test میتواند بدون browser اجرا شود و شکل، status و معنای خطا را برای consumer تضمین کند. این تست باید مثالهای موفق، validation، tenant isolation و خطاهای canonical را شامل شود؛ صرفاً بررسی type کافی نیست. E2E برعکس، routing، auth، selector، loading state و پیام قابل مشاهده را میسنجد. E2E نباید implementation داخلی را assert کند، اما باید در صورت موفقیت یک backend evidence مکمل برای version یا audit داشته باشد. screenshot سبز ممکن است با mutation دوباره یا audit ناقص همراه باشد.
برای کاهش flaky بودن E2E، fixture را با شناسهٔ run جدا کن، clock را کنترل کن، viewport و font را pin کن و cleanup را در finally انجام بده. retry خودکار را بهعنوان evidence موفقیت حساب نکن؛ trace اجرای اول و علت retry را نگه دار. اگر selector به متن تصادفی یا ترتیب DOM وابسته است، آن را با role و label پایدار جایگزین کن. در visual testing، threshold باید با دلیل و baseline محیط همراه باشد؛ باز کردن threshold برای خاموش کردن diff، تست را از سؤال اصلی دور میکند.
Mutation testing و معیار پایان
Mutation testing برای کشف assertionهای بیاثر مفید است، اما هر mutant ارزش یکسان ندارد. ابتدا mutationهای مربوط به status، شرط authorization، مرز زمان و count mutation را اولویت بده. اگر mutant مهم زنده ماند، یا Oracle ناقص است یا لایهٔ غلط انتخاب شده است. کشتن همهٔ mutantها هدف اقتصادی واقعبینانهای نیست؛ mutantهای unreachable یا مربوط به logging ممکن است با rationale کنار گذاشته شوند. این تصمیم باید در Evidence ثبت شود، نه اینکه ابزار silently آنها را exclude کند.
در نهایت، «سبز» را به سطح شواهد محدود کن. Unit سبز میگوید مثالهای policy درستاند، Integration سبز میگوید fixture و transaction چنین رفتار میکنند، Contract سبز میگوید interface توافقشده حفظ شده و E2E سبز میگوید مسیر کاربر در محیط مشخص کار میکند. هیچکدام ظرفیت، availability یا نبود race در production را بهتنهایی ثابت نمیکند. گزارش باید کنار هر فرمان، محیط، seed، نسخه و limitation را بنویسد تا تصمیم release از یک عدد coverage بزرگتر اما مبهم ساخته نشود.
## ساخت ماتریس ریسک عملی
برای هر Slice یک ماتریس کوچک بساز: ردیف اول failure mode، ردیف دوم لایهٔ اصلی، ردیف سوم Oracle، ردیف چهارم هزینه و ردیف پنجم recovery. اگر یک ریسک دو لایه دارد، نقش هر لایه را توضیح بده. مثلاً Unit تصمیم eligibility را میسنجد و Integration عدم mutation را؛ اجرای دوبارهٔ هر دو بدون تفاوت شواهد فقط زمان CI را زیاد میکند. ماتریس را قبل از نوشتن تست ثبت کن تا انتخاب ابزار تحت تأثیر تستی که از قبل وجود دارد قرار نگیرد.
برای booking-platform، یک نمونهٔ ماتریس چنین reasoningای دارد: cross-tenant access با Integration و Contract پوشش داده میشود، چون هم auth و هم response privacy مهماند؛ timezone boundary با Unit و چند example صریح پوشش داده میشود، چون database برای این سؤال لازم نیست؛ double-booking با Integration دارای barrier پوشش داده میشود، چون ترتیب همزمانی و constraint واقعیاند؛ و مسیر owner با E2E پوشش داده میشود، چون رفتار browser بخشی از ریسک است. نتیجه باید با failure mode توضیح داده شود، نه با محبوبیت Vitest یا Playwright.
## خطاهای رایج و نشانهها
- استفاده از E2E برای همهچیز؛ CI کند و failure علت نامشخص دارد.
- اعلام coverage بهعنوان correctness؛ branch پوشش داده شده اما Oracle ضعیف است.
- property با generator محدود به happy path؛ edge case واقعی تولید نمیشود.
- screenshot بدون semantic assertion؛ ظاهر درست و رفتار غلط است.
- contract test فقط type را میسنجد و status/error semantics را نادیده میگیرد.
## چکلیست Verification
- [ ] هر ریسک دقیقاً لایه و Oracle مناسب دارد.
- [ ] property replay، fingerprint متفاوت و count mutation را میسنجد.
- [ ] Playwright مسیر واقعی، پیام، version و trace دارد.
- [ ] mutationهای critical با suite کشته میشوند.
- [ ] visual evidence با browser و baseline مشخص ثبت شده است.
## تمرین مستقل
برای «actor cross-tenant باید 404 بدون PII بگیرد» یک unit، integration، contract و E2E candidate پیشنهاد کن و یکی را انتخاب نهایی کن.
**Rubric پاسخ:** unit برای serializer یا policy، integration برای auth+store، contract برای status/body، E2E برای مسیر کاربر؛ انتخاب نهایی باید دلیل risk، هزینه و Oracle داشته باشد.
## سطح شواهد و انتخاب اقتصادی
هر لایه trade-off دارد. Unit سریع و تشخیصی است، اما مرز واقعی database را حذف میکند. Integration کندتر است و به fixture و cleanup نیاز دارد، ولی برای transaction و audit ارزش بالایی دارد. Contract با هزینهٔ کم شکستن consumer را زود نشان میدهد. E2E نزدیک به تجربهٔ کاربر است، اما selector، browser، شبکه و دادهٔ seed آن flaky میشوند. Property فضای بزرگی از ورودی را میکاود، ولی generator بد میتواند فقط happy path بسازد. Mutation هزینهٔ اجرا دارد و باید به moduleهای riskدار محدود شود. Visual به baseline محیط، font و viewport حساس است.
پس تعداد تست یا درصد coverage را بهعنوان هدف مستقل نگذار. برای هر ریسک بنویس «چه چیزی ممکن است خراب شود، کدام تست آن را میگیرد، و اگر سبز شد چه چیزی هنوز ناشناخته میماند». در booking-platform، property replay و integration count مکملاند؛ یکی domain invariant را روی ورودیهای مختلف میسنجد و دیگری storage واقعی را. Playwright پیام و version را میبیند، ولی audit را از API یا database باید جداگانه بررسی کرد. این تفکیک از اجرای گران و تکراری تستهای نامرتبط جلوگیری میکند.
## جمعبندی
لایهٔ آزمون را از failure mode انتخاب کن. Unit سریع است، Integration invariant زیرساختی را میسنجد، E2E مسیر کاربر را، Property فضای ورودی را و Mutation قدرت تست را. ترکیب آنها Evidence است، نه جمعکردن badgeها.
## منابع مستقیم
- [Playwright — Test assertions](https://playwright.dev/docs/test-assertions) — مستند رسمی assertion و انتظار خودکار.
- [Playwright — Trace viewer](https://playwright.dev/docs/trace-viewer) — مستند رسمی شواهد runtime.
- [QuickCheck paper](https://doi.org/10.1145/351240.351266) — منبع اولیهٔ property-based testing.
- [Vitest — Coverage](https://vitest.dev/guide/coverage) — coverage را از correctness جدا میکند.
- [Stryker — Why mutation testing](https://stryker-mutator.io/docs/mutation-testing-elements/why-mutation-testing/) — توضیح رسمی هدف mutation testing.
به زبان ساده
هر failure mode لایهٔ آزمون مناسب خود را دارد: Unit برای policy، Integration برای transaction، Contract برای response و E2E برای مسیر کاربر. Coverage درصدی جای Evidence رفتاری و persistent state را نمیگیرد.
مثال واقعی
در ماتریس فرضی دوره، replay با Property و Integration، race با Integration barrier و پیام قابلدسترسی با Playwright بررسی میشود؛ screenshot بهتنهایی audit را اثبات نمیکند.