تست‌نویسی در javascript/typescript — بخش ۴: تست نهایی (E2E) با Playwright

تست‌نویسی در 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 سه نکته مهم رو یادآوری می‌کنه:

  1. هنوز مرورگر واقعی نیست. JSDOM طوری طراحی شده که مثل مرورگر رفتار کنه، نه اینکه باشه مرورگر. subtletyهای Chrome یا Safari رو نداره.

  2. Performance متفاوت است. تست با jsdom از تست pure Node کندتره — هزینه شبیه‌سازی DOM رو می‌پردازید.

  3. مشکلات 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 است! قبل از اجرای تست‌ها:

  1. دستور npm run dev رو اجرا می‌کنه
  2. منتظر می‌مونه تا پورت ۳۰۰۰ آماده بشه
  3. تست‌ها رو اجرا می‌کنه
  4. بعد از اتمام، سرور رو می‌بنده

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 این کارها رو می‌کنه:

  1. منتظر می‌مونه المان توی DOM باشه
  2. منتظر می‌مونه visible باشه
  3. منتظر می‌مونه stable باشه (animation تمام شده)
  4. منتظر می‌مونه enabled باشه
  5. بعد 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، fetch behavior
  • همه تست‌ها رو 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) شدیم:

  1. هرم تست — unit زیاد و سریع، E2E کم ولی باارزش
  2. JSDOM vs مرورگر واقعی — چرا E2E لازم است
  3. راه‌اندازی Playwright — نصب، config، webServer
  4. LocatorهاgetByRole اولویت اول
  5. Assertionهای Playwright — auto-waiting و async
  6. Fixturespage, custom fixtures
  7. Tic-Tac-Toe — مثال کامل E2E
  8. Login Flow — unhappy path، POM، protected pages
  9. Cross-browser — projects، mobile viewport
  10. دیباگ — UI mode، trace، codegen
  11. بهترین روش‌ها — چه تست کنیم، flakiness
  12. 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تون رو بزرگ کنید. توی مقاله بعدی سراغ تست‌های یکپارچگی می‌ریم. تا بعد!