Mert Özen Her Satırda Daha İleri
Test ve Kalite Integration ve E2E Test — #12

Playwright Test Runner Yapılandırması: playwright.config, Project'ler, Fixture Yazmak (test.extend), test.step, Tag/Annotation ve Reporter'lar

Mert Özen 15 Kas 2026 12 dk 6 görüntülenme
Playwright Test Runner Yapılandırması: playwright.config, Project'ler, Fixture Yazmak (test.extend), test.step, Tag/Annotation ve Reporter'lar

Angular uygulamasında yakalanmamış hataları pageerror ile yakalayan bir auto fixture hiçbir şey görmez, çünkü Angular'ın global error listener'ları hatayı console.error'a çevirir. Bookstore üzerinde playwright.config'i, timeout katmanlarını, project'leri, ortam değişkenli webServer'ı, test ve worker scope'lu fixture yazmayı, test.step'i, tag ve annotation'ları, blob raporlarıyla shard birleştirmeyi ve .NET'te PageTest'i gerçek koşularla anlatıyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Angular 22.2, Playwright 1.65 (@playwright/test), Microsoft.Playwright ve Microsoft.Playwright.Xunit.v3 1.64.1 ve xUnit.net v3 4.0.3 ile derlenip denenmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) E2E (uçtan uca) suite'ine her testte kendiliğinden çalışan bir fixture eklendi. Amacı, sayfada yakalanmamış bir JavaScript hatası olursa testi kırmak:

pageErrors: [
  async ({ page }, use) => {
    const errors: Error[] = [];
    page.on('pageerror', (error) => errors.push(error));
    await use();
    expect(errors.map((e) => e.message), 'uncaught errors in the page').toEqual([]);
  },
  { auto: true },
],

Fixture'ı denemek için katalog sayfasında bir setTimeout içinden throw new Error('price is undefined') çalıştıran bir test yazdım. Uygulama Angular 22; app.config.ts'te CLI'ın eklediği provideBrowserGlobalErrorListeners() duruyor. Test kalır mı?

Cevap: Angular Hatayı Yakaladı, pageerror Gelmedi

Test geçti. Aynı sayfada iki event'i birlikte dinleyince neden görüldü:

console.error: ERROR Error: price is undefined
console.error: ERROR Error: coupon lookup failed

Ne fırlatılan hata ne de reddedilen promise pageerror olarak geldi. provideBrowserGlobalErrorListeners, window'daki yakalanmamış hataları ve promise reddedilmelerini yakalayıp Angular'ın ErrorHandler'ına veriyor; o da hatayı ERROR önekiyle console.error'a yazıyor. Tarayıcıya göre hata yakalanmış oluyor. Fixture'ın Angular uygulamasında işe yaraması için iki kaynağı da dinlemesi gerekiyor:

// Her testte kendiliğinden çalışır: sayfada yakalanmamış bir JavaScript hatası testi kırar
pageErrors: [
  async ({ page }, use) => {
    const errors: string[] = [];
    page.on('pageerror', (error) => errors.push(error.message));
    // Angular'ın global error listener'ları hatayı ErrorHandler'a verir: pageerror değil, "ERROR ..." console.error'ı gelir
    page.on('console', (message) => {
      if (message.type() === 'error' && message.text().startsWith('ERROR')) {
        errors.push(message.text().split('\n')[0]);
      }
    });
    await use();
    expect(errors, 'uncaught errors in the page').toEqual([]);
  },
  { auto: true },
],
    Error: uncaught errors in the page

    expect(received).toEqual(expected) // deep equality

    - Expected  - 1
    + Received  + 3

    - Array []
    + Array [
    +   "ERROR Error: price is undefined",
    + ]

Filtrenin ERROR önekine bakması bilinçli: 8. yazıdaki ödeme kesintisi testi 503 dönen bir istek yüzünden tarayıcının kendi "Failed to load resource" mesajını üretiyor ve bu beklenen bir durum. Bu yazı, 7. yazıdan beri parça parça büyüyen playwright.config.ts'i ve fixture dosyasını baştan sona ele alıyor.

playwright.config.ts ve Timeout Katmanları

import { defineConfig, devices } from '@playwright/test';
import { apiPort, apiURL, customerStorageState, timezoneId, webPort, webURL } from './e2e/support/config';

const ci = !!process.env.CI;

export default defineConfig({
  testDir: './e2e',
  // Görsel testler platforma bağlı; playwright.visual.config.ts ile container'da koşar
  testIgnore: /visual\.spec\.ts/,
  fullyParallel: true,
  forbidOnly: ci,
  retries: ci ? 2 : 0,
  // Timeout katmanları: test 30 sn, assertion 5 sn; CI'da bütün koşu en fazla 20 dk
  timeout: 30_000,
  expect: { timeout: 5_000 },
  globalTimeout: ci ? 20 * 60_000 : undefined,
  reporter: ci
    ? [['blob'], ['junit', { outputFile: 'test-results/junit.xml' }], ['list']]
    : [['list'], ['html', { open: 'never' }]],
  use: {
    baseURL: webURL,
    locale: 'en-US',
    timezoneId,
    actionTimeout: 10_000,
    navigationTimeout: 15_000,
    trace: 'retain-on-failure',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], storageState: customerStorageState },
      dependencies: ['setup'],
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'], storageState: customerStorageState },
      dependencies: ['setup'],
      grep: /@smoke/,
    },
    {
      name: 'mobile',
      use: { ...devices['Pixel 7'], storageState: customerStorageState },
      dependencies: ['setup'],
      grep: /@mobile/,
    },
  ],
  webServer: [
    {
      name: 'api',
      command: 'dotnet run --project ../tests/Bookstore.E2E.Host',
      url: `${apiURL}/api/books`,
      env: { BOOKSTORE_API_PORT: String(apiPort) },
      reuseExistingServer: !ci,
      timeout: 180_000,
      gracefulShutdown: { signal: 'SIGTERM', timeout: 10_000 },
    },
    {
      name: 'web',
      command: `npm start -- --port ${webPort}`,
      url: webURL,
      env: { BOOKSTORE_API_URL: apiURL },
      reuseExistingServer: !ci,
    },
  ],
});

use bloğu her testin browser context'ine giden seçenekler: baseURL, locale ve timezoneId (8. yazıda saat dilimi hatası için eklenmişti), başarısız testte iz bırakan trace, screenshot ve video. Aynı anahtarlar project'in ya da tek bir dosyanın test.use(...)'unda da veriliyor; 10. yazıdaki test.use({ storageState: buyerStorageState }) bunun örneğiydi.

Timeout'lar iç içe katmanlar. Test timeout'u (varsayılan 30 saniye) test fonksiyonunu, beforeEach'leri ve fixture kurulumlarını kapsıyor; teardown'lara aynı uzunlukta ayrı bir süre veriliyor. Expect timeout'u (5 saniye) tek bir assertion için. actionTimeout ve navigationTimeout'un varsayılanı yok: belirtmezsen takılan bir click, test timeout'u dolana kadar bekliyor. 10. yazının tahmin bölümündeki testin ilk hâli tam olarak böyle bitti: çıktıda yalnız "Test timeout of 8000ms exceeded." vardı, hangi action'ın neyi beklediği yoktu. click'e ayrı timeout verince aynı hata call log'uyla geldi. globalTimeout bütün koşunun üst sınırı; yalnız CI'da açtım. test.slow() bir testin timeout'unu üçe katlıyor: test.info().timeout 90000 yazdı.

Project'ler ve webServer

Project, aynı testlerin farklı ayarlarla koşması. Bookstore'da dört project var: bütün testler Chromium'da, @smoke etiketli üçü WebKit'te, @mobile etiketli arama testi Pixel 7 emülasyonunda; setup ise 7. yazıdaki login'i yapıp ötekilerin önüne bağımlılık olarak giriyor. Firefox da aynı şekilde devices['Desktop Firefox'] ile bir project olarak ekleniyor. Yalnız smoke testleri çalıştırınca:

$ npx playwright test --grep @smoke
Running 9 tests using 5 workers
  ✓  2 [setup] › e2e/auth.setup.ts:18:1 › sign in as Nevin (institution buyer) (326ms)
  ✓  1 [setup] › e2e/auth.setup.ts:14:1 › sign in as Ada (359ms)
  ✓  4 [chromium] › e2e/catalog.spec.ts:3:1 › search finds a book with a lowercase term @smoke @mobile (848ms)
  ✓  7 [webkit] › e2e/catalog.spec.ts:3:1 › search finds a book with a lowercase term @smoke @mobile (957ms)
  ✓  5 [webkit] › e2e/checkout.spec.ts:3:1 › a customer orders a book with a coupon @smoke (1.2s)
  ✓  6 [chromium] › e2e/checkout.spec.ts:3:1 › a customer orders a book with a coupon @smoke (1.7s)
  ✓  3 [chromium] › e2e/orders.spec.ts:3:1 › the order page shows a seeded order @smoke (1.8s)
  ✓  8 [webkit] › e2e/orders.spec.ts:3:1 › the order page shows a seeded order @smoke (791ms)
  ✓  9 [mobile] › e2e/catalog.spec.ts:3:1 › search finds a book with a lowercase term @smoke @mobile (590ms)
  9 passed (7.2s)

Setup testleri @smoke etiketi taşımadıkları halde çalıştı: --grep bağımlılık project'lerine uygulanmıyor. Bütün suite 21 test, 9,7 saniye sürdü. devices içindeki tanımlar viewport, user agent, deviceScaleFactor, isMobile ve hasTouch gibi değerleri birlikte veriyor; Pixel 7 Chromium motoruyla, iPhone tanımları WebKit'le koşuyor.

Adresler artık ortam değişkenlerinden geliyor. 6. yazıdan beri 4200 ve 5280 config'e yazılıydı; aynı makinede ikinci bir kopya ya da port'u dolu bir CI runner'ı için değiştirilebilir olmalılar:

// Yerelde varsayılan portlar; CI ya da aynı makinede ikinci bir kopya ortam değişkeniyle değiştirir
export const apiPort = Number(process.env.BOOKSTORE_API_PORT ?? 5280);
export const webPort = Number(process.env.BOOKSTORE_WEB_PORT ?? 4200);
export const apiURL = process.env.BOOKSTORE_API_URL ?? `http://127.0.0.1:${apiPort}`;
export const webURL = process.env.BOOKSTORE_WEB_URL ?? `http://localhost:${webPort}`;

webServer'ın env seçeneği değişkenleri başlattığı process'e geçiriyor. E2E host'u port'u BOOKSTORE_API_PORT'tan okuyor; Angular'ın proxy ayarı da JSON yerine bir proxy.conf.mjs oldu ve hedefi process.env.BOOKSTORE_API_URL'den alıyor. angular.json'daki proxyConfig bu dosyayı gösteriyor.

Fixture Yazmak: test.extend

Fixture, testin ihtiyaç duyduğu bir şeyi hazırlayan, teste veren ve sonra toplayan bir fonksiyon: use(...)'dan önceki kod kurulum, sonraki kod teardown. Bookstore'un fixture dosyasına bu yazıda üç fixture eklendi:

// E2E host'unun token endpoint'inden token alıp onu taşıyan bir API context'i açar
async function signedInApi(playwright: PlaywrightWorkerArgs['playwright'], body: object): Promise<APIRequestContext> {
  const anonymous = await playwright.request.newContext({ baseURL: apiURL });
  const { accessToken } = await (await anonymous.post('/e2e/token', { data: body })).json();
  await anonymous.dispose();
  return playwright.request.newContext({ baseURL: apiURL, extraHTTPHeaders: { Authorization: `Bearer ${accessToken}` } });
}

export const test = base.extend<TestFixtures, WorkerFixtures>({
  // Worker başına bir kez: yönetici token'ı yalnız veri hazırlamak için
  adminApi: [
    async ({ playwright }, use) => {
      const api = await signedInApi(playwright, { customerId: 0, roles: ['admin'] });
      await use(api);
      await api.dispose();
    },
    { scope: 'worker' },
  ],
  // Ada adına API çağrıları; storageState'teki token'la aynı müşteri
  customerApi: async ({ playwright }, use) => {
    const api = await signedInApi(playwright, { customerId: 1 });
    await use(api);
    await api.dispose();
  },
  // Test başına yeni bir sipariş; test bitince iptal edilir
  placedOrder: async ({ data, customerApi }, use) => {
    const book = await data.book('Middlemarch', 48.75);
    const response = await customerApi.post('/api/orders', {
      data: { items: [{ bookId: book.id, quantity: 1 }], shippingCity: 'Izmir' },
    });
    expect(response.status(), await response.text()).toBe(201);
    const order: OrderDto = await response.json();

    await use(order);

    const cancelled = await customerApi.post(`/api/orders/${order.id}/cancel`);
    expect(cancelled.status(), 'teardown: cancel the seeded order').toBe(200);
  },
  // data, catalog, cart ve pageErrors önceki gibi
});

Fixture'lar tembel: bir test yalnız parametrelerinde adı geçen fixture'ları ve onların bağımlılıklarını kuruyor. placedOrder isteyen test için zincir adminApi → data → customerApi → placedOrder oluyor. Test scope varsayılan; her test kendi kopyasını alıyor. Worker scope, worker process başına bir kez kuruluyor ve o worker'ın bütün testleri aynı nesneyi paylaşıyor: adminApi için doğru seçim, çünkü yalnız bir token taşıyor ve state tutmuyor. { auto: true } ise adı geçmese de her testte kuruluyor; yukarıdaki pageErrors böyle.

Worker scope'un yanlış kullanımı da kolay. Stok adedi 1 olan bir "paylaşılan kitap"ı worker fixture'ında oluşturup iki testte satın aldırdım. Tek worker'la:

  ✓  3 [chromium] › experiments/demo12.spec.ts:18:3 › first customer buys the shared book (549ms)
  ✘  4 [chromium] › experiments/demo12.spec.ts:18:3 › second customer buys the shared book (141ms)

    Error: {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"Insufficient stock","status":409,"detail":"Book 5 does not have 1 copies in stock.", ...}

    expect(received).toBe(expected) // Object.is equality

    Expected: 201
    Received: 409

İki worker'la ikisi de geçti, çünkü her worker kendi kitabını oluşturdu. Sonuç testlerin worker'lara nasıl dağıldığına bağlı; bu, 8. yazıda anlattığımız türden bir flaky test. State taşıyan test verisi test scope'ta kalmalı.

test.step, Tag ve Annotation

test('the order page shows a seeded order', { tag: '@smoke' }, async ({ placedOrder, page }) => {
  await test.step('open the order page', async () => {
    await page.goto(`/orders/${placedOrder.id}`);
    await expect(page.getByRole('heading', { name: 'Order placed' })).toBeVisible();
  });

  await test.step('check the summary', async () => {
    await expect(page.getByText(placedOrder.reference)).toBeVisible();
    await expect(page.getByRole('definition').nth(1)).toShowPrice(48.75);
    await expect(page.getByRole('definition').nth(2)).toHaveText('TRK-E2E-1');
  });
});

12-html-report-steps-tr.png

HTML raporunda adımlar testin iş akışını anlatan başlıklara dönüşüyor. Görseldeki rapor aşağıda birleştirdiğim shard'lardan üretildi. "Before Hooks" fixture'ların kurulma sırasını, "After Hooks" ters sırada kapanışlarını gösteriyor: placedOrder açılırken 1,3 saniye sürdü (kitap oluşturmak ve sipariş vermek), kapanırken iptal isteği 14 milisaniye. Adımlar trace viewer'da da aynı adlarla görünüyor. test.step(..., { box: true }), hata çıktığında satır numarası olarak adımın içini değil çağrıldığı yeri gösteriyor; page object method'larını sarmak için kullanışlı.

Tag, testin ikinci argümanında { tag: '@smoke' } ya da dizi olarak veriliyor ve --grep, --grep-invert ya da project'in grep'i ile filtreleniyor. Annotation'lar testin nasıl çalışacağını değiştiriyor: test.skip(condition, reason) atlıyor, test.fixme() bilinen bir hata yüzünden hiç çalıştırmıyor, test.slow() timeout'u üçe katlıyor. { annotation: { type: 'issue', description: 'BOOK-142' } } ise rapora iş takip numarası gibi bilgi ekliyor:

  -  1 [chromium] › experiments/demo12c.spec.ts:3:1 › coupon codes are case-insensitive
  -  3 [chromium] › experiments/demo12c.spec.ts:8:1 › the catalog renders on a narrow screen
  ✓  2 [chromium] › experiments/demo12c.spec.ts:14:1 › a slow report export (514ms)

  2 skipped
  1 passed (4.8s)

Reporter'lar ve Shard'ları Birleştirmek

Yerelde list ve html, CI'da blob, junit ve list çalışıyor. junit dosyası CI sisteminin test sekmesi için; blob ise her shard'ın bütün sonuçlarını taşıyan ve sonra tek rapora birleştirilen bir zip. Suite'i iki shard'a bölüp sırayla çalıştırdım ve blob'ları birleştirdim:

$ CI=1 npx playwright test --shard=1/2
Running 12 tests using 5 workers, shard 1 of 2
  12 passed (7.1s)
$ CI=1 npx playwright test --shard=2/2
Running 11 tests using 5 workers, shard 2 of 2
  11 passed (7.9s)
$ npx playwright merge-reports --reporter list ./all-blob-reports
...
  23 passed (15.4s)

21 testlik suite iki shard'da 23 test oldu: setup project'i iki shard'da da çalıştı, çünkü her shard'ın kendi login'ine ihtiyacı var. --reporter html ile aynı blob'lardan tek bir HTML raporu çıkıyor; yukarıdaki görsel o rapordan. CI'da her shard ayrı bir job, blob'lar artifact olarak tek job'da toplanıyor; kurulumu GitHub Actions serisinin Playwright yazısında.

.NET Tarafı: PageTest ve Ayarlar

Microsoft.Playwright'ın test framework'leri için base class paketleri var: Microsoft.Playwright.NUnit, .MSTest, .Xunit ve .Xunit.v3. 6. yazıdaki BrowserFixture'ın elle yaptığını PageTest hazır yapıyor:

// PageTest her test için yeni bir BrowserContext ve Page açar; tarayıcı BROWSER ortam değişkeninden gelir (varsayılan chromium)
public sealed class CatalogPageTests : PageTest
{
    public override BrowserNewContextOptions ContextOptions() => new()
    {
        BaseURL = Environment.GetEnvironmentVariable("BOOKSTORE_WEB_URL") ?? "http://localhost:4200",
        Locale = "en-US",
        TimezoneId = "Europe/Istanbul",
    };

    [Fact]
    public async Task Out_of_stock_book_cannot_be_added()
    {
        await Page.GotoAsync("/");

        ILocator hobbit = Page.GetByRole(AriaRole.Listitem).Filter(new() { HasText = "The Hobbit" });
        await Expect(hobbit.GetByText("Out of stock")).ToBeVisibleAsync();
        await Expect(hobbit.GetByRole(AriaRole.Button, new() { Name = "Add to cart" })).ToBeDisabledAsync();
    }
}

Playwright'ın .NET dokümanı tarayıcı ve launch seçenekleri için bir .runsettings dosyası gösteriyor. Bookstore'un E2E test projesi 1. yazıdan beri Microsoft Testing Platform (MTP; .NET'in yeni test çalıştırma altyapısı) ile çalışıyor ve dotnet test --settings şu cevabı verdi:

Unknown option '--settings'

Bu projede tarayıcıyı BROWSER ortam değişkeniyle seçtim; BROWSER=webkit denemesi, 1.64.1'in WebKit build'i indirilmediği için "Executable doesn't exist" ile kaldı, yani değişken okundu. Chromium'la iki .NET testi 794 milisaniyede geçti.

Playwright Test.NET (Microsoft.Playwright.Xunit.v3)
use: { baseURL, locale, timezoneId }ContextOptions() override'ı
projectsBROWSER değişkeniyle ayrı koşular
test.extend fixture'larıxUnit fixture'ları, constructor injection
webServeryok; servisleri ayrıca başlatmak gerekiyor
retries, --shard, blob raporutest runner'ın ve CI'ın araçları

Üretimde Dikkat

Config'i iki ortama göre ayır ama ikiye bölme. ci değişkeniyle değişen birkaç satır (retry, reporter, forbidOnly, reuseExistingServer, globalTimeout) tek dosyada okunur kalıyor; ayrı bir CI config'i zamanla asıl config'den uzaklaşıyor.

Project sayısını süreyle birlikte düşün. Her tarayıcı project'i bütün suite'i bir kez daha koşturuyor; Bookstore'da WebKit'i ve mobili smoke testleriyle sınırlamak süreyi 21 testte tuttu. Bütün testleri bütün tarayıcılarda gece koşusuna bırakmak yaygın bir denge.

Fixture teardown'ları da hata verebilir ve test geçmiş olsa bile sonucu kırar. placedOrder'ın iptali 30 dakikalık iptal penceresine bağlı; test bir gün daha uzun sürerse teardown 409 alacak. Teardown'ı test kadar dikkatle yaz.

Sık Düşülen Tuzaklar

Worker scope'lu fixture'da state taşıyan test verisini paylaşmak. Yukarıdaki stok örneği tek worker'la kaldı, iki worker'la geçti; sonuç zamanlamaya bağlı.

Yavaşlığı global timeout'u büyüterek gizlemek. Test timeout'unu 30'dan 120 saniyeye çıkarmak takılan bir testi dört kat geç bildirir. actionTimeout ve navigationTimeout vererek takılan adımın kendisini hata mesajına taşı; gerçekten yavaş olan testi test.slow() ile işaretle.

Angular uygulamasında yalnız pageerror'a güvenmek. Global error listener'lar açıkken yakalanmamış hatalar console.error olarak geliyor; tahmin bölümündeki fixture hiçbir şey yakalamadı.

Shard'lı koşuda HTML reporter'ı doğrudan kullanmak. Her shard kendi raporunu üretir ve tek bir resim çıkmaz; CI'da blob ve merge-reports kullan.

Küçük Bir Deneme

★☆☆ playwright.config.ts'teki actionTimeout satırını kaldır ve 10. yazıdaki first() testini click({ timeout }) olmadan çalıştır. Hata mesajı ve süresi nasıl değişti?

★★☆ Nevin Hanım için buyerPage adında bir test fixture'ı yaz: yeni bir browser context'i buyerStorageState ile açıp sayfayı versin, test bitince context'i kapatsın. Toplu sipariş testinden test.use satırını kaldır.

★★★ placedOrder'ın teardown'ını 30 dakikalık pencereye bağımlı olmaktan çıkar: iptal 409 dönerse ne yapmalı? Teardown'ın kendisini test.info().annotations ile rapora not düşecek şekilde değiştir.

Bu yazının fixture'ları API'yi yalnız veri hazırlamak için kullandı. Bir sonraki ve serinin son yazısında APIRequestContext'i doğrudan test aracı olarak ele alıyoruz: API testleri, UI ile API'yi birleştiren hibrit senaryolar ve Playwright Test Agents'ın planner, generator ve healer'ını güvenle kullanmak.