تغییر متن دکمه «ثبت سفارش» به «پرداخت» نباید ۷۴ تست را بشکند. اگر چنین شد، مشکل فقط Locator نیست؛ Suite درباره جزئیات UI بیش از حد میداند. در سوی دیگر، یک Page Object دوهزارخطی که همه کلیکها، APIها، Assertionها و دادهها را پنهان کرده شاید امروز سبز باشد، اما فردا هیچکس نمیفهمد شکست از محصول است یا از معماری تست.
کد تست قابل نگهداری کدی نیست که بیشترین Design Pattern را دارد. باید قصد سناریو را آشکار، تغییر محصول را محلی، اجرا را مستقل، Failure را تشخیصپذیر و حذف یا Refactor کردن تست را کمریسک کند. در این راهنما Page Object Model و SOLID را با همین معیار میسنجیم و یک معماری عملی Playwright برای Checkout ایرانی میسازیم.
خلاصه عملی: رفتار قابل مشاهده را تست کنید؛ مرزهای پرتغییر را پشت APIهای کوچک پنهان کنید؛ Composition را بر ارثبری ترجیح دهید؛ صراحت تست را فدای DRY نکنید؛ Test Double را فقط برای یک دلیل روشن بهکار ببرید؛ داده و زمان را کنترل کنید؛ و Retry را درمان Flakiness ندانید.
نگهداریپذیری کد تست یعنی چه؟
نگهداریپذیری یک صفت مبهم مانند «تمیزی» نیست؛ رفتار Suite هنگام تغییر است. اگر یک تغییر مجاز در UI یا Implementation، دهها سناریوی نامرتبط را ویرایش کند، Change Amplification بالاست. اگر Failure فقط «Timeout» بدهد، زمان تشخیص زیاد است. اگر یک تست به نتیجه تست قبلی وابسته باشد، اجرای محلی و موازی قابل اعتماد نیست.
شش ویژگی قابل مشاهده
| ویژگی | پرسش ممیزی | شاهد |
|---|---|---|
| Behavioral signal | تست رفتار مهم را میسنجد یا Implementation را؟ | Refactor داخلی بیاثر است؛ تغییر رفتار تست را قرمز میکند |
| خوانایی | نام، Arrange، Act و Assert قصد را نشان میدهند؟ | Reviewer بدون بازکردن پنج Helper سناریو را میفهمد |
| Change locality | یک تغییر Product در چند فایل Test تغییر میخواهد؟ | Locator در Component و Rule در Oracle مربوط اصلاح میشود |
| استقلال | تست تنها، موازی و با ترتیب تصادفی اجرا میشود؟ | هیچ State پنهان یا پیشنیاز از تست دیگر ندارد |
| تشخیصپذیری | Failure محیط، داده، عمل و انتظار را نشان میدهد؟ | Trace، Request، ID داده و Assertion معنادار موجود است |
| هزینه بازخورد | زمان اجرا و Triage با ارزشی که میدهد متناسب است؟ | بیشتر شاخهها پایینتر و جریانهای ضروری در E2E هستند |
نگهداریپذیری، Flakiness و سرعت یکی نیستند
تست میتواند بسیار خوانا اما بهدلیل Clock یا شبکه Flaky باشد؛ بسیار سریع اما به Implementation قفل شده باشد؛ یا پایدار اما Assertion بیارزش داشته باشد. معماری خوب زمینه را بهتر میکند، اما خودبهخود همه Flakeها را حذف یا اجرا را سریع نمیکند. برای یک Failure متناوب باید شواهد و Trigger واقعی را پیدا کرد؛ روش آن در راهنمای بازتولید باگ متناوب آمده است.
Page Object Model؛ یک گزینه ساختاردهی، نه معماری کامل
مستندات Playwright، POM را یکی از روشهای ساختاردهی Suite بزرگ معرفی میکند: Selectorها در یک محل و عملیات پرتکرار پشت API سطح بالاتر قرار میگیرند. این تعریف محدود مفید است. POM درباره Test Data، Oracle، Parallelism، Service Virtualization، Portfolio یا Ownership تصمیم نمیگیرد.
Page Object چه چیزی باید بداند؟
- Locatorهای صفحه یا Component؛
- عملهایی که UI واقعاً به کاربر ارائه میدهد؛
- انتقالهای UI و انتظارهای فنی لازم برای آمادهبودن صفحه؛
- در صورت نیاز، Locator یا داده قابل مشاهده برای Assertion تست.
راهنمای Page Object در Selenium نیز میگوید Object باید خدمات صفحه را مدل کند، جزئیات HTML را متمرکز سازد و معمولاً Assertion سناریو را داخل خود نبرد؛ استثنای معقول، بررسی آمادهبودن همان Page/Component است. این مرزبندی باعث میشود تست بگوید «چه چیزی باید درست باشد» و Object بگوید «چگونه با UI تعامل کنیم».
چه زمانی POM اضافهکاری است؟
- یک Spike یا تست کوتاه با دو تعامل ثابت دارید؛
- Abstraction دقیقاً همان خطوط تست را با نامهای مبهم تکرار میکند؛
- صفحه هنوز پرنوسان است و Boundary پایدار شناخته نشده؛
- تست Component یا API است و مفهوم «صفحه» ندارد؛
- API ساختهشده فقط WebDriver/Page را دوباره بستهبندی میکند.
از ابتدا برای هر Route یک کلاس نسازید. وقتی یک مفهوم UI در چند سناریو تکرار شد یا تغییرش چند تست را شکست، Boundary واقعی را استخراج کنید. در انتخاب POM، Screenplay، Task/Action یا DSL، معیارهای PoC و هزینه مالکیت انتخاب فریمورک اتوماسیون را بهکار ببرید.
هفت بوی بد در Page Objectها
| بو | نشانه | اصلاح |
|---|---|---|
| God Page | صدها Locator و Action نامرتبط | Page Component یا Capability کوچک استخراج کنید |
| BasePage متورم | همه Pageها Wait، API، DB، Screenshot و Navigation ارث میبرند | Composition و Fixtureهای هدفمند |
| Assertion پنهان | submitOrder() چند نتیجه کسبوکار را Assert میکند |
عمل را از Oracle جدا و انتظار را در Spec آشکار کنید |
| Locator leakage | تست برای ادامه کار به CSS یا Page خام دسترسی میگیرد | خدمت یا Locator معنادار همان Component را ارائه کنید |
| Magic workflow | login() کاربر میسازد، Feature Flag عوض میکند و Cache پاک میکند |
Arrange را به Fixture/Service و UI Act را به Page بسپارید |
| Navigation assumption | یک Click همیشه نوع صفحه بعد را برمیگرداند | نتیجههای موفق/خطا را صریح یا در تست Assert کنید |
| Sleep wrapper | waitAndClick همهجا Timeout ثابت دارد |
Condition و Web-first Locator/Assertion را استفاده کنید |
SOLID در کد تست؛ ترجمه عملی و محدودیتها
SOLID از طراحی شیءگرا آمده است. مقاله Robert C. Martin درباره ارتباط SOLID بر مدیریت وابستگی و تغییر تأکید میکند؛ اما نامبردن از پنج اصل، طراحی خوب را اثبات نمیکند. در Test Code باید هزینه انتزاع، صراحت سناریو و عمر Suite را هم دید.
SRP؛ یک دلیل برای تغییر، نه یک Assertion
Single Responsibility درباره محور تغییر است. CouponPanel با تغییر UI کوپن عوض میشود؛ OrderBuilder با تغییر قرارداد داده؛ و Oracle مبلغ با تغییر قانون مالی. قراردادن همه آنها در CheckoutPage سه دلیل تغییر میسازد. از آن سو، «هر تست فقط یک Assert» تعبیر SRP نیست. یک رفتار میتواند چند پیامد مرتبط داشته باشد: وضعیت سفارش، مبلغ و پیام موفقیت.
OCP؛ Extension Point را بعد از مشاهده تغییر بسازید
Open/Closed مجوز ساخت Framework عمومی برای آینده فرضی نیست. اگر سه PSP با قرارداد یکسان دارید، Adapter قابل تعویض ارزشمند است؛ اگر فقط یک مسیر دارید، Interface و Factory چندلایه شاید هزینه بیفایده باشد. ابتدا محور Variation را مشاهده کنید، سپس با Composition یا Configuration آن را جدا کنید. ارثبری تنها راه توسعه نیست و معمولاً در Test Suite کوپلینگ پنهان میسازد.
LSP؛ فقط وقتی Polymorphism واقعی دارید
اگر Clock واقعی و Fake، یا Adapter چند Browser/PSP یک Interface دارند، هر جایگزین باید Precondition، Postcondition و Error semantics قرارداد را حفظ کند. Fake که هر درخواست را موفق میکند جانشین معتبر سرویس خطاپذیر نیست. یک Contract Test مشترک را روی همه Implementationها اجرا کنید. اگر هیچ Subtype ندارید، افزودن Hierarchy برای «رعایت LSP» معنایی ندارد.
ISP؛ Capability کوچکتر از Driver همهکاره
تستی که فقط Clock کنترلشده میخواهد نباید Fixture عظیمی شامل Admin API، Mailbox، Database و Browser بگیرد. Interface/Fixture را براساس Capability مصرفکننده بسازید: createOrder، freezeClock، readOtp. بااینحال دهها Wrapper تکمتدی بدون Boundary واقعی هم خوانایی را کم میکنند.
DIP؛ به مرز پایدار وابسته شوید، نه به Mock بیشتر
وابستگی سطح بالا را از جزئیات پرتغییر جدا کنید: Test به زبان Checkout وابسته باشد، نه DOM؛ Domain Rule به Clock abstraction، نه زمان سیستم. اما DIP نمیگوید همه چیز را Mock کنید. Test Double نامناسب میتواند تست را به Sequence فراخوانیهای داخلی قفل کند و Refactor سالم را بشکند.
ماتریس تصمیم SOLID
| مشکل واقعی | اصل راهنما | راهحل کمهزینه | زیادهروی |
|---|---|---|---|
| UI و داده با هم تغییر میکنند | SRP | Page Component و Data Builder جدا | یک کلاس برای هر Locator |
| سه پیادهسازی با قرارداد مشترک | OCP/LSP | Adapter + Contract Test | Hierarchy برای یک پیادهسازی |
| Fixture عظیم و اجباری | ISP | Fixtureهای Capabilityمحور | Wrapperهای ریز بدون معنا |
| Clock/PSP غیرقطعی | DIP | Port پایدار + Fake/Stub هدفمند | Mock کردن همه کلاسهای داخلی |
معماری لایهای کد تست؛ چه چیزی کجا باشد؟
| لایه | مسئولیت | نباید بداند |
|---|---|---|
| Spec/Test | سناریو، Act و Oracle قابل مشاهده | CSS عمیق، SQL آمادهسازی و راز محیط |
| Task/Workflow | عمل کسبوکاری چندمرحلهایِ واقعاً مشترک | انتظار اختصاصی هر سناریو |
| Page/Component | Locator و خدمات UI | ساخت داده Backend و سیاست Retry Suite |
| Driver/Adapter | API، DB، Mail، Clock و سرویس ثالث | قصد سناریوی UI |
| Builder/Fixture | Arrange، داده، Scope و Cleanup | Assertion نتیجه محصول |
| Oracle/Matcher | مقایسه معنایی و پیام Failure | ناوبری و آمادهسازی مخفی |
| Runner/Config | Project، Timeout، Retry، Trace، Secret و Parallelism | قانون کسبوکار سناریو |
هر پروژه به همه لایهها نیاز ندارد. برای Suite کوچک، Spec + Component + Fixture کافی است. لایه را وقتی اضافه کنید که یک Boundary، مالک و دلیل تغییر جدا دارد. هدف اتوماسیون تست تولید کد بیشتر نیست؛ بازخورد تکرارپذیر با هزینه قابل کنترل است.
نمونه Playwright؛ Composition بهجای BasePage
نمونه زیر Component کوپن، Page پرداخت، Service داده و Fixture را جدا میکند. Test قصد را نگه میدارد و Assertion کسبوکار داخل Page Object پنهان نشده است:
import { test as base, expect } from '@playwright/test';
class CouponPanel {
constructor(page) {
this.code = page.getByLabel('کد تخفیف');
this.applyButton = page.getByRole('button', { name: 'اعمال کد' });
this.status = page.getByRole('status');
}
async apply(code) {
await this.code.fill(code);
await this.applyButton.click();
}
}
class CheckoutPage {
constructor(page) {
this.page = page;
this.coupon = new CouponPanel(page);
this.total = page.getByTestId('order-total-rial');
this.payButton = page.getByRole('button', { name: 'پرداخت' });
}
async open(orderId) {
await this.page.goto(`/checkout/${orderId}`);
}
}
function orderInput(overrides = {}) {
return {
currency: 'IRR',
items: [{ sku: 'BOOK-1', quantity: 1, unitPrice: 500000 }],
...overrides,
};
}
async function createOrder(request, input) {
const response = await request.post('/api/test-support/orders', { data: input });
await expect(response).toBeOK();
return response.json();
}
const test = base.extend({
checkout: async ({ page }, use) => {
await use(new CheckoutPage(page));
},
});
test('کوپن معتبر مبلغ نهایی را یک بار کم میکند', async ({ checkout, request }) => {
const order = await createOrder(request, orderInput());
await checkout.open(order.id);
await checkout.coupon.apply('IRAN10');
await expect(checkout.coupon.status).toHaveText('تخفیف اعمال شد');
await expect(checkout.total).toHaveText('۴۵۰٬۰۰۰ ریال');
await expect(checkout.payButton).toBeEnabled();
});
چرا این ساختار قابل تغییرتر است؟
- تغییر Label یا Locator کوپن فقط
CouponPanelرا تغییر میدهد. - تغییر قرارداد ساخت Order فقط Service/Builder را متأثر میکند.
- تغییر انتظار مبلغ در همان Spec دیده میشود و مخفی نیست.
- Component با Composition داخل Checkout قرار گرفته؛ هیچ BasePage اجباری وجود ندارد.
- Test Data صریح و Deterministic است؛ مقدار تصادفی Oracle را مبهم نمیکند.
Fixtureهای Playwright برای ساخت Context قابل اعتماد و تزریق وابستگی به Test طراحی شدهاند. Scope را آگاهانه انتخاب کنید: Fixture سطح Worker سریعتر است ولی State مشترک میسازد؛ Fixture سطح Test ایزولهتر است ولی Setup گران را تکرار میکند. انتخاب باید با قابلیت Reset و Parallelism سازگار باشد.
Page Component، Task و Service؛ مرز را از زبان دامنه بگیرید
Page Component
بخشی با Root و رفتار مستقل مانند Cart، Date Picker، OTP Panel یا Header را Component کنید. Component باید فقط داخل Root خودش Locator بزند تا دو نمونه همزمان صفحه با هم اشتباه نشوند. «صفحه» لزوماً واحد مناسب نیست؛ SPA ممکن است ده Component مستقل داشته باشد.
Task/Workflow
عملی مانند «خرید بهعنوان مشتری مهمان» چند Page را طی میکند و در سناریوهای متعدد به یک معنا تکرار میشود. آن را Task کنید، اما گزینههای مهم را پنهان نکنید. buyProduct() با ۱۲ Default نامرئی از چند خط صریح خطرناکتر است. Task باید زبان دامنه، ورودی محدود و خروجی قابل Assert داشته باشد.
Service/Driver
Arrange سریع را از UI خارج کنید: Order را با API مجاز تست بسازید، Mailbox را با Client جدا بخوانید و Clock را از Test Support کنترل کنید. اما اگر هدف سناریو خود فرم ثبت سفارش است، دورزدن آن با API، رفتار هدف را حذف میکند. مرز Setup و Act باید در Test واضح باشد.
DRY یا خوانایی؟ تکرار دانش را حذف کنید، نه هر خط مشابه
دو تست ممکن است سه خط Setup مشابه داشته باشند اما دو داستان مستقل تعریف کنند. استخراج زودهنگام آنها به prepareEverything()، معنا را پنهان و تغییر یک سناریو را به دیگری وصل میکند. راهنمای Best Practices پلی رایت صریحاً مقدار کمی تکرار را وقتی Test سادهتر و خواناتر میماند قابل قبول میداند.
راهنمای Practical Test Pyramid نیز تعادل DRY با عبارتهای توصیفی و معنادار را مطرح میکند. قانون عملی:
- تکرار یک قاعده کسبوکار را در Matcher/Oracle متمرکز کنید.
- تکرار یک جزئیات پرتغییر UI را در Component متمرکز کنید.
- تکرار کوچک Arrange خوانا را تا زمانی که واقعاً با هم تغییر نکرده، تحمل کنید.
- پس از سومین کاربرد هممعنا Refactor کنید، نه پس از دومین شباهت ظاهری.
BaseTest؛ راحتی امروز، Coupling فردا
BaseTest اغلب Hook، Login، Driver، DB، Screenshot و Cleanup را به همه Testها تحمیل میکند. Subclass نمیداند کدام State از کجا آمده و Override ترتیب را میشکند. Fixtureهای صریح و Composition، Dependency را در Signature Test نشان میدهند. Base کوچک فقط برای قرارداد واقعاً مشترک Runner قابل دفاع است؛ نه انبار Utilityها.
ساختار هر تست؛ Arrange، Act و Assert را آشکار کنید
راهنمای Unit Test مایکروسافت الگوی Arrange–Act–Assert، نام توصیفی و پرهیز از منطق پیچیده در Test را توصیه میکند. همین شکل در زبان Given–When–Then نیز قابل استفاده است.
یک رفتار، نه الزاماً یک Assert
Test «پرداخت موفق» میتواند وضعیت سفارش، شماره پیگیری و مبلغ را با سه Assert مرتبط بررسی کند. شکستن آن به سه E2E، Setup و زمان را تکرار و داستان را جدا میکند. مرز بهتر: یک Act اصلی و یک Outcome منسجم. اگر Failure اول، بقیه شواهد را پنهان میکند، Soft Assertion یا Testهای پایینتر را با احتیاط بهکار ببرید.
از منطق برنامهمانند دوری کنید
ifبرای اینکه Test در هر Environment «به شکلی پاس شود» ننویسید.- Expected را با همان Algorithm محصول محاسبه نکنید.
- Loop بزرگ که صد Case را یک Test میکند، Failure را مبهم میسازد؛ Parametrization با ID Case بهتر است.
- Random بدون Seed و Shrink/Replay، بازتولید را دشوار میکند.
- Retry داخل Helper برای پنهانکردن خطا ننویسید؛ Condition و Deadline را صریح کنید.
استقلال تست؛ Browser Context تنها بخشی از State است
Isolation در Playwright برای هر Test یک Browser Context جدا با Cookie، Local Storage و Session Storage مستقل میسازد. این قابلیت ارزشمند است، اما رکورد Database، Queue، Cache، Bucket، Mailbox و PSP Sandbox همچنان میتوانند مشترک باشند.
الگوهای داده موازی
- برای هر Test یک Namespace یا شناسه یکتا و قابل Trace بسازید.
- داده را از API/Fixture کنترلشده ایجاد و ID را در Artifact ثبت کنید.
- بهجای «پاککردن همه»، Cleanup را به Scope همان Test محدود کنید.
- اگر Cleanup شکست خورد، داده با TTL و Owner قابل جمعآوری باشد.
- تست به ترتیب فایل، Worker یا ساعت اجرا وابسته نباشد.
- Clock، Locale، Timezone و Feature Flag در Metadata اجرا ثبت شوند.
برای Data Builder، Fixture، Synthetic data و چرخه حذف، راهنمای انتخاب داده تست واقعی یا مصنوعی را ببینید. «هر Test داده تازه بسازد» قانون مطلق نیست؛ Snapshot تغییرناپذیر یا Seed مشترک Read-only میتواند امن باشد، به شرط آنکه Mutability و Scope روشن باشند.
Locator و Wait؛ قرارداد کاربر را ترجیح دهید
Locator خوب فقط Selector کوتاه نیست؛ قراردادی است که با رفتار مورد نظر همراستاست. برای دکمه پرداخت، Role و Accessible Name معمولاً از زنجیره CSS پایدارتر و معنادارتر است. Test ID زمانی مفید است که محتوا متغیر یا نقش کافی نیست و تیم آن را یک قرارداد رسمی بداند.
سیاست Locator پیشنهادی
- Role + Accessible Name برای کنترلهای کاربر؛
- Label برای Form Control؛
- Text برای محتوای پایدار و هدف سناریو؛
- Test ID قراردادی برای Component یا داده پویا؛
- CSS/XPath فقط در نبود قرارداد بهتر، داخل Component.
Auto-waiting به معنی «هر چیزی بالاخره درست میشود» نیست. انتظار را به Condition مشاهدهپذیر متصل کنید: Response مشخص، Status، URL، تعداد ردیف یا Enabled شدن کنترل. waitForTimeout(5000) هم کند است و هم در ماشین کندتر میشکند. این اصول در تست چندمرورگری هم اهمیت دارند؛ تنظیم Matrix را در راهنمای Cross Browser با Playwright تکمیل کنید.
Test Double؛ Dummy، Stub، Spy، Mock و Fake یکی نیستند
Martin Fowler در تعریف Test Double چند نقش را تفکیک میکند: Dummy فقط آرگومان را پر میکند؛ Stub پاسخ ازپیشتعیینشده میدهد؛ Spy فراخوانی را ثبت میکند؛ Mock انتظار تعامل دارد؛ و Fake پیادهسازی سبک اما کارا است. انتخاب نادرست، Test را یا کند و غیرقطعی یا بیش از حد وابسته به Implementation میکند.
| نیاز | Double مناسب | ریسک |
|---|---|---|
| Clock ثابت | Fake Clock | اگر Timezone/DST قرارداد واقعی را مدل نکند |
| پاسخ خطای PSP مشخص | Stub/Service Virtualization | Drift از قرارداد Provider |
| اثبات ارسال یک Event | Spy یا Mock در مرز | قفلشدن به تعداد/ترتیب داخلی غیرضروری |
| Repository سریع | Fake یا پایگاه داده موقت | تفاوت Constraint/Transaction با Production |
| وابستگی ثالث E2E | Network Stub برای جریانهای قطعی + Contract/Smoke جدا | اعتماد کاذب بدون آزمون قرارداد |
چه چیزی را Mock نکنیم؟
- کلاسهای داخلی که فقط جزئیات Refactor هستند؛
- Value Objectهای ساده؛
- کتابخانهای که Fake شما رفتار واقعیاش را نمیداند؛
- Browser در تستی که هدفش Integration واقعی UI است؛
- Database وقتی Query/Transaction/Constraint همان هدف آزمون است.
هر Adapter یا Fake مهم را با Contract Test در برابر Implementation واقعی کالیبره کنید. «Mock سبز» فقط سازگاری Test با فرض خودش را ثابت میکند.
Assertion و Oracle؛ پیام Failure بخشی از API تست است
Assertion خوب انتظار، مقدار واقعی، Context و شناسه Business را نشان میدهد. expected true, got false هزینه Triage را بالا میبرد. Matcher دامنهای مانند expectSettlementToBalance() فقط وقتی مفید است که Failure آن اجزای مبلغ، واحد، PSP و تاریخ را گزارش کند و قانون را پنهان نسازد.
Oracle را از SUT مستقل نگه دارید
- Expected را از نمونه دستمحاسبه یا پیادهسازی مستقل بسازید.
- همان Helper تولید مبلغ Product را در Test برای Expected صدا نزنید.
- Snapshot را برای خروجی بزرگِ بازبینیپذیر بهکار ببرید، نه هر JSON متغیر.
- Timestamp، ID و ترتیب نامربوط را پیش از Snapshot Canonical کنید.
- در E2E پیامد کاربر را Assert کنید؛ جزئیات داخلی را به تست پایینتر بسپارید.
Flaky Test را با Retry سبز نکنید
Playwright Retries تستی را که بار اول شکست و در Retry پاس شده جداگانه flaky دستهبندی میکند. Retry برای جمعآوری شاهد یا کاهش اختلال کوتاهمدت CI مفید است، اما Failure اولیه همچنان سیگنال کیفیت است.
درخت Triage
- آیا Product واقعاً رفتار Race/Intermittent دارد؟
- آیا Test روی Time، Random، Network یا State مشترک کنترل ندارد؟
- آیا Locator یا Assertion قبل از Condition درست اجرا میشود؟
- آیا Environment کمبود Resource یا Version Drift دارد؟
- آیا Test فقط در Parallel/Shard خاص میشکند؟
برای هر Retry، Trace، Screenshot، Console، Network، Worker، Seed، Test Data ID و Attempt را نگه دارید. Quarantine باید Owner، دلیل، Issue، تاریخ شروع و SLA داشته باشد. تستی که ماهها Quarantine است Coverage نیست.
ساختار پوشه؛ براساس Capability، نه نوع فایل تنها
tests/
checkout/
apply-coupon.spec.js
payment-failure.spec.js
checkout.page.js
coupon.component.js
checkout.assertions.js
support/
api/
orders.client.js
payments.stub.js
data/
order.builder.js
fixtures/
checkout.fixture.js
observability/
failure-artifacts.js
contracts/
psp.contract.spec.js
playwright.config.js
این ساختار قانون جهانی نیست. مزیت Feature-first این است که تغییر Checkout فایلهای مرتبط را کنار هم میآورد و Ownership روشنتر میشود. Support فقط چیزهای واقعاً Cross-cutting را میگیرد؛ پوشه utils/ نباید محل دفن مسئولیتهای نامعلوم شود.
مثال ایران؛ Checkout، RTL و PSP
یک Suite پرداخت ایرانی باید چند محور تغییر مستقل را جدا کند:
- UI: RTL، Label فارسی، Modal کوپن و پیام خطا؛
- Domain: ریال/تومان، تخفیف، گردکردن و وضعیت سفارش؛
- Data: Order، کاربر، موجودی، موبایل و OTP؛
- External: PSP موفق/ناموفق/Timeout/Callback تکراری؛
- Time: انقضای OTP، تاریخ تهران و پنجره تسویه؛
- Environment: Sandbox، Feature Flag و شبکه محدود.
تفکیک پیشنهادی
Component Object فقط UI کوپن و پرداخت را میشناسد؛ Money و Oracle مالی در لایه Domain است؛ Stub PSP سناریوهای قطعی را میدهد؛ Contract Test شکل Callback را با Provider/Sandbox میسنجد؛ Fixture، Order و Clock را آماده میکند؛ و Spec میگوید کاربر چه میکند و چه نتیجهای میبیند.
سناریوهایی که معماری را میآزمایند
- Label «تومان» به «ریال» تغییر کند: Test باید تغییر رفتار را آشکار کند، نه فقط Locator را.
- Modal کوپن به Drawer تبدیل شود: فقط Component تغییر کند.
- PSP دوم اضافه شود: Adapter تازه Contract مشترک را پاس کند.
- Callback دوبار برسد: Fake/Stub و Integration، Idempotency را پوشش دهند.
- زبان انگلیسی فعال شود: Locator مبتنی بر متن ثابت فارسی کورکورانه نشکند؛ Project/Contract مناسب داشته باشد.
- اعداد فارسی نمایش داده شوند: Oracle مقدار معنایی را از Formatting دیداری تفکیک کند.
بازبینی کد تست؛ چکلیست Pull Request
قصد و Scope
- نام Test شرایط، رفتار و نتیجه را روشن میکند؟
- Test در پایینترین سطحی است که اعتماد لازم را میدهد؟
- Act اصلی و Oracle رفتار قابل مشاهدهاند؟
- Case جدید واقعاً شکافی را پوشش میدهد یا Duplicate است؟
وابستگی و داده
- State، Clock، Random و External dependency کنترل شدهاند؟
- Parallel execution و Unique namespace در نظر گرفته شدهاند؟
- Secret و داده حساس وارد Fixture/Artifact نشده است؟
- Test Double قرارداد و دلیل روشنی دارد؟
طراحی و تشخیص
- Abstraction یک تغییر واقعی را محلی میکند یا فقط خطها را جابهجا کرده؟
- Helper نامی دامنهای و Failure شفاف دارد؟
- Wait به Condition وصل است و Await جا نیفتاده؟
- Failure Artifact برای CI کافی است؟
Lint، Type Check و Static Analysis خطاهایی مانند Promise بدون Await، Import دوری و Complexity را زود میگیرند؛ اما قصد سناریو را داوری نمیکنند. راهاندازی Gate مناسب در راهنمای تحلیل استاتیک کد برای QA توضیح داده شده است.
چگونه خود Test Suite را آزمایش کنیم؟
- Red check: پیش از Fix، Test باید با نقص هدف شکست بخورد.
- Mutation: شرط/Operator مرتبط را موقت تغییر دهید و ببینید Test میکشد یا نه.
- Selector contract: عنصر همنام یا Layout متفاوت اضافه کنید و Strictness را بسنجید.
- Order randomization: Testها را تنها، تصادفی، موازی و Shardشده اجرا کنید.
- Double contract: Fake/Stub را دورهای با Adapter واقعی کالیبره کنید.
- Failure drill: Network delay، ۵۰۰، Timeout و Cleanup failure را القا کنید.
- Delete test: Test را موقت حذف کنید؛ آیا Coverage/Confidence واقعاً کم میشود؟
اشتباهات رایجی مانند Assertion ضعیف، E2E بیش از حد و Test بدون Oracle در ۱۲ اشتباه رایج تست نرمافزار نیز بررسی شدهاند.
متریکهای نگهداریپذیری Test Code
| متریک | تعریف | برداشت درست |
|---|---|---|
| Change amplification | فایل/تست تغییرکرده برای یک تغییر Product | Trend و Hotspot مهمتر از عدد عمومی است |
| First-run failure rate | Failure بار اول تقسیم بر اجرا | Product و Test/Environment را جدا Triage کنید |
| Retry recovery rate | Fail سپس Pass تقسیم بر Failureها | نرخ بالا نشانه بدهی است، نه موفقیت Retry |
| Median diagnosis time | زمان Failure تا علت طبقهبندیشده | کیفیت Artifact و Ownership را نشان میدهد |
| Quarantine age | عمر تست خارجشده از Gate | همراه Owner و Coverage lost گزارش شود |
| Suite feedback time | Commit تا نتیجه قابل اقدام | Queue و Setup را از Execution جدا کنید |
| Value deletion | تستهای زائد/منسوخ حذفشده | افزایش تعداد Test هدف نیست |
LOC، تعداد Page Object، درصد DRY یا تعداد Pattern KPI کیفیت نیستند. متریک باید تصمیمی بسازد: کدام Hotspot Refactor شود، کدام Test پایینتر برود، کدام Flake SLA شکسته و کدام E2E دیگر ارزش ندارد. Pilot و بازنگری روند را با چرخه بهبود مستمر QA پیوند دهید.
برنامه ۳۰روزه Refactor بدون بازنویسی بزرگ
هفته اول: Baseline و یک جریان
- Change amplification، Flaky rate، زمان Suite و زمان Triage را Baseline کنید.
- یک جریان پرتغییر مانند Checkout را انتخاب کنید.
- Testهای زائد، وابسته به ترتیب و بدون Oracle را علامت بزنید.
هفته دوم: Boundary واقعی
- Component پرتکرار را از God Page استخراج کنید.
- Setup Backend را به Client/Fixture صریح منتقل کنید.
- Sleep و CSS شکننده را با Condition و Locator قراردادی جایگزین کنید.
هفته سوم: Isolation و Double
- Unique namespace و Cleanup محدود را برای Parallelism اضافه کنید.
- Clock/PSP غیرقطعی را پشت Port کوچک ببرید.
- برای Fake یا Adapter مهم Contract Test بسازید.
هفته چهارم: Gate و اندازهگیری
- Lint، Type Check، no-floating-promise و Test review checklist را Gate کنید.
- Trace روی Failure/Retry و SLA Quarantine را فعال کنید.
- همان تغییر نمونه را تکرار و متریک قبل/بعد را مقایسه کنید.
Big-bang rewrite معمولاً Coverage را همزمان مبهم میکند. هر Refactor را کوچک، Behavior-preserving و با Suite سبز انجام دهید؛ سپس یک Case واقعی اضافه کنید. معماری Test باید در خدمت تحویل باشد، نه پروژهای مستقل از Product.
ضدالگوهای کد تست قابل نگهداری
- Pattern shopping: برای رعایت SOLID کلاس و Interface بیمصرف میسازید.
- God BaseTest: State و Hook پنهان به همه Suite نشت میکند.
- DRY افراطی: Test به زنجیره Helperهای مبهم تبدیل میشود.
- Mock everything: تعامل داخلی را بهجای رفتار قفل میکنید.
- Assertion در Page: انتظار سناریو از Spec ناپدید میشود.
- One assert dogma: یک Outcome منسجم به چند E2E تکراری میشکند.
- Random data theater: داده متنوع ولی بدون Seed، Oracle و Replay میسازید.
- Sleep abstraction: Timeout ثابت را پشت نام زیبا پنهان میکنید.
- Retry as pass: Flaky را سبز و بدهی را نامرئی میکنید.
- Utils graveyard: هر کد بیمالک به Helper عمومی میرود.
- Test order contract: یک Test داده Test بعد را میسازد.
- Coverage by count: تعداد Case را بهجای Confidence و هزینه میسنجید.
پرسشهای متداول
آیا Page Object Model هنوز برای Playwright مناسب است؟
بله، وقتی Suite بزرگ است و Locator/عملیات UI واقعاً تکرار میشوند. اما POM فقط یکی از روشهای ساختاردهی است. Component Object، Fixture، API Client، Data Builder و Oracle همچنان مرزهای جدا میخواهند. برای Test کوتاه، Abstraction زودهنگام ممکن است هزینه بیشتری از تکرار داشته باشد.
آیا باید اصول SOLID را کامل در کد تست اجرا کنیم؟
خیر؛ SOLID چکلیست قبولی نیست. از اصل مرتبط برای یک مشکل واقعی تغییر یا وابستگی استفاده کنید. اگر Interface، Inheritance یا Factory قصد Test را پنهان و نگهداری را سختتر میکند، حتی با نام SOLID انتخاب خوبی نیست. سادگی، صراحت و Feedback معتبر معیار نهاییاند.
تفاوت Helper، Fixture و Page Object چیست؟
Page Object خدمات و جزئیات یک صفحه/Component را کپسوله میکند. Fixture زمینه قابل اعتماد مانند Page، کاربر، داده یا Clock را فراهم و Teardown را مدیریت میکند. Helper نام کلی است؛ اگر استفاده میشود باید مسئولیت و مالک روشن داشته باشد. Helper بیمرز معمولاً به Utility متورم تبدیل میشود.
برای خوانایی، تکرار بهتر است یا DRY؟
تکرار دانش یا جزئیات پرتغییر را حذف کنید؛ چند خط Arrange توصیفی را میتوان نگه داشت اگر داستان Test را روشن میکند. وقتی قطعات واقعاً یک معنا دارند و با هم تغییر میکنند، آنها را با نام دامنهای استخراج کنید. شباهت ظاهری بهتنهایی دلیل Abstraction نیست.
چگونه بفهمیم معماری تست بهتر شده است؟
یک تغییر Product نماینده را قبل و بعد اجرا کنید و Change amplification، زمان Triage، Flaky rate، زمان Feedback و تعداد فایلهای درگیر را بسنجید. همچنین Testها را تنها، موازی و با Failure القایی اجرا کنید. کاهش LOC یا افزایش تعداد Pattern بدون بهبود این Outcomes، شاهد کافی نیست.
جمعبندی؛ کد تست وسیله است، نه محصول نهایی
کد تست قابل نگهداری رفتار را روشن بیان میکند، جزئیات پرتغییر را در مرز درست نگه میدارد، داده و وابستگی را صریح میکند و هنگام شکست شاهد قابل اقدام میدهد. POM برای UI مفید است؛ SOLID برای فکرکردن درباره تغییر و وابستگی؛ Fixture برای Context؛ Test Double برای کنترل یک مرز؛ و Metric برای سنجش نتیجه. هیچکدام بهتنهایی معماری نیستند.
از یک God Page یا Flaky flow شروع کنید. یک Component واقعی استخراج کنید، Assertion را به Spec برگردانید، Setup را صریح کنید، Sleep را با Condition جایگزین و Test را در حالت تنها و موازی اجرا کنید. سپس یک تغییر واقعی Product را شبیهسازی کنید. اگر ویرایش محلیتر و Failure روشنتر شد، Refactor ارزش ساخته است.

