تستنویسی در javascript/typescript — بخش ۴: تست نهایی (E2E) با Playwright
در بخشهای قبلی این سری، با تست واحد و تکنیکهای پیشرفتهاش آشنا شدیم. یاد گرفتیم چطور توابع رو ایزوله تست کنیم، از toBe و toEqual درست استفاده کنیم، با همسانیابی نامتقارن تستهای انعطافپذیر بنویسیم و با هوکها تستهامون رو تمیز نگه داریم.
اما یه سوال مهم باقی مونده: وقتی همه این تستهای unit پاس میشن، آیا واقعاً مطمئنیم برنامه از دید کاربر درست کار میکنه؟
جواب معمولاً «نه» است — یا حداقل «نه به اندازه کافی». تست واحد منطق factorial رو تست میکنه، ولی نمیتونه مطمئن بشه که دکمه «ثبت» روی صفحه login واقعاً کاربر رو به داشبورد میبره. تست واحد add رو تست میکنه، ولی نمیتونه CSS شکسته، باگ Safari، یا مشکل routing رو بگیره.
اینجاست که تست نهایی (End-to-End یا E2E) وارد میشه. توی این مقاله یاد میگیریم چطور با Playwright کل برنامه رو داخل مرورگر واقعی تست کنیم — از دید کاربری که کلیک میکنه، فرم پر میکنه و نتیجه رو میبینه.
خب بزن بریم!
طیف تستها: از unit تا E2E
توی بخش ۱ گفتیم سه نوع تست رایج داریم: unit، integration و E2E. ولی این خطوط مرزی همیشه واضح نیستن — و این اصطلاحات گاهی کمی overloaded هستن.
بیایید یه بار دیگه، ولی این بار از زاویه اعتماد و سرعت نگاه کنیم:
تست واحد (Unit Test)
کوچکترین قطعه کد رو به صورت ایزوله تست میکنه. معمولاً یه تابع، یه متد، یه reducer. سریع هستن — میتونید هزاران تا رو توی چند ثانیه اجرا کنید.
تست یکپارچگی (Integration Test)
چند واحد با هم کار میکنن. معمولاً گفته میشود مرز این نوع تست کمی squishy (نامشخص) است — هر تستی که بیش از یک واحد رو درگیر کنه، از نظر فنی integration test حساب میشه. توی بخش ۵ این سری (مقاله بعدی) عمیقتر وارد این لایه میشیم: Testing Library، MSW و تست DOM در Node.
تست نهایی (E2E Test)
کل سیستم رو از ابتدا تا انتها تست میکنه — authentication، API، UI، routing، همه با هم. در دنیای ایدهآل، یه تست E2E موفق یعنی کاربر واقعی میتونه سفر کاملش رو بدون مشکل طی کنه.
هرم تست (Test Pyramid)
همه تستها روی یه طیف قرار دارن:
| لایه | تعداد | سرعت | اطمینان |
|---|---|---|---|
| Unit | زیاد | خیلی سریع | پایینتر (ایزوله) |
| Integration | متوسط | متوسط | متوسط |
| E2E | کم | کند | بالا (کل سیستم) |
به عنوان یه rule of thumb: هرچه بالاتر میرید در هرم، تستها کمتر ولی باارزشتر میشن. یه تست E2E موفق برای flow ورود کاربر، گاهی به اندازه ۶۰ تست unit بهتون اعتماد میده.
نکته مهم اینه که ما تست نمینویسیم برای اینکه «coverage» داشته باشیم. هدف اعتماد است — اعتماد به اینکه وقتی کد رو refactor میکنیم، چیزی مهم نشکنه. هر نوع تستی که سریعتر به این اعتماد برسونه، همون تست درسته.
چرا الان وقت E2E است؟
توی بخش ۲ و ۳ یاد گرفتیم Vitest رو راه بندازیم و تستهای unit حرفهای بنویسیم. حالا وقتشه لایه بعدی رو اضافه کنیم — لایهای که مرورگر واقعی رو درگیر میکنه.
اگر فقط unit test داشته باشید و هیچ E2E نداشته باشید، مثل اینه که هر قطعه ماشین رو جداگانه تست کردید ولی هیچوقت ماشین رو روشن نکردید!
JSDOM در برابر مرورگر واقعی
توی بخش ۱ گفتیم از JSDOM یا HappyDOM برای شبیهسازی DOM در Node استفاده میکنیم. این ابزارها عالی هستن برای تست unit و integration — سریع، سبک، و توی CI خیلی راحت اجرا میشن.
ولی بیایید صادق باشیم: JSDOM مرورگر واقعی نیست.
JSDOM چی هست؟
JSDOM یه پیادهسازی JavaScript از استانداردهای DOM و HTML هست که توی Node.js اجرا میشه. یعنی میتونید document.querySelector، addEventListener و خیلی از APIهای مرورگر رو بدون باز کردن Chrome تست کنید.
HappyDOM چی هست؟
HappyDOM هم همین کار رو میکنه، ولی با تمرکز روی سرعت. برای پروژههای Vitest که میخوان تستها سریع باشن، گزینه محبوبیه.
مقایسه سریع
| ویژگی | JSDOM | Happy DOM | Playwright (مرورگر واقعی) |
|---|---|---|---|
| سرعت | کندتر | سریعتر | کندترین |
| دقت | نسبتاً واقعی | سادهتر | کاملاً واقعی |
| مرورگر | شبیهسازی | شبیهسازی | Chromium, Firefox, WebKit |
| بهترین برای | تست DOM پیچیده | تست سریع unit/integration | تست E2E |
| Layout/CSS | محدود | محدود | کامل |
| رویدادهای واقعی | تقریبی | تقریبی | واقعی |
سه gotcha مهم JSDOM
توی تست E2E سه نکته مهم رو یادآوری میکنه:
هنوز مرورگر واقعی نیست. JSDOM طوری طراحی شده که مثل مرورگر رفتار کنه، نه اینکه باشه مرورگر. subtletyهای Chrome یا Safari رو نداره.
Performance متفاوت است. تست با jsdom از تست pure Node کندتره — هزینه شبیهسازی DOM رو میپردازید.
مشکلات browser-specific رو نمیگیره. اگر چیزی توی Vitest + jsdom کار کنه، تضمینی نیست توی Firefox یا Safari هم کار کنه.
چه زمانی کافی است و چه زمانی E2E لازم است؟
JSDOM/HappyDOM کافی است وقتی:
- یه کامپوننت React رو تست میکنید
- یه تابع DOM manipulation رو unit test میکنید
- سرعت اجرای تست براتون اولویت داره
Playwright E2E لازم است وقتی:
- میخواید کل flow کاربر رو تست کنید (login → dashboard → logout)
- routing و navigation مهمه
- cross-browser compatibility مهمه
- میخواید CSS layout و rendering واقعی رو ببینید
نکته مهم: این دو جایگزین هم نیستن — مکمل هستن. unit test برای منطق، E2E برای سفر کاربر.
معرفی Playwright
Playwright یه فریمورک تست E2E از مایکروسافته که بهتون اجازه میده برنامهتون رو توی مرورگرهای واقعی اتومات کنید:
- Chromium (Chrome و Edge)
- Firefox
- WebKit (Safari)
چرا Playwright؟
- یک API برای همه مرورگرها — نیازی به نوشتن تست جدا برای هر مرورگر نیست
- Auto-waiting — منتظر میمونه المان آماده بشه، بدون
sleepدستی - Headless و Headed — میتونید با UI یا بدون UI اجرا کنید
- Trace و Screenshot — دیباگ تستهای شکستخورده خیلی راحتتره
- webServer — سرور dev رو خودش بالا میآره قبل از تست
- Codegen — میتونه تست بنویسه وقتی شما دستی کلیک میکنید!
تفاوت Playwright با Vitest
این نکته مهمه و گیجکننده هم هست:
- Vitest = test runner برای unit test و integration test (توی Node یا jsdom)
- Playwright = test runner جداگانه برای E2E (توی مرورگر واقعی)
توی بخش ۲ و ۳ همه تستهامون با import { test, expect } from 'vitest' بود. توی E2E مینویسیم:
import { test, expect } from '@playwright/test';
دو runner، دو لایه تست — هر دو توی پروژه واقعی کنار هم هستن.
راهاندازی پروژه
بیایید یه پروژه نمونه بسازیم که هم بازی Tic-Tac-Toe داشته باشه، هم یه flow ساده login. این پروژه پایه همه مثالهای این مقاله است.
ساخت پوشه پروژه
mkdir my-e2e-app
cd my-e2e-app
npm init -y
نصب Playwright
npm install -D @playwright/test
npx playwright install
دستور playwright install مرورگرهای Chromium، Firefox و WebKit رو دانلود میکنه. اولین بار ممکنه چند دقیقه طول بکشه — صبور باشید!
نصب سرور static (برای سرو فایلهای HTML)
npm install -D serve
ساختار پوشه پیشنهادی
my-e2e-app/
├── package.json
├── playwright.config.ts
├── src/
│ ├── index.html # صفحه Tic-Tac-Toe
│ ├── login.html # صفحه ورود
│ ├── dashboard.html # صفحه بعد از login
│ ├── game.js # منطق بازی
│ ├── ui.js # UI بازی
│ └── auth.js # منطق login ساده
└── e2e/
├── tic-tac-toe.spec.ts
├── auth.spec.ts
└── pages/
└── login.page.ts # Page Object Model
تنظیم package.json
{
"name": "my-e2e-app",
"version": "1.0.0",
"scripts": {
"dev": "serve src -p 3000",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui"
},
"devDependencies": {
"@playwright/test": "^1.40.0",
"serve": "^14.0.0"
}
}
پیکربندی Playwright
فایل playwright.config.ts قلب تنظیمات Playwright است. بیایید یه config کامل بنویسیم و هر بخشش رو توضیح بدیم:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// پوشه تستهای E2E
testDir: './e2e',
// تایماوت هر تست (میلیثانیه)
timeout: 30 * 1000,
// تعداد تلاش مجدد در صورت شکست (مخصوص CI)
retries: process.env.CI ? 2 : 0,
// تعداد worker موازی
workers: process.env.CI ? 1 : undefined,
// Reporter — خروجی تست
reporter: [['html'], ['list']],
// تنظیمات مشترک همه تستها
use: {
// آدرس پایه — در تستها فقط /index.html مینویسیم
baseURL: 'http://localhost:3000',
// اجرای headless (بدون باز شدن پنجره مرورگر)
headless: true,
// اسکرینشات فقط وقتی تست fail میشه
screenshot: 'only-on-failure',
// trace برای دیباگ
trace: 'on-first-retry',
},
// سرور dev — قبل از تستها خودکار بالا میآد
webServer: {
command: 'npm run dev',
port: 3000,
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
// پروژههای مختلف = مرورگرهای مختلف
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
});
توضیح بخشهای مهم
testDir
مشخص میکنه Playwright کدوم پوشه رو برای فایلهای .spec.ts بگرده. معمولاً ./e2e یا ./tests/e2e.
baseURL
وقتی baseURL تنظیم شده، به جای page.goto('http://localhost:3000/index.html') مینویسید page.goto('/index.html'). تمیزتر و قابل نگهداریتر.
webServer
این یکی از قدرتمندترین featureهای Playwright است! قبل از اجرای تستها:
- دستور
npm run devرو اجرا میکنه - منتظر میمونه تا پورت ۳۰۰۰ آماده بشه
- تستها رو اجرا میکنه
- بعد از اتمام، سرور رو میبنده
reuseExistingServer: !process.env.CI یعنی در توسعه محلی، اگر سرور از قبل بالا باشه دوباره start نمیکنه — سریعتر!
projects
هر project = یه ترکیب مرورگر + تنظیمات. با این config، هر تست سه بار اجرا میشه: یکی در Chromium، یکی Firefox، یکی WebKit.
retries و trace
در CI، گاهی تستها به خاطر timing flaky میشن. retries: 2 یعنی دو بار دیگه تلاش میکنه. trace: 'on-first-retry' یعنی در retry اول، trace کامل ضبط میشه برای دیباگ.
نکته مهم: در توسعه محلی
retries: 0بذارید تا سریعتر fail ببینید و مشکل رو پیدا کنید. retry فقط برای CI مفیده.
اولین تست E2E
حالا که config آماده است، بیایید اولین تست رو بنویسیم. قبل از تست کامل، یه HTML ساده لازم داریم:
<!-- src/index.html — نسخه ساده برای اولین تست -->
<!doctype html>
<html lang="fa">
<head>
<meta charset="UTF-8" />
<title>Tic Tac Toe</title>
</head>
<body>
<h1>Tic Tac Toe</h1>
<button type="submit">Submit</button>
</body>
</html>
نوشتن تست
// e2e/first-test.spec.ts
import { test, expect } from '@playwright/test';
test.describe('اولین تست E2E', () => {
test('دکمه Submit باید نمایش داده بشه', async ({ page }) => {
// رفتن به صفحه
await page.goto('/index.html');
// پیدا کردن دکمه
const button = page.locator('button');
// assertion
await expect(button).toHaveText('Submit');
});
});
اجرای تست
npx playwright test
یا:
npm run test:e2e
آناتومی یک تست Playwright
بیایید هر قسمت رو بررسی کنیم:
test و test.describe
مشابه Vitest! test.describe تستها رو گروهبندی میکنه. test یه تست واحد E2E است.
`
این fixture Playwright است. page یه تب مرورگر آماده است که Playwright قبل از تست براتون باز میکنه. Vitest test context از Playwright fixtures الهام گرفته — Playwright اول این الگو رو popular کرد.
page.goto()
مرورگر رو به URL مشخص میبره. با baseURL فقط مسیر نسبی میدیم.
page.locator()
یه locator برمیگردونه — اشارهای به المان(های) DOM. locatorها lazy هستن: تا وقتی action یا assertion نزنید، DOM رو query نمیکنن.
expect(locator).toHaveText()
assertion مخصوص Playwright. خودکار منتظر میمونه تا المان ظاهر بشه و متنش درست بشه — نیازی به waitFor دستی نیست!
Auto-waiting — چرا E2E با unit فرق داره
یکی از بزرگترین مزایای Playwright auto-waiting است. وقتی مینویسید:
await page.locator('button').click();
Playwright قبل از click این کارها رو میکنه:
- منتظر میمونه المان توی DOM باشه
- منتظر میمونه visible باشه
- منتظر میمونه stable باشه (animation تمام شده)
- منتظر میمونه enabled باشه
- بعد click میکنه
توی unit test با jsdom، همه چیز فوری اتفاق میافته. توی E2E، شبکه، rendering و animation زمان میبرن — Playwright این رو handle میکنه.
به عنوان یه rule of thumb: هرگز
page.waitForTimeout(3000)ننویسید مگر اینکه واقعاً راه دیگهای نباشه. auto-waiting Playwright تقریباً همیشه کافیه.
Locatorها — پیدا کردن المانها مثل کاربر
انتخاب درست locator تفاوت بین تست پایدار و تست شکننده است. Playwright چند روش برای پیدا کردن المانها داره:
getByRole — اولویت اول
// پیدا کردن دکمه با نقش و نام accessible
await page.getByRole('button', { name: 'Reset Game' }).click();
// پیدا کردن heading
await expect(page.getByRole('heading', { name: 'Tic Tac Toe' })).toBeVisible();
// پیدا کردن textbox (input)
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
چرا getByRole بهترینه؟
- مثل کاربر واقعی عمل میکنه (کاربر role میبینه، نه class CSS)
- accessibility رو همزمان تست میکنه
- با تغییر class یا structure نشکنه
getByLabel
await page.getByLabel('Email').fill('john@example.com');
await page.getByLabel('Password').fill('secret123');
برای inputهایی که <label> دارن عالیه.
getByText
await expect(page.getByText('Player X wins!')).toBeVisible();
await expect(page.getByText("It's a draw!")).toBeVisible();
getByTestId
await page.getByTestId('submit-button').click();
نیاز به data-testid در HTML داره. وقتی role/label/text کافی نیست استفاده کنید.
locator با CSS/XPath
await page.locator('.cell').first().click();
await page.locator('#message').textContent();
await page.locator('button.submit-btn').click();
آخرین گزینه — وقتی روشهای user-facing جواب نمیدن.
مقایسه روشها
| روش | پایداری | accessibility | پیشنهاد |
|---|---|---|---|
getByRole |
عالی | بله | همیشه اول این |
getByLabel |
عالی | بله | برای فرمها |
getByText |
خوب | نسبی | برای پیامها |
getByTestId |
عالی | خیر | fallback |
| CSS selector | ضعیف | خیر | آخرین راه |
مثالهای بد و خوب
// ❌ بد — وابسته به class که ممکنه عوض بشه
await page.locator('.btn-primary-v2-new').click();
// ❌ بد — وابسته به ساختار DOM
await page.locator('div > div > div > button').click();
// ✅ خوب — role-based
await page.getByRole('button', { name: 'ثبت' }).click();
// ✅ خوب — label-based
await page.getByLabel('ایمیل').fill('test@example.com');
فیلتر و زنجیرهسازی locator
// همه دکمههای داخل board
const cells = page.locator('.board .cell');
await expect(cells).toHaveCount(9);
// اولین سلول
await cells.first().click();
// سلول سوم
await cells.nth(2).click();
// فیلتر بر اساس متن
await page.getByRole('button').filter({ hasText: 'Reset' }).click();
Assertionهای Playwright
Playwright assertionهای مخصوص خودش رو داره — متفاوت از toBe و toEqual Vitest که توی بخش ۳ یاد گرفتیم.
Assertionهای visibility
// المان باید visible باشه
await expect(page.getByRole('heading')).toBeVisible();
// المان باید hidden باشه
await expect(page.getByText('خطا')).toBeHidden();
// المان باید توی DOM باشه (حتی اگر hidden)
await expect(page.locator('#modal')).toBeAttached();
Assertionهای متن
// متن دقیق
await expect(page.locator('#message')).toHaveText('Player X wins!');
// شامل زیررشته
await expect(page.locator('#message')).toContainText('wins');
// regex
await expect(page.locator('#message')).toHaveText(/Player \w wins!/);
Assertionهای input
await expect(page.getByLabel('Email')).toHaveValue('john@example.com');
await expect(page.getByLabel('Email')).toBeEmpty();
await expect(page.getByRole('checkbox')).toBeChecked();
Assertionهای وضعیت
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await expect(page.getByRole('button', { name: 'Submit' })).toBeDisabled();
Assertionهای URL و صفحه
await expect(page).toHaveURL('/dashboard.html');
await expect(page).toHaveURL(/dashboard/);
await expect(page).toHaveTitle('Dashboard');
Assertionهای تعداد
await expect(page.locator('.cell')).toHaveCount(9);
await expect(page.getByRole('listitem')).toHaveCount(3);
Soft Assertions
گاهی میخواید چند assertion بزنید و همه رو ببینید — نه اینکه در اولین fail متوقف بشید:
test('بررسی صفحه dashboard', async ({ page }) => {
await page.goto('/dashboard.html');
await expect.soft(page.getByRole('heading')).toBeVisible();
await expect.soft(page.getByText('خوش آمدید')).toBeVisible();
await expect.soft(page.getByRole('button', { name: 'Logout' })).toBeEnabled();
// همه soft assertionها چک میشن، بعد تست fail میشه اگر هر کدوم fail بوده
});
تفاوت با Vitest
| Vitest (بخش ۳) | Playwright (بخش ۴) |
|---|---|
toBe(5) |
toHaveText('5') |
toEqual({ a: 1 }) |
toHaveValue('1') |
| sync یا async ساده | همیشه async + auto-wait |
| تست داده/تابع | تست UI/مرورگر |
Fixtures و Test Context
توی بخش ۳ با هوکهای Vitest آشنا شدیم. Playwright یه مفهوم مشابه ولی قدرتمندتر داره: fixtures.
fixtureهای built-in
test('مثال fixtureها', async ({ page, browser, context }) => {
// page = یک تب مرورگر
// browser = instance مرورگر
// context = context مرورگر (شامل cookies, storage)
});
| Fixture | توضیح |
|---|---|
page |
یه تب مرورگر آماده — پرکاربردترین |
browser |
instance مرورگر — برای چند tab |
context |
context ایزوله — cookies و storage جدا |
browserName |
نام مرورگر فعلی (chromium, firefox, webkit) |
Vitest test context از Playwright fixtures الهام گرفته. Playwright اول این الگوی «تزریق وابستگی به تست» رو popular کرد.
test.beforeEach در Playwright
import { test, expect } from '@playwright/test';
test.describe('Dashboard', () => {
test.beforeEach(async ({ page }) => {
// قبل از هر تست، login کن و برو dashboard
await page.goto('/login.html');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page).toHaveURL('/dashboard.html');
});
test('نمایش نام کاربر', async ({ page }) => {
await expect(page.getByText('خوش آمدید')).toBeVisible();
});
test('دکمه logout موجود است', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Logout' })).toBeEnabled();
});
});
Custom Fixture
میتونید fixture سفارشی بسازید — مثلاً یه کاربر loginشده:
// e2e/fixtures.ts
import { test as base } from '@playwright/test';
type MyFixtures = {
loggedInPage: Page;
};
export const test = base.extend<MyFixtures>({
loggedInPage: async ({ page }, use) => {
await page.goto('/login.html');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Login' }).click();
await use(page);
},
});
export { expect } from '@playwright/test';
استفاده:
import { test, expect } from './fixtures';
test('dashboard با کاربر login شده', async ({ loggedInPage }) => {
await expect(loggedInPage.getByText('خوش آمدید')).toBeVisible();
});
مثال عملی اصلی: Tic-Tac-Toe با Playwright
حالا وقتشه یه مثال کامل بزنیم. از مثال Tic-Tac-Toe در دوره استفاده میکنیم — ولی به جای Testing Library (که توی بخش ۵ آمد)، کل بازی رو توی مرورگر واقعی با Playwright تست میکنیم.
ساخت اپلیکیشن
منطق بازی — src/game.js
// src/game.js
export function createGame() {
return {
board: [
['', '', ''],
['', '', ''],
['', '', ''],
],
currentPlayer: 'X',
placeMove(row, col) {
if (this.board[row][col] !== '') {
throw new Error('Spot already taken');
}
this.board[row][col] = this.currentPlayer;
this.currentPlayer = this.currentPlayer === 'X' ? 'O' : 'X';
},
checkWinner() {
const b = this.board;
const lines = [
[b[0][0], b[0][1], b[0][2]],
[b[1][0], b[1][1], b[1][2]],
[b[2][0], b[2][1], b[2][2]],
[b[0][0], b[1][0], b[2][0]],
[b[0][1], b[1][1], b[2][1]],
[b[0][2], b[1][2], b[2][2]],
[b[0][0], b[1][1], b[2][2]],
[b[0][2], b[1][1], b[2][0]],
];
for (const line of lines) {
if (line[0] && line[0] === line[1] && line[1] === line[2]) {
return line[0];
}
}
return null;
},
isDraw() {
if (this.checkWinner()) return false;
for (const row of this.board) {
if (row.includes('')) return false;
}
return true;
},
};
}
رابط کاربری — src/ui.js
// src/ui.js
import { createGame } from './game.js';
const game = createGame();
const boardElement = document.getElementById('board');
const messageElement = document.getElementById('message');
const resetButton = document.getElementById('reset');
for (let row = 0; row < 3; row++) {
for (let col = 0; col < 3; col++) {
const cell = document.createElement('button');
cell.classList.add('cell');
cell.dataset.row = row;
cell.dataset.col = col;
cell.setAttribute('aria-label', `Cell ${row},${col}`);
boardElement.appendChild(cell);
}
}
function updateBoard() {
document.querySelectorAll('.cell').forEach((cell) => {
const row = cell.dataset.row;
const col = cell.dataset.col;
cell.textContent = game.board[row][col];
});
}
function handleClick(event) {
const cell = event.target;
const row = cell.dataset.row;
const col = cell.dataset.col;
try {
game.placeMove(Number(row), Number(col));
updateBoard();
const winner = game.checkWinner();
if (winner) {
messageElement.textContent = `Player ${winner} wins!`;
disableBoard();
} else if (game.isDraw()) {
messageElement.textContent = "It's a draw!";
disableBoard();
}
} catch {
// خانه پر است — نادیده بگیر
}
}
function disableBoard() {
document.querySelectorAll('.cell').forEach((cell) => {
cell.disabled = true;
cell.classList.add('disabled');
});
}
function resetGame() {
game.board = [['', '', ''], ['', '', ''], ['', '', '']];
game.currentPlayer = 'X';
messageElement.textContent = '';
document.querySelectorAll('.cell').forEach((cell) => {
cell.disabled = false;
cell.classList.remove('disabled');
cell.textContent = '';
});
}
document.querySelectorAll('.cell').forEach((cell) => {
cell.addEventListener('click', handleClick);
});
resetButton.addEventListener('click', resetGame);
HTML — src/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Tic Tac Toe</title>
<style>
.board {
display: grid;
grid-template-columns: repeat(3, 100px);
gap: 5px;
}
.cell {
width: 100px;
height: 100px;
font-size: 2em;
text-align: center;
line-height: 100px;
border: 1px solid #000;
cursor: pointer;
}
.disabled {
pointer-events: none;
background-color: #f0f0f0;
}
#message {
margin-top: 20px;
font-size: 1.2em;
}
</style>
</head>
<body>
<h1>Tic Tac Toe</h1>
<div class="board" id="board"></div>
<div id="message" role="status"></div>
<button id="reset" type="button">Reset Game</button>
<script type="module" src="ui.js"></script>
</body>
</html>
نوشتن تستهای E2E
تست رندر صفحه
// e2e/tic-tac-toe.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Tic Tac Toe — E2E', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/index.html');
});
test('صفحه بازی باید عنوان و ۹ خانه داشته باشه', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Tic Tac Toe' })).toBeVisible();
await expect(page.locator('.cell')).toHaveCount(9);
await expect(page.getByRole('button', { name: 'Reset Game' })).toBeVisible();
});
});
تست نوبتدهی بازیکنان
test('بازیکنان باید نوبتی X و O بذارن', async ({ page }) => {
const cells = page.locator('.cell');
await cells.nth(0).click();
await expect(cells.nth(0)).toHaveText('X');
await cells.nth(1).click();
await expect(cells.nth(1)).toHaveText('O');
await cells.nth(2).click();
await expect(cells.nth(2)).toHaveText('X');
});
تست برد بازیکن
test('باید پیام برد Player X رو نشون بده', async ({ page }) => {
const cells = page.locator('.cell');
// X: 0, 1, 2 | O: 3, 4
await cells.nth(0).click(); // X
await cells.nth(3).click(); // O
await cells.nth(1).click(); // X
await cells.nth(4).click(); // O
await cells.nth(2).click(); // X wins — ردیف اول
await expect(page.locator('#message')).toHaveText('Player X wins!');
// بعد از برد، خانهها باید disabled باشن
await expect(cells.nth(5)).toBeDisabled();
});
تست مساوی (Draw)
test('باید پیام draw رو نشون بده وقتی صفحه پر میشه', async ({ page }) => {
const cells = page.locator('.cell');
// ترتیب حرکتها برای رسیدن به draw
const moves = [0, 1, 2, 4, 3, 5, 7, 6, 8];
for (const index of moves) {
await cells.nth(index).click();
}
await expect(page.locator('#message')).toHaveText("It's a draw!");
});
تست Reset
test('دکمه Reset باید بازی رو از اول شروع کنه', async ({ page }) => {
const cells = page.locator('.cell');
await cells.nth(0).click();
await cells.nth(1).click();
await expect(cells.nth(0)).toHaveText('X');
await page.getByRole('button', { name: 'Reset Game' }).click();
await expect(cells.nth(0)).toHaveText('');
await expect(cells.nth(1)).toHaveText('');
await expect(page.locator('#message')).toHaveText('');
await expect(cells.nth(0)).toBeEnabled();
});
تست کلیک روی خانه پر
test('کلیک روی خانه پر نباید بازی رو خراب کنه', async ({ page }) => {
const cells = page.locator('.cell');
await cells.nth(0).click();
await expect(cells.nth(0)).toHaveText('X');
// کلیک دوباره روی همون خانه
await cells.nth(0).click();
await expect(cells.nth(0)).toHaveText('X'); // هنوز X
await expect(cells.nth(1)).toHaveText(''); // خانه بعدی خالی مونده
});
اجرای تستهای Tic-Tac-Toe
npx playwright test e2e/tic-tac-toe.spec.ts
برای دیدن مرورگر:
npx playwright test e2e/tic-tac-toe.spec.ts --headed
test.describe.configure — تستهای سریالی
گاهی تستها به ترتیب خاصی وابستهان. Playwright به صورت پیشفرض تستها رو موازی اجرا میکنه. برای وابستگی:
test.describe.configure({ mode: 'serial' });
test.describe('سناریوی چندمرحلهای', () => {
test('مرحله ۱: شروع بازی', async ({ page }) => { /* ... */ });
test('مرحله ۲: ادامه بازی', async ({ page }) => { /* ... */ });
});
نکته مهم: سعی کنید تستهای E2E مستقل باشن. serial mode فقط وقتی واقعاً لازمه استفاده کنید — موازی بودن سرعت suite رو خیلی بالا میبره.
مثال دوم: جریان ورود کاربر (Login Flow)
حالا یه مثال واقعیتر — flow ورود کاربر. از ایدههای flow ورود کاربر استفاده میکنیم، ولی فقط لایه مرورگر — بدون Express و supertest (اونها میرن بخش ۵).
اپلیکیشن Login ساده
src/login.html
<!doctype html>
<html lang="fa">
<head>
<meta charset="UTF-8" />
<title>Login</title>
<style>
body { font-family: sans-serif; max-width: 400px; margin: 50px auto; }
.error { color: red; margin-top: 10px; }
label { display: block; margin-top: 15px; }
input { width: 100%; padding: 8px; margin-top: 5px; }
button { margin-top: 20px; padding: 10px 20px; }
</style>
</head>
<body>
<h1>ورود</h1>
<form id="login-form">
<label for="email">Email</label>
<input type="email" id="email" name="email" required />
<label for="password">Password</label>
<input type="password" id="password" name="password" required />
<button type="submit">Login</button>
</form>
<p id="error-message" class="error" role="alert" hidden></p>
<script src="auth.js"></script>
</body>
</html>
src/dashboard.html
<!doctype html>
<html lang="fa">
<head>
<meta charset="UTF-8" />
<title>Dashboard</title>
</head>
<body>
<h1>Dashboard</h1>
<p id="welcome-message">خوش آمدید!</p>
<button id="logout" type="button">Logout</button>
<script>
if (!localStorage.getItem('authToken')) {
window.location.href = '/login.html';
}
document.getElementById('logout').addEventListener('click', () => {
localStorage.removeItem('authToken');
window.location.href = '/login.html';
});
</script>
</body>
</html>
src/auth.js
// auth.js — منطق login سمت کلاینت
const VALID_USERS = {
'admin@example.com': 'password123',
'user@example.com': 'userpass456',
};
document.getElementById('login-form').addEventListener('submit', (e) => {
e.preventDefault();
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
const errorEl = document.getElementById('error-message');
errorEl.hidden = true;
if (!email) {
errorEl.textContent = 'Email is required';
errorEl.hidden = false;
return;
}
if (!email.includes('@')) {
errorEl.textContent = 'Invalid email format';
errorEl.hidden = false;
return;
}
if (!password) {
errorEl.textContent = 'Password is required';
errorEl.hidden = false;
return;
}
if (VALID_USERS[email] !== password) {
errorEl.textContent = 'Invalid email or password';
errorEl.hidden = false;
return;
}
localStorage.setItem('authToken', 'fake-jwt-token-' + email);
window.location.href = '/dashboard.html';
});
تستهای E2E برای Login
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('جریان ورود کاربر', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login.html');
});
test('صفحه login باید فرم داشته باشه', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'ورود' })).toBeVisible();
await expect(page.getByLabel('Email')).toBeVisible();
await expect(page.getByLabel('Password')).toBeVisible();
await expect(page.getByRole('button', { name: 'Login' })).toBeVisible();
});
test('login موفق باید به dashboard هدایت کنه', async ({ page }) => {
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page).toHaveURL('/dashboard.html');
await expect(page.getByText('خوش آمدید!')).toBeVisible();
});
test('login ناموفق باید پیام خطا نشون بده', async ({ page }) => {
await page.getByLabel('Email').fill('wrong@example.com');
await page.getByLabel('Password').fill('wrongpassword');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page.getByRole('alert')).toHaveText('Invalid email or password');
await expect(page).toHaveURL('/login.html');
});
});
تست Unhappy Path — ورودیهای نامعتبر
در تست unhappy path: کاربران همیشه ورودی درست نمیدن!
test.describe('Unhappy Path — Login', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login.html');
});
test('ایمیل خالی باید خطا بده', async ({ page }) => {
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page.getByRole('alert')).toHaveText('Email is required');
});
test('فرمت ایمیل نامعتبر باید خطا بده', async ({ page }) => {
await page.getByLabel('Email').fill('not-an-email');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page.getByRole('alert')).toHaveText('Invalid email format');
});
test('رمز خالی باید خطا بده', async ({ page }) => {
await page.getByLabel('Email').fill('admin@example.com');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page.getByRole('alert')).toHaveText('Password is required');
});
});
تست Protected Page
test('dashboard بدون login باید redirect به login بشه', async ({ page }) => {
await page.goto('/dashboard.html');
await expect(page).toHaveURL('/login.html');
});
test('logout باید به login برگردونه', async ({ page }) => {
// اول login
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page).toHaveURL('/dashboard.html');
// logout
await page.getByRole('button', { name: 'Logout' }).click();
await expect(page).toHaveURL('/login.html');
// دوباره dashboard — باید redirect بشه
await page.goto('/dashboard.html');
await expect(page).toHaveURL('/login.html');
});
Page Object Model (POM)
وقتی تستها زیاد میشن، selectorها و actionها تکراری میشن. Page Object Model این تکرار رو حذف میکنه:
// e2e/pages/login.page.ts
import { type Page, type Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.loginButton = page.getByRole('button', { name: 'Login' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login.html');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
}
استفاده در تست:
import { LoginPage } from './pages/login.page';
test('login با Page Object', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('admin@example.com', 'password123');
await expect(page).toHaveURL('/dashboard.html');
});
نکته مهم: POM برای suiteهای بزرگ عالیه. برای ۳-۴ تست ساده، مستقیم locator بنویسید — over-engineering نکنید!
تست چندمرورگری (Cross-Browser Testing)
یکی از قدرتمندترین featureهای Playwright اینه که همون تست رو توی چند مرورگر اجرا میکنه.
چرا Cross-Browser مهم است؟
فرض کنید برنامهتون فقط روی Chrome تست شده. کاربری با Safari میاد — و دکمه submit کار نمیکنه! یا CSS flexbox متفاوت render میشه. یا localStorage رفتار متفاوتی داره.
به عنوان یه rule of thumb: اگر کاربران واقعی از مرورگرهای مختلف استفاده میکنن (که همیشه اینطوره!)، حداقل smoke testهای E2E رو cross-browser اجرا کنید.
اجرا با Projects (در config)
توی playwright.config.ts قبلاً projects تعریف کردیم:
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
هر تست سه بار اجرا میشه — یکی در هر مرورگر.
اجرای فقط یک مرورگر
# فقط Chromium
npx playwright test --project=chromium
# فقط Firefox
npx playwright test --project=firefox
# فقط WebKit (Safari)
npx playwright test --project=webkit
override مرورگر در فایل تست
import { test, expect } from '@playwright/test';
test.use({ browserName: 'firefox' });
test('این تست فقط در Firefox اجرا میشه', async ({ page }) => {
await page.goto('/index.html');
await expect(page.getByRole('heading')).toBeVisible();
});
تست Mobile Viewport
import { test, devices } from '@playwright/test';
test.use({ ...devices['iPhone 13'] });
test('بازی در موبایل باید درست نمایش داده بشه', async ({ page }) => {
await page.goto('/index.html');
await expect(page.locator('.cell')).toHaveCount(9);
});
نکات Cross-Browser
- WebKit = موتور Safari — برای کاربران iOS/Mac مهم است
- Firefox = موتور Gecko — رفتارهای متفاوت در CSS و JS
- تفاوتهای رایج:
flexbox،grid،date input،fetchbehavior - همه تستها رو cross-browser نکنید — فقط smoke tests و critical paths
اجرا، دیباگ و رفع خطا
حالتهای اجرا
# اجرای عادی (headless)
npm run test:e2e
# با نمایش مرورگر
npx playwright test --headed
# UI Mode — بهترین ابزار دیباگ!
npx playwright test --ui
# فقط یک فایل
npx playwright test e2e/tic-tac-toe.spec.ts
# فقط یک تست (با نام)
npx playwright test -g "باید پیام برد"
# debug mode — قدم به قدم
npx playwright test --debug
UI Mode
npx playwright test --ui یه رابط گرافیکی باز میکنه که میتونید:
- تستها رو یکی یکی اجرا کنید
- DOM رو ببینید
- locatorها رو امتحان کنید
- trace رو مرور کنید
برای یادگیری Playwright، UI Mode بهترین نقطه شروع است!
Trace Viewer
وقتی تست fail میشه، trace ضبط شده (اگر trace: 'on-first-retry' فعال باشه):
npx playwright show-trace test-results/.../trace.zip
Trace شامل: screenshot هر step، network requests، console logs، DOM snapshot.
Codegen — ضبط تست
Playwright میتونه وقتی شما دستی کلیک میکنید، تست بنویسه:
npx playwright codegen http://localhost:3000/index.html
یه مرورگر باز میشه + inspector. هر کاری بکنید، کد TypeScript تولید میشه. برای شروع سریع عالیه — ولی کد تولیدشده رو refactor کنید (selectorهای بهتر، حذف تکرار).
Screenshot در صورت شکست
با screenshot: 'only-on-failure' در config، وقتی تست fail میشه screenshot ذخیره میشه توی test-results/.
خطاهای رایج و راهحل
۱. فراموش کردن await
// ❌ بد — Promise اجرا نمیشه، تست flaky میشه
page.goto('/index.html');
page.locator('button').click();
// ✅ خوب
await page.goto('/index.html');
await page.locator('button').click();
۲. Selector شکننده
// ❌ بد
await page.locator('div.container > div.row > button.btn-primary').click();
// ✅ خوب
await page.getByRole('button', { name: 'Login' }).click();
۳. استفاده از waitForTimeout
// ❌ بد — brittle و کند
await page.waitForTimeout(3000);
await expect(page.locator('#message')).toHaveText('Done');
// ✅ خوب — Playwright خودش منتظر میمونه
await expect(page.locator('#message')).toHaveText('Done');
۴. سرور بالا نیامده
Error: page.goto: net::ERR_CONNECTION_REFUSED
راهحل: مطمئن شید webServer در config درست تنظیم شده، یا دستی npm run dev رو اجرا کنید.
۵. پورت اشتباه
baseURL و webServer.port باید یکی باشن. اگر dev server روی 5173 است (Vite)، config رو آپدیت کنید.
۶. تستهای وابسته به هم
// ❌ بد — تست B به state تست A وابسته است
test('A: ایجاد کاربر', async ({ page }) => { /* ... */ });
test('B: حذف همون کاربر', async ({ page }) => { /* به A وابسته */ });
// ✅ خوب — هر تست مستقل setup داره
test.beforeEach(async ({ page }) => {
await setupTestUser(page);
});
۷. Flaky tests
تستی که گاهی pass گاهی fail میشه = flaky. دلایل رایج:
waitForTimeoutبه جای auto-wait- وابستگی به داده خارجی (API واقعی)
- race condition بین تستهای موازی
- animation یا lazy loading
راهحل: از auto-wait استفاده کنید، داده تست رو mock کنید، تستها رو ایزوله نگه دارید.
پایداری و بهترین روشها
چه چیزی را E2E تست کنیم؟
تست کنید:
- مسیرهای بحرانی کاربر (login، checkout، ثبتنام)
- navigation بین صفحات
- فرمهای مهم و submit
- نمایش دادههای حیاتی
تست نکنید:
- منطق محاسباتی (factorial، validation جزئی) — کار unit test است
- هر edge case — کار unit test است
- styling جزئی — visual regression جداگانه است
- API response structure — کار integration test است
نکته مهم: «ما تست نمینویسیم برای تست. ما تست مینویسیم برای اعتماد.»
اصول نگهداری Suite E2E
۱. کم نگه دارید
هر تست E2E کند است (ثانیهها). ۱۰۰ تست E2E = چند دقیقه اجرا. هدف: ۱۰-۲۰ تست smoke برای critical paths.
۲. مستقل باشند
هر تست باید بدون وابستگی به تست دیگه اجرا بشه. beforeEach برای setup، نه تکیه به state تست قبلی.
۳. داده تست کنترلشده
از API واقعی یا database production استفاده نکنید. seed data مخصوص تست یا mock server.
۴. Selector پایدار
getByRole > getByLabel > getByTestId > CSS selector
۵. نامگذاری واضح
// ✅ خوب — مشخصه چی تست میشه
test('login با ایمیل نامعتبر باید پیام خطا نشون بده', ...);
// ❌ بد
test('test1', ...);
Flakiness — دشمن E2E
Flaky test بدتر از نداشتن تست است — اعتماد تیم به suite رو از بین میبره.
راههای کاهش flakiness:
- auto-waiting Playwright (پیشفرض)
retriesفقط در CI- ایزوله کردن تستها
- حذف
waitForTimeout - استفاده از
webServerبه جای فرض سرور بالا - trace برای دیباگ تستهای flaky
Testing Trophy vs Pyramid
هرم تست (pyramid) یه مدل رایجه. بعضیها Testing Trophy رو ترجیح میدن — integration test بیشتر، E2E کمتر. مهم نیست کدوم مدل — مهم اینه که تعادل داشته باشید:
/\
/E2E\ ← کم، critical paths
/------\
/Integr.\ ← متوسط (بخش ۵)
/----------\
/ Unit \ ← زیاد، سریع (بخش ۲-۳)
/--------------\
E2E در CI (Continuous Integration)
برای CI نحوه راهاندازی GitHub Actions برای Vitest رو توضیح میده. برای Playwright E2E هم الگوی مشابهی داریم:
GitHub Actions Workflow
# .github/workflows/e2e.yml
name: E2E Tests
on:
pull_request:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run E2E tests
run: npx playwright test --project=chromium
env:
CI: true
- name: Upload test report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
نکات CI
CI=true: Playwright میفهمه در CI است —retriesفعال میشه،reuseExistingServer: false--with-deps: dependencyهای سیستمی مرورگر (libgtk و...) رو نصب میکنه- فقط chromium در PR: سریعتر — cross-browser کامل شاید فقط nightly
- آپلود report: وقتی fail میشه، HTML report رو artifact کنید
- parallel sharding: برای suite بزرگ:
npx playwright test --shard=1/3
اجرای Smoke Tests در هر PR
# فقط تستهای مهم — سریع
npx playwright test e2e/auth.spec.ts --project=chromium
همه ۵۰ تست E2E رو در هر PR اجرا نکنید — CI کند میشه و developer experience بد میشه.
مقایسه Playwright با Cypress
هر دو ابزار محبوب E2E هستن. یه مقایسه سریع:
| ویژگی | Playwright | Cypress |
|---|---|---|
| مرورگرها | Chromium, Firefox, WebKit | عمدتاً Chromium |
| معماری | out-of-process (CDP) | in-browser |
| چند tab/window | بله | محدود |
| parallel | built-in sharding | نیاز به cloud |
| auto-wait | بله | بله |
| زبان | JS/TS/Python/Java/C# | JS/TS |
در این سری از Playwright برای E2E استفاده میکنیم.
خلاصه آنچه یاد گرفتیم
توی این مقاله وارد دنیای تست نهایی (E2E) شدیم:
- هرم تست — unit زیاد و سریع، E2E کم ولی باارزش
- JSDOM vs مرورگر واقعی — چرا E2E لازم است
- راهاندازی Playwright — نصب، config، webServer
- Locatorها —
getByRoleاولویت اول - Assertionهای Playwright — auto-waiting و async
- Fixtures —
page, custom fixtures - Tic-Tac-Toe — مثال کامل E2E
- Login Flow — unhappy path، POM، protected pages
- Cross-browser — projects، mobile viewport
- دیباگ — UI mode، trace، codegen
- بهترین روشها — چه تست کنیم، flakiness
- CI — GitHub Actions
چکلیست قبل از merge
قبل از اینکه تست E2E جدید رو به suite اضافه کنید، این سوالات رو بپرسید:
- آیا این سناریو critical path است؟
- آیا unit test یا integration test کافی نیست؟
- آیا تست مستقل از تستهای دیگه است؟
- آیا از
getByRole/getByLabelاستفاده شده؟ - آیا
waitForTimeoutنداریم؟ - آیا در CI هم pass میشه؟
تیزر بخش ۵: تست یکپارچگی (Integration Test)
توی مقاله بعدی این سری، وارد لایه میانی میشیم — جایی بین unit test و E2E:
- Testing Library — تست کامپوننت از دید کاربر (در Node با jsdom)
- user-event — شبیهسازی واقعیتر تعامل کاربر
- MSW (Mock Service Worker) — mock کردن API در تست
- تست DOM — JSDOM و HappyDOM
- مثال Task List و Authentication با React و Vitest
این لایه سریعتر از E2E است ولی بیشتر از unit test به ما اعتماد میده — مخصوصاً وقتی چند کامپوننت با هم کار میکنن.
تستنویسی یک سفر یادگیری مستمره. با unit test پایه رو ساختید (بخش ۲-۳)، حالا با E2E لایه اعتماد نهایی رو اضافه کردید. هرچقدر بیشتر تمرین کنید — هم در Vitest و هم در Playwright — بهتر میشید.
موفق باشید و به تستنویسی ادامه بدید!
خب دوستان، امیدوارم این مقاله براتون مفید بوده باشه. Playwright ابزار قدرتمندیه — شروع کنید با یه تست ساده، بعد کمکم suiteتون رو بزرگ کنید. توی مقاله بعدی سراغ تستهای یکپارچگی میریم. تا بعد!