Playwright E2E Testing: Page Objects and Best Practices

Build robust Playwright E2E test suites with page objects, data-testid selectors, multi-browser testing, CI integration, and accessibility assertions for Next.js applications.

E
ECOSIRE Research and Development Team
|19 Mart 20268 dk okuma1.8k Kelime|

Oyun Yazarı E2E Testi: Sayfa Nesneleri ve En İyi Uygulamalar

Birim testleri, bireysel işlevlerin doğru şekilde davrandığını doğrular. Entegrasyon testleri hizmetlerin birlikte çalıştığını doğrular. Ancak bir kullanıcı uygulamanıza gerçekten tıkladığında bozulan gezinme akışlarını, JavaScript hidrasyon hatalarını, ağa bağlı işleme hatalarını ve tarayıcılar arası işleme hatalarını da yakalayamaz. Playwright ile yapılan uçtan uca testler bu boşluğu dolduruyor ve Cypress veya Selenium öncüllerinden çok daha güvenilirler.

Oyun Yazarı gerçek Chromium, Firefox ve WebKit tarayıcılarını paralel olarak çalıştırır. Etkileşime geçmeden önce öğelerin görünür ve sabit olmasını otomatik olarak bekler. Arıza durumunda ekran görüntülerini, videoları ve ağ izlerini yakalar. Ve TypeScript öncelikli API'si, sürdürülebilir testler yazmayı bir zevk haline getiriyor. Bu kılavuz, yapılandırmadan sayfa nesnelerine ve Next.js uygulamaları için CI entegrasyonuna kadar her şeyi kapsar.

Önemli Çıkarımlar

  • Yalnızca data-testid seçicilerini kullanın — CSS seçicileri ve metin seçicileri kullanıcı arayüzü değişikliklerinde bozulur
  • Sayfa Nesne Modeli (POM), seçicileri yeniden kullanılabilir sınıflara ayırır - kullanıcı arayüzü öğesi başına bir konum değişikliği
  • Her zaman await page.goto() + await expect(page).toHaveURL() kullanın — navigasyonun eşzamanlı olarak tamamlandığını asla varsaymayın
  • Daha hızlı CI çalışmaları için test.describe.parallel() ve --workers bayrağını kullanın
  • Testleri playwright codegen ile kaydedin ancak işleme koymadan önce daima sayfa nesnelerini yeniden düzenleyin
  • Başarısızlık durumunda yapılandırmada screenshot: 'only-on-failure' aracılığıyla ekran görüntüleri yakalayın
  • @axe-core/playwright ile davranışın yanı sıra erişilebilirliği de test edin
  • Kesintisiz üçüncü taraf aramaları için sahte API yanıtları; kritik mutlu yollar için gerçek API'yi kullanın

Yapılandırma

// 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,
  },
});

Dizin Yapısını Test Edin

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

Sayfa Nesne Modeli

Sayfa Nesne Modeli, sürdürülebilir E2E testleri için en önemli modeldir. Bir seçici değiştiğinde, onu kullanan her testi değil, tek bir yeri güncellersiniz.

// 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);
  }
}

Kimlik Doğrulama Armatürleri

// 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';

Test Yazma

// 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');
  });
});

Genel Sayfa Testleri

// 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 Testleri

// 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);
  });
});

Erişilebilirlik Testleri

// 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 Entegrasyonu

# .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

Sıkça Sorulan Sorular

data-testid, ARIA rolleri ve metin seçicileri ne zaman kullanmalıyım?

Birincil seçici olarak data-testid'ı tercih edin; kullanıcı arayüzü yeniden tasarımlarından, kopya değişikliklerinden ve yeniden düzenlemelerden kurtulur. Erişilebilirliği aynı anda test etmek için ARIA rol seçicilerini (getByRole('button', { name: 'Submit' })) kullanın. Metin içeriği seçicilerden (getByText('Submit')) kaçının çünkü kopya değiştiğinde bozulurlar. Hiçbir zaman CSS sınıfı seçicilerini kullanmayın; bunlar stil çalışmasıyla değişen uygulama ayrıntılarıdır.

CI'da kaç proje (tarayıcı) çalıştırmalıyım?

Çekme isteklerinde Chromium'u yalnızca CI'yi hızlı tutmak için çalıştırın. Ana dal birleştirmeleri için üç masaüstü tarayıcının tümünü (Chromium, Firefox, WebKit) çalıştırın. Sürüm adayları için mobil görünümler ekleyin. WebKit, Safari'ye özgü düzen hatalarını yakalar; Firefox, Chrome ve Safari'nin gizlediği tuhaflıkları yakalar. Katmanlama, yayınlanmadan önce tarayıcılar arası hataları yakalarken CI dakikalarından tasarruf sağlar.

Zamanlama sorunlarından kaynaklanan hatalı testleri nasıl halledebilirim?

Oyun yazarının otomatik beklemesi çoğu zamanlama sorununu çözecektir; page.waitForTimeout() yerine await expect(locator).toBeVisible() kullanın. Otomatik bekleme yeterli değilse, ağa bağlı durum için page.waitForResponse(), yoğun sayfalar için page.waitForLoadState('networkidle') veya özel koşullar için page.waitForFunction() kullanın. Gerçek arızaları altyapıdaki düzensizliklerden ayırmak için CI'da retries: 2 değerini ayarlayın.

E2E testleri gerçek veritabanını mı yoksa tohumlanmış test veritabanını mı kullanmalı?

E2E testleri için yerleşik bir test veritabanına sahip özel bir hazırlama ortamı kullanın. Üretime karşı asla E2E testlerini çalıştırmayın. CI için, test verilerinin eklendiği yeni bir veritabanıyla Docker Compose yığınını başlatın. Test verilerini, sürüklenebilecek önceden eklenmiş verilere güvenmek yerine beforeAll/afterAll kancalarındaki API çağrıları aracılığıyla oluşturun ve temizleyin.

Playwright'ta dosya indirme işlemlerini nasıl test ederim?

page.waitForDownload() modelini kullanın: const [download] = await Promise.all([page.waitForEvent('download'), page.click('[data-testid="download-button"]')]); const path = await download.path(); expect(path).toBeTruthy();. Ayrıca download.suggestedFilename() öğesini kontrol edebilir ve doğrulama için dosya içeriğini okuyabilirsiniz.


Sonraki Adımlar

Playwright E2E testleri, kullanıcıların uygulamanızı görmeden önceki son güvenlik ağıdır. Sayfa nesneleri, veri testi seçicileri, kimlik doğrulama fikstürleri ve CI entegrasyonuyla doğru bir şekilde yatırım yapıldığında, birim testlerinin kaçırdığı şeyleri yakalar ve size Cuma günleri konuşlandırma konusunda güven verir.

ECOSIRE, genel sayfalarda, yönetici iş akışlarında ve 11 yerel ayarın tamamı için erişilebilirlikte 416'dan fazla test senaryosunu kapsayan 29 Playwright E2E test dosyasını tutar. Kalite kontrollü Next.js uygulamalarını nasıl oluşturduğumuzu öğrenmek için ön uç mühendislik hizmetlerimizi keşfedin.

E

Yazan

ECOSIRE Research and Development Team

ECOSIRE'da kurumsal düzeyde dijital ürünler geliştiriyor. Odoo entegrasyonları, e-ticaret otomasyonu ve yapay zeka destekli iş çözümleri hakkında içgörüler paylaşıyor.

WhatsApp'ta Sohbet Et