Playwright ile Modern Web Uygulama Test Otomasyonu

Bir üretim ortamında geceleri kimsenin fark etmediği bir JavaScript hatası yüzünden checkout akışının sessizce kırıldığını ve sabah iş yerine geldiğinizde bunu müşteri şikayetlerinden öğrendiğinizi hayal edin. Bunu bir kez yaşadıktan sonra test otomasyonuna bakış açınız tamamen değişiyor. Ben bu deneyimi yaşadım ve o günden bu yana Playwright benim için bir tercih değil, bir zorunluluk haline geldi.

Playwright, Microsoft’un geliştirdiği ve Chromium, Firefox ile WebKit üzerinde çalışabilen modern bir web test framework’ü. Selenium’un 2004’ten gelen mirasının aksine, Playwright sıfırdan modern web uygulamaları düşünülerek tasarlandı. Single-page application’lar, dinamik içerikler, network request intercepting, paralel test çalıştırma… Bunların hepsi Playwright’ta first-class citizen.

Neden Playwright, Neden Şimdi?

Türkiye’deki pek çok ekiple konuştuğumda hâlâ Selenium WebDriver kullananlar görüyorum. Bunu anlayışla karşılıyorum çünkü Selenium’un geniş ekosistemi ve uzun geçmişi var. Ama şunu açıkça söylemeliyim: Eğer bugün sıfırdan bir test altyapısı kuruyorsanız, Playwright’ı seçin.

Playwright’ın öne çıkan özellikleri:

  • Auto-waiting mekanizması: Element görünür olana, tıklanabilir olana kadar otomatik bekliyor. sleep() ve waitForElement() cehenneminden kurtuluyorsunuz
  • Network interception: API response’larını mock’layabilir, network koşullarını simüle edebilirsiniz
  • Paralel test çalıştırma: Worker’lar üzerinde testler paralel koşar, CI/CD süreniz dramatik düşer
  • Trace viewer: Test başarısız olduğunda tam olarak ne olduğunu, hangi network request’lerin gittiğini, screenshot’ları görebilirsiniz
  • Codegen: Tarayıcıda yaptığınız işlemleri otomatik olarak koda dönüştürür

Kurulum ve Temel Yapılandırma

Node.js projesi için kurulum gayet basit:

npm init -y
npm install -D @playwright/test
npx playwright install
npx playwright install-deps

Bu komutlar Chromium, Firefox ve WebKit tarayıcılarını indirir. CI ortamında genellikle sadece Chromium yeterlidir, gereksiz indirmelerden kaçınmak için:

npx playwright install chromium

Temel playwright.config.ts dosyası şöyle görünmeli:

# playwright.config.ts içeriği
cat > playwright.config.ts << 'EOF'
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 4 : undefined,
  reporter: [
    ['html', { outputFolder: 'playwright-report' }],
    ['junit', { outputFile: 'test-results/junit.xml' }],
  ],
  use: {
    baseURL: process.env.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: 'mobile-chrome',
      use: { ...devices['Pixel 5'] },
    },
  ],
});
EOF

Burada dikkat etmenizi istediğim birkaç nokta var. retries: 2 sadece CI ortamında aktif çünkü lokal geliştirmede bir test başarısız olduğunda sebebini hemen görmek istiyorsunuz, gereksiz retry sizi yanıltmasın. forbidOnly ise .only() ile işaretlenmiş testlerin CI’a yanlışlıkla push edilmesini engelliyor.

İlk Gerçek Test: E-ticaret Senaryosu

Teoriden ziyade gerçek dünya senaryolarına geçelim. Bir e-ticaret sitesinin ödeme akışını test edelim:

# tests/checkout.spec.ts
cat > tests/checkout.spec.ts << 'EOF'
import { test, expect } from '@playwright/test';

test.describe('Checkout Akışı', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/');
    // Her testten önce sepeti temizle
    await page.evaluate(() => localStorage.clear());
  });

  test('ürün sepete ekleme ve ödeme tamamlama', async ({ page }) => {
    // Ürün sayfasına git
    await page.goto('/products/laptop-123');
    
    // Ürünün fiyatını al ve doğrula
    const price = await page.locator('[data-testid="product-price"]').textContent();
    expect(price).toMatch(/₺[d.,]+/);
    
    // Sepete ekle
    await page.locator('[data-testid="add-to-cart"]').click();
    
    // Sepet sayacının güncellendiğini doğrula
    await expect(page.locator('[data-testid="cart-count"]')).toHaveText('1');
    
    // Checkout sayfasına git
    await page.goto('/checkout');
    
    // Form doldurma
    await page.fill('[name="firstName"]', 'Ahmet');
    await page.fill('[name="lastName"]', 'Yılmaz');
    await page.fill('[name="email"]', '[email protected]');
    await page.fill('[name="phone"]', '05321234567');
    
    // Adres bilgileri
    await page.fill('[name="address"]', 'Atatürk Caddesi No:1');
    await page.selectOption('[name="city"]', 'İstanbul');
    
    // Sipariş tamamla
    await page.locator('[data-testid="complete-order"]').click();
    
    // Başarı mesajını bekle
    await expect(page.locator('[data-testid="order-success"]')).toBeVisible({ timeout: 10000 });
    await expect(page.locator('[data-testid="order-number"]')).toBeVisible();
  });
});
EOF

Page Object Model ile Sürdürülebilir Test Mimarisi

Testleriniz büyüdükçe her şeyi tek bir dosyaya yazmak kaosa dönüşür. Page Object Model (POM) bu sorunu çözer. Her sayfa için ayrı bir sınıf oluşturuyorsunuz:

# pages/LoginPage.ts
cat > pages/LoginPage.ts << 'EOF'
import { Page, Locator, expect } 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.locator('[data-testid="email-input"]');
    this.passwordInput = page.locator('[data-testid="password-input"]');
    this.loginButton = page.locator('[data-testid="login-button"]');
    this.errorMessage = page.locator('[data-testid="error-message"]');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }

  async expectErrorMessage(message: string) {
    await expect(this.errorMessage).toBeVisible();
    await expect(this.errorMessage).toContainText(message);
  }

  async expectRedirectToDashboard() {
    await this.page.waitForURL('/dashboard');
    await expect(this.page).toHaveURL('/dashboard');
  }
}
EOF

# tests/login.spec.ts
cat > tests/login.spec.ts << 'EOF'
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

test.describe('Login Sayfası', () => {
  let loginPage: LoginPage;

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    await loginPage.goto();
  });

  test('geçerli kimlik bilgileriyle giriş', async () => {
    await loginPage.login('[email protected]', 'SecurePass123!');
    await loginPage.expectRedirectToDashboard();
  });

  test('yanlış şifre ile hata mesajı', async () => {
    await loginPage.login('[email protected]', 'yanlisSifre');
    await loginPage.expectErrorMessage('E-posta veya şifre hatalı');
  });

  test('boş form gönderiminde validasyon', async ({ page }) => {
    await loginPage.loginButton.click();
    await expect(page.locator('[data-testid="email-error"]')).toBeVisible();
  });
});
EOF

Network Mocking ile Bağımsız Testler

Production’da test yazmayı seven yok. Gerçek API’lere bağlı testler yavaş, kararsız ve bakımı zor oluyor. Playwright’ın network interception özelliği bu sorunu çözüyor:

# tests/api-mocking.spec.ts
cat > tests/api-mocking.spec.ts << 'EOF'
import { test, expect } from '@playwright/test';

test.describe('API Mocking Senaryoları', () => {
  test('başarılı ürün listesi yüklenmesi', async ({ page }) => {
    // API çağrısını intercept et ve mock data dön
    await page.route('**/api/products', async (route) => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({
          products: [
            { id: 1, name: 'Test Ürünü', price: 299.99, stock: 10 },
            { id: 2, name: 'Başka Ürün', price: 149.50, stock: 0 },
          ],
          total: 2,
        }),
      });
    });

    await page.goto('/products');
    
    await expect(page.locator('[data-testid="product-card"]')).toHaveCount(2);
    await expect(page.locator('text=Test Ürünü')).toBeVisible();
    
    // Stok olmayan ürün için "Tükendi" etiketi
    const outOfStock = page.locator('[data-testid="product-card"]').nth(1);
    await expect(outOfStock.locator('[data-testid="out-of-stock"]')).toBeVisible();
  });

  test('API hatası durumunda kullanıcıya bildirim', async ({ page }) => {
    await page.route('**/api/products', async (route) => {
      await route.fulfill({
        status: 500,
        contentType: 'application/json',
        body: JSON.stringify({ error: 'Internal Server Error' }),
      });
    });

    await page.goto('/products');
    
    await expect(page.locator('[data-testid="error-notification"]')).toBeVisible();
    await expect(page.locator('text=Ürünler yüklenirken bir hata oluştu')).toBeVisible();
  });

  test('yavaş internet bağlantısı simülasyonu', async ({ page, context }) => {
    // 3G benzeri yavaş bağlantı
    await context.route('**/api/**', async (route) => {
      await new Promise(resolve => setTimeout(resolve, 2000));
      await route.continue();
    });

    await page.goto('/products');
    
    // Loading skeleton görünmeli
    await expect(page.locator('[data-testid="loading-skeleton"]')).toBeVisible();
    
    // İçerik sonunda yüklenmeli
    await expect(page.locator('[data-testid="product-list"]')).toBeVisible({ timeout: 10000 });
  });
});
EOF

Authentication State Yönetimi

Her testte login işlemi yapmak hem zaman kaybı hem de test kararsızlığına yol açar. Playwright’ın storage state özelliğiyle authentication durumunu bir kez oluşturup yeniden kullanabilirsiniz:

# tests/auth.setup.ts - Global setup dosyası
cat > tests/auth.setup.ts << 'EOF'
import { test as setup, expect } from '@playwright/test';
import path from 'path';

const authFile = path.join(__dirname, '../.auth/user.json');

setup('authentication state hazırlama', async ({ page }) => {
  await page.goto('/login');
  
  await page.fill('[name="email"]', process.env.TEST_USER_EMAIL || '[email protected]');
  await page.fill('[name="password"]', process.env.TEST_USER_PASSWORD || 'TestPass123!');
  await page.click('[data-testid="login-button"]');
  
  await page.waitForURL('/dashboard');
  await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();
  
  // Authentication state'i kaydet
  await page.context().storageState({ path: authFile });
});
EOF

# playwright.config.ts'e eklenecek kısım
# projects array'ine şunu ekleyin:
cat >> playwright.config.ts << 'EOF'
# Config'e authentication project eklemek için:
# {
#   name: 'setup',
#   testMatch: /auth.setup.ts/,
# },
# {
#   name: 'authenticated-tests',
#   use: {
#     ...devices['Desktop Chrome'],
#     storageState: '.auth/user.json',
#   },
#   dependencies: ['setup'],
# },
EOF

CI/CD Pipeline Entegrasyonu

GitLab CI örneği üzerinden gidelim. GitHub Actions için de mantık aynı, sadece syntax değişiyor:

# .gitlab-ci.yml
cat > .gitlab-ci.yml << 'EOF'
stages:
  - test

playwright-tests:
  stage: test
  image: mcr.microsoft.com/playwright:v1.48.0-jammy
  variables:
    BASE_URL: $STAGING_URL
    TEST_USER_EMAIL: $CI_TEST_EMAIL
    TEST_USER_PASSWORD: $CI_TEST_PASSWORD
  before_script:
    - npm ci
  script:
    - npx playwright test --reporter=html,junit
  after_script:
    - npx playwright show-report || true
  artifacts:
    when: always
    expire_in: 7 days
    paths:
      - playwright-report/
      - test-results/
    reports:
      junit: test-results/junit.xml
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"
  parallel:
    matrix:
      - SHARD_INDEX: [1, 2, 3, 4]
        SHARD_TOTAL: 4
EOF

# Test sharding için package.json script'i
cat > run-shard.sh << 'EOF'
#!/bin/bash
npx playwright test 
  --shard=$SHARD_INDEX/$SHARD_TOTAL 
  --reporter=blob
EOF
chmod +x run-shard.sh

Buradaki parallel matrix yaklaşımı önemli. 200 test varsa her shard 50 test çalıştırır, toplam süreniz dörtte bire düşer. Büyük projelerde bu fark çok kritik oluyor.

Visual Regression Testing

Bir UI değişikliğinin beklenmedik yerleri etkileyip etkilemediğini yakalamak için screenshot karşılaştırması yapabilirsiniz:

# tests/visual.spec.ts
cat > tests/visual.spec.ts << 'EOF'
import { test, expect } from '@playwright/test';

test.describe('Visual Regression Testleri', () => {
  test('ana sayfa görsel karşılaştırması', async ({ page }) => {
    await page.goto('/');
    
    // Animasyonları durdur - tutarsız screenshot'ları önler
    await page.addStyleTag({
      content: `
        *, *::before, *::after {
          animation-duration: 0s !important;
          transition-duration: 0s !important;
        }
      `
    });
    
    // Dinamik içerikleri maskele
    await expect(page).toHaveScreenshot('homepage.png', {
      mask: [
        page.locator('[data-testid="current-date"]'),
        page.locator('[data-testid="live-price"]'),
      ],
      maxDiffPixelRatio: 0.02, // %2 tolerans
    });
  });

  test('mobil görünüm karşılaştırması', async ({ page }) => {
    await page.setViewportSize({ width: 375, height: 812 });
    await page.goto('/');
    
    await expect(page).toHaveScreenshot('homepage-mobile.png', {
      fullPage: true,
    });
  });
});
EOF

İlk çalıştırmada screenshot’lar baseline olarak kaydedilir. Sonraki çalıştırmalarda bu baseline’la karşılaştırılır. Bir geliştirici yanlışlıkla CSS’i bozduğunda test anında yakalar.

Gerçek Hayatta Karşılaşılan Sorunlar ve Çözümleri

Test yazarken mutlaka karşılaşacağınız durumlar var. Bunları önceden bilmek size çok zaman kazandırır.

Flaky testlerle başa çıkma:

# Belirli testler için retry sayısını artırabilirsiniz
# tests/flaky.spec.ts
test('bazen başarısız olan test', async ({ page }) => {
  test.slow(); // timeout'u 3 katına çıkarır
  
  await page.goto('/heavy-page');
  
  // Kesin selector kullanın, text tabanlı seçicilerden kaçının
  // YANLIŞ: await page.click('text=Kaydet')
  // DOĞRU:
  await page.locator('[data-testid="save-button"]').click();
  
  // Network request tamamlanana kadar bekleyin
  await page.waitForResponse(
    response => response.url().includes('/api/save') && response.status() === 200
  );
});

data-testid kullanımı: Geliştirici ekibiyle anlaşın ve tüm interaktif elemanlara data-testid attribute’u ekleyin. CSS sınıfları değişir, element yapısı değişir, ama data-testid değişmez. Bu küçük kural test bakım maliyetinizi yarıya indirir.

Ortam bağımsızlığı: Testlerinizin local, staging ve production’da çalışması gerekiyorsa baseURL’i her zaman environment variable’dan alın. Hardcoded URL’ler ekibi yavaşlatır.

Test Raporlama ve Monitoring

Playwright’ın HTML reporter’ı iyi ama enterprise ortamlarda daha fazlasına ihtiyaç duyabilirsiniz. Allure ile entegrasyon oldukça popüler:

# Allure entegrasyonu
npm install -D allure-playwright

# package.json scripts
cat >> package.json << 'EOF'
{
  "scripts": {
    "test": "playwright test",
    "test:headed": "playwright test --headed",
    "test:debug": "playwright test --debug",
    "test:ui": "playwright test --ui",
    "report": "playwright show-report",
    "allure:generate": "allure generate allure-results --clean -o allure-report",
    "allure:open": "allure open allure-report"
  }
}
EOF

# Test sonuçlarını Slack'e bildirmek için basit script
cat > scripts/notify-slack.sh << 'EOF'
#!/bin/bash
PASSED=$(grep -o '"passed":[0-9]*' test-results/results.json | grep -o '[0-9]*')
FAILED=$(grep -o '"failed":[0-9]*' test-results/results.json | grep -o '[0-9]*')

curl -X POST $SLACK_WEBHOOK_URL 
  -H 'Content-Type: application/json' 
  -d "{
    "text": "Playwright Test Sonuçları",
    "attachments": [{
      "color": "$([ $FAILED -eq 0 ] && echo 'good' || echo 'danger')",
      "fields": [
        {"title": "Başarılı", "value": "$PASSED", "short": true},
        {"title": "Başarısız", "value": "$FAILED", "short": true}
      ]
    }]
  }"
EOF
chmod +x scripts/notify-slack.sh

Sonuç

Playwright’a geçiş kararı kolay ama doğru kurgulamak emek istiyor. Benim önerim şu sırayla ilerleyin:

  • Önce en kritik kullanıcı akışlarını kapsayan 10-15 test yazın, mükemmeliyetçiliği bir kenara bırakın
  • Page Object Model’i erken benimseyin, sonradan refactoring acı vericidir
  • CI entegrasyonunu ilk haftadan kurun, local’de çalışıp CI’da çalışmayan testler güven kırar
  • data-testid convention’ını frontend ekibiyle mutabık kalın
  • Flaky testlere sıfır tolerans politikası uygulayın, güvenilmez test güvensiz sistemden beterdir

Playwright topluluğu aktif, dokümantasyonu gerçekten iyi yazılmış ve Microsoft’un kurumsal desteği arkasında. Cypress ile kıyasladığınızda özellikle multi-tab, multi-origin ve iframe senaryolarında Playwright çok daha esnek davranıyor.

Test otomasyonu bir yatırım ve geri dönüşü zaman alıyor. Ama o ilk geceden öğrendiğim dersi asla unutmuyorum: Müşteriden önce siz bulursanız bug, bu bir başarıdır. Müşteri bulursa, bu bir kriz.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir