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()vewaitForElement()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-testidconvention’ı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.
