اختبار الكاتب المسرحي E2E: كائنات الصفحة وأفضل الممارسات
تتحقق اختبارات الوحدة من أن الوظائف الفردية تعمل بشكل صحيح. تتحقق اختبارات التكامل من أن الخدمات تعمل معًا. ولكن لا يمكن لأي منهما اكتشاف ما ينقطع عندما ينقر المستخدم فعليًا عبر التطبيق الخاص بك - تدفقات التنقل المعطلة، وأخطاء ترطيب JavaScript، وفشل العرض المعتمد على الشبكة، وأخطاء العرض عبر المتصفحات. تملأ الاختبارات الشاملة مع Playwright هذه الفجوة، وهي أكثر موثوقية بكثير من أسلافها من Cypress أو Selenium.
يقوم Playwright بتشغيل متصفحات Chromium وFirefox وWebKit الحقيقية بالتوازي. إنه ينتظر تلقائيًا حتى تصبح العناصر مرئية ومستقرة قبل التفاعل. فهو يلتقط لقطات الشاشة ومقاطع الفيديو وآثار الشبكة عند الفشل. كما أن واجهة برمجة التطبيقات TypeScript-first تجعل كتابة الاختبارات القابلة للصيانة أمرًا ممتعًا. يغطي هذا الدليل كل شيء بدءًا من التكوين وحتى كائنات الصفحة وحتى تكامل CI لتطبيقات Next.js.
الوجبات الرئيسية
- استخدم محددات
data-testidحصريًا - تتوقف محددات CSS ومحددات النص على تغييرات واجهة المستخدم- نموذج كائن الصفحة (POM) يلخص المحددات في فئات قابلة لإعادة الاستخدام - موقع تغيير واحد لكل عنصر واجهة مستخدم
- استخدم دائمًا
await page.goto()+await expect(page).toHaveURL()— لا تفترض أبدًا اكتمال التنقل بشكل متزامن- استخدم العلامة
test.describe.parallel()و--workersلتشغيل CI بشكل أسرع- قم بتسجيل الاختبارات باستخدام
playwright codegenولكن قم دائمًا بإعادة تصميم كائنات الصفحة قبل الالتزام بها- التقط لقطات شاشة عند الفشل عبر
screenshot: 'only-on-failure'في التكوين- اختبار إمكانية الوصول إلى جانب السلوك باستخدام
@axe-core/playwright- استجابات وهمية لواجهة برمجة التطبيقات (API) لمكالمات الطرف الثالث غير المستقرة؛ استخدم واجهة برمجة التطبيقات الحقيقية للمسارات السعيدة الحرجة
التكوين
// apps/web/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: [
['html', { outputFolder: 'playwright-report' }],
['json', { outputFile: 'playwright-report/results.json' }],
...(process.env.CI ? [['github'] as ['github']] : []),
],
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 7'] },
},
{
name: 'mobile-safari',
use: { ...devices['iPhone 14'] },
},
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
هيكل دليل الاختبار
tests/
public/ # Tests for unauthenticated public pages
homepage.spec.ts
blog.spec.ts
services.spec.ts
sitemap.spec.ts
i18n.spec.ts
seo.spec.ts
admin/ # Tests for authenticated admin pages
contacts.spec.ts
orders.spec.ts
dashboard.spec.ts
fixtures/
auth.fixture.ts # Authentication fixtures
data.fixture.ts # Test data factories
pages/ # Page Object Model classes
HomePage.ts
BlogPage.ts
ContactsPage.ts
LoginPage.ts
helpers/
api.helper.ts # API call helpers for test setup
نموذج كائن الصفحة
يعد نموذج كائن الصفحة هو النمط الوحيد الأكثر أهمية لاختبارات E2E القابلة للصيانة. عندما يتغير أحد المحددات، فإنك تقوم بتحديث مكان واحد، وليس كل اختبار يستخدمه.
// tests/pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
readonly forgotPasswordLink: Locator;
constructor(page: Page) {
this.page = page;
// Always use data-testid selectors
this.emailInput = page.getByTestId('login-email');
this.passwordInput = page.getByTestId('login-password');
this.submitButton = page.getByTestId('login-submit');
this.errorMessage = page.getByTestId('login-error');
this.forgotPasswordLink = page.getByTestId('forgot-password-link');
}
async goto() {
await this.page.goto('/auth/login');
await expect(this.page).toHaveURL('/auth/login');
await expect(this.submitButton).toBeVisible();
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async loginAndWaitForRedirect(email: string, password: string, expectedUrl: string) {
await this.login(email, password);
await expect(this.page).toHaveURL(expectedUrl, { timeout: 10_000 });
}
async expectError(message: string) {
await expect(this.errorMessage).toBeVisible();
await expect(this.errorMessage).toContainText(message);
}
}
// tests/pages/ContactsPage.ts
import { Page, Locator, expect } from '@playwright/test';
export class ContactsPage {
readonly page: Page;
readonly addButton: Locator;
readonly searchInput: Locator;
readonly table: Locator;
readonly dialog: Locator;
readonly nameInput: Locator;
readonly emailInput: Locator;
readonly saveButton: Locator;
readonly successToast: Locator;
constructor(page: Page) {
this.page = page;
this.addButton = page.getByTestId('contacts-add-button');
this.searchInput = page.getByTestId('contacts-search');
this.table = page.getByTestId('contacts-table');
this.dialog = page.getByTestId('contact-dialog');
this.nameInput = page.getByTestId('contact-name-input');
this.emailInput = page.getByTestId('contact-email-input');
this.saveButton = page.getByTestId('contact-save-button');
this.successToast = page.getByTestId('toast-success');
}
async goto() {
await this.page.goto('/dashboard/contacts');
await expect(this.table).toBeVisible();
}
async openAddDialog() {
await this.addButton.click();
await expect(this.dialog).toBeVisible();
}
async createContact(name: string, email: string) {
await this.openAddDialog();
await this.nameInput.fill(name);
await this.emailInput.fill(email);
await this.saveButton.click();
await expect(this.successToast).toBeVisible();
}
async searchFor(query: string) {
await this.searchInput.fill(query);
await this.page.waitForTimeout(500); // Debounce delay
}
async getRowCount() {
return this.table.getByRole('row').count();
}
async expectContactInTable(name: string) {
await expect(this.table).toContainText(name);
}
}
تركيبات المصادقة
// tests/fixtures/auth.fixture.ts
import { test as base, Page } from '@playwright/test';
type AuthFixture = {
adminPage: Page;
userPage: Page;
};
export const test = base.extend<AuthFixture>({
adminPage: async ({ browser }, use) => {
const context = await browser.newContext();
const page = await context.newPage();
// Login via API to bypass UI (faster, more reliable)
const response = await page.request.post('/auth/login', {
data: {
email: process.env.TEST_ADMIN_EMAIL || '[email protected]',
password: process.env.TEST_ADMIN_PASSWORD || 'testpassword123',
},
});
if (!response.ok()) {
throw new Error(`Auth failed: ${response.status()}`);
}
// Cookies are set automatically on the context
await use(page);
await context.close();
},
userPage: async ({ browser }, use) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.request.post('/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL || '[email protected]',
password: process.env.TEST_USER_PASSWORD || 'testpassword123',
},
});
await use(page);
await context.close();
},
});
export { expect } from '@playwright/test';
اختبارات الكتابة
// tests/admin/contacts.spec.ts
import { test, expect } from '../fixtures/auth.fixture';
import { ContactsPage } from '../pages/ContactsPage';
test.describe('Contacts Management', () => {
test('admin can create, search, and delete a contact', async ({ adminPage }) => {
const contactsPage = new ContactsPage(adminPage);
const testName = `Test User ${Date.now()}`;
const testEmail = `test-${Date.now()}@example.com`;
await contactsPage.goto();
// Create
await contactsPage.createContact(testName, testEmail);
await contactsPage.expectContactInTable(testName);
// Search
await contactsPage.searchFor(testName);
const rowCount = await contactsPage.getRowCount();
expect(rowCount).toBe(2); // 1 header + 1 result
// Delete
await adminPage.getByTestId(`contact-delete-${testEmail}`).click();
await adminPage.getByTestId('confirm-delete').click();
await expect(contactsPage.successToast).toBeVisible();
await expect(contactsPage.table).not.toContainText(testName);
});
test('shows validation errors for invalid contact data', async ({ adminPage }) => {
const contactsPage = new ContactsPage(adminPage);
await contactsPage.goto();
await contactsPage.openAddDialog();
// Submit without filling required fields
await contactsPage.saveButton.click();
await expect(adminPage.getByTestId('error-name')).toContainText('required');
await expect(adminPage.getByTestId('error-email')).toContainText('required');
});
});
اختبارات الصفحة العامة
// tests/public/homepage.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Homepage', () => {
test('renders hero section with correct heading', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/ECOSIRE/);
const hero = page.getByTestId('hero-heading');
await expect(hero).toBeVisible();
await expect(hero).not.toBeEmpty();
});
test('navigation links are all accessible', async ({ page }) => {
await page.goto('/');
const navLinks = page.getByRole('navigation').getByRole('link');
const count = await navLinks.count();
for (let i = 0; i < count; i++) {
const link = navLinks.nth(i);
const href = await link.getAttribute('href');
// All nav links should have valid hrefs
expect(href).toBeTruthy();
expect(href).not.toBe('#');
}
});
test('CTA button links to services page', async ({ page }) => {
await page.goto('/');
await page.getByTestId('hero-cta').click();
await expect(page).toHaveURL(/\/services/);
});
});
اختبارات i18n
// tests/public/i18n.spec.ts
import { test, expect } from '@playwright/test';
const testLocales = ['zh', 'es', 'ar', 'fr'];
test.describe('Internationalization', () => {
for (const locale of testLocales) {
test(`/${locale}/ renders with correct lang attribute`, async ({ page }) => {
await page.goto(`/${locale}/`);
const html = page.locator('html');
await expect(html).toHaveAttribute('lang', locale);
});
}
test('Arabic locale sets dir="rtl"', async ({ page }) => {
await page.goto('/ar/');
const html = page.locator('html');
await expect(html).toHaveAttribute('dir', 'rtl');
});
test('language switcher changes locale', async ({ page }) => {
await page.goto('/');
await page.getByTestId('language-switcher').click();
await page.getByTestId('locale-option-fr').click();
await expect(page).toHaveURL('/fr/');
await expect(page.locator('html')).toHaveAttribute('lang', 'fr');
});
test('hreflang tags are present on all pages', async ({ page }) => {
await page.goto('/');
const hreflangs = await page.locator('link[rel="alternate"][hreflang]').all();
// Should have 11 locale hreflangs + x-default
expect(hreflangs.length).toBeGreaterThanOrEqual(12);
});
});
اختبارات إمكانية الوصول
// tests/public/accessibility.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Accessibility', () => {
const pages = ['/', '/blog', '/services', '/about', '/contact'];
for (const path of pages) {
test(`${path} has no WCAG 2.1 AA violations`, async ({ page }) => {
await page.goto(path);
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze();
if (results.violations.length > 0) {
const report = results.violations.map((v) =>
`${v.id}: ${v.description} — ${v.nodes.length} element(s)`
).join('\n');
expect.fail(`Accessibility violations on ${path}:\n${report}`);
}
});
}
test('skip link is the first focusable element', async ({ page }) => {
await page.goto('/');
await page.keyboard.press('Tab');
const focused = page.locator(':focus');
await expect(focused).toHaveAttribute('href', '#main-content');
});
});
التكامل مع CI
# .github/workflows/ci.yml (E2E section)
e2e-tests:
runs-on: ubuntu-latest
needs: [unit-tests, build]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: cd apps/web && npx playwright install --with-deps chromium firefox
- name: Run E2E tests
run: cd apps/web && npx playwright test --project=chromium --project=firefox
env:
PLAYWRIGHT_BASE_URL: ${{ secrets.STAGING_URL }}
TEST_ADMIN_EMAIL: ${{ secrets.TEST_ADMIN_EMAIL }}
TEST_ADMIN_PASSWORD: ${{ secrets.TEST_ADMIN_PASSWORD }}
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: apps/web/playwright-report/
retention-days: 7
الأسئلة المتداولة
متى يجب أن أستخدم أدوار data-testid مقابل أدوار ARIA مقابل محددات النص؟
قم بتفضيل data-testid باعتباره المحدد الأساسي — فهو يظل قائمًا بعد إعادة تصميم واجهة المستخدم، وتغييرات النسخ، وإعادة البناء. استخدم محددات دور ARIA (getByRole('button', { name: 'Submit' })) لاختبار إمكانية الوصول في وقت واحد. تجنب محددات محتوى النص (getByText('Submit')) لأنها تنقطع عند تغيير النسخة. لا تستخدم مطلقًا محددات فئة CSS، فهي عبارة عن تفاصيل تنفيذ تتغير مع عمل التصميم.
كم عدد المشاريع (المتصفحات) التي يجب تشغيلها في CI؟
بالنسبة لطلبات السحب، قم بتشغيل Chromium فقط للحفاظ على سرعة CI. بالنسبة لدمج الفروع الرئيسية، قم بتشغيل جميع متصفحات سطح المكتب الثلاثة (Chromium وFirefox وWebKit). بالنسبة للإصدارات المرشحة، أضف إطارات عرض للجوال. يكتشف WebKit أخطاء التخطيط الخاصة بـ Safari؛ يلتقط Firefox المراوغات التي يخفيها كل من Chrome وSafari. يوفر نظام Tiering دقائق CI أثناء اكتشاف الأخطاء في المتصفحات قبل الإصدار.
كيف أتعامل مع الاختبارات غير المستقرة الناتجة عن مشكلات التوقيت؟
يجب أن يعالج الانتظار التلقائي للكاتب المسرحي معظم مشكلات التوقيت — استخدم await expect(locator).toBeVisible() بدلاً من page.waitForTimeout(). إذا لم يكن الانتظار التلقائي كافيًا، فاستخدم page.waitForResponse() للحالة المعتمدة على الشبكة، أو page.waitForLoadState('networkidle') للصفحات الثقيلة، أو page.waitForFunction() للشروط المخصصة. قم بتعيين retries: 2 في CI للتمييز بين حالات الفشل الحقيقية وهشاشة البنية التحتية.
هل يجب أن تستخدم اختبارات E2E قاعدة البيانات الحقيقية أم قاعدة بيانات الاختبار المصنفة؟
استخدم بيئة مرحلية مخصصة مع قاعدة بيانات اختبار مصنفة لاختبارات E2E. لا تقم مطلقًا بإجراء اختبارات E2E مقابل الإنتاج. بالنسبة إلى CI، قم بتدوير مكدس Docker Compose بقاعدة بيانات جديدة مزوّدة ببيانات الاختبار. قم بإنشاء بيانات الاختبار وتنظيفها عبر استدعاءات واجهة برمجة التطبيقات (API) في الخطافات beforeAll/afterAll بدلاً من الاعتماد على البيانات المصنفة مسبقًا والتي يمكن أن تنحرف.
كيف أختبر تنزيلات الملفات في Playwright؟
استخدم النمط page.waitForDownload(): const [download] = await Promise.all([page.waitForEvent('download'), page.click('[data-testid="download-button"]')]); const path = await download.path(); expect(path).toBeTruthy();. يمكنك أيضًا التحقق من download.suggestedFilename() وقراءة محتويات الملف للتحقق من صحته.
الخطوات التالية
تعد اختبارات Playwright E2E آخر شبكة أمان قبل أن يرى المستخدمون تطبيقك. إذا استثمرت بشكل صحيح - باستخدام كائنات الصفحة، ومحددات اختبار البيانات، وتركيبات المصادقة، وتكامل CI - فإنها تلتقط ما تفتقده اختبارات الوحدة وتمنحك الثقة للنشر في أيام الجمعة.
يحتفظ ECOSIRE بـ 29 ملف اختبار Playwright E2E يغطي أكثر من 416 حالة اختبار عبر الصفحات العامة، وسير عمل المسؤول، وإمكانية الوصول لجميع اللغات الـ 11. استكشف خدماتنا الهندسية للواجهة الأمامية للتعرف على كيفية إنشاء تطبيقات Next.js ذات الجودة العالية.
بقلم
ECOSIRE Research and Development Team
بناء منتجات رقمية بمستوى المؤسسات في ECOSIRE. مشاركة رؤى حول تكاملات Odoo وأتمتة التجارة الإلكترونية وحلول الأعمال المدعومة بالذكاء الاصطناعي.
مقالات ذات صلة
AI-Powered Accounting Automation: What Works in 2026
Discover which AI accounting automation tools deliver real ROI in 2026, from bank reconciliation to predictive cash flow, with implementation strategies.
Payroll Processing: Setup, Compliance, and Automation
Complete payroll processing guide covering employee classification, federal and state withholding, payroll taxes, garnishments, automation platforms, and year-end W-2 compliance.
AI Agents for Business Automation: The 2026 Landscape
Explore how AI agents are transforming business automation in 2026, from multi-agent orchestration to practical deployment strategies for enterprise teams.