Mert Özen Her Satırda Daha İleri
Test ve Kalite Frontend Testleri — #08

Playwright ile Angular E2E: Locator'lar, Auto-Waiting, Network Mocking, storageState ve Trace Viewer

Mert Özen 14 Kas 2026 12 dk 10 görüntülenme
Playwright ile Angular E2E: Locator'lar, Auto-Waiting, Network Mocking, storageState ve Trace Viewer

Angular arayüzünü backend olmadan E2E test etmek için Playwright'ı ng e2e'ye bağla, API'yi otomatik bir page.route fixture'ıyla taklit et ve karşılanmayan her isteği hata say; yoksa geliştirme sunucusu index.html döndürür. Bookstore'un arama, sepet ve checkout akışında rol tabanlı locator'ları, storageState ile tek seferlik girişi, trace'i ve flaky test kaynaklarını gerçek koşularla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar Angular 22.2 (TypeScript 6.0), Playwright 1.65 (@playwright/test 1.65.0) ve playwright-ng-schematics 22.1.4 ile Node.js 24 üzerinde derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un (sitedeki serilerin ortak örnek uygulaması olan online kitapçı) Angular arayüzü için ilk E2E (uçtan uca) testlerini yazıyoruz. Backend yok; API'yi testlerde Playwright'ın page.route'u ile taklit ediyoruz. Checkout testleri bunun için yazılmış api fixture'ını kullanıyor. Katalogdaki sıralamayı test eden yeni test ise fixture'ı parametre olarak istemeyi unutmuş:

import { expect, test } from './fixtures';

test('sorts the catalog by price with the Material select', async ({ page }) => {
  await page.goto('/');

  await page.getByRole('combobox', { name: 'Sort by' }).click();
  await page.getByRole('option', { name: 'Price: low to high' }).click();

  await expect(page.getByRole('article').getByRole('heading')).toHaveText(['The Hobbit', 'Dune']);
});

Testler ng e2e ile çalışıyor; uygulamayı Angular'ın geliştirme sunucusu (ng serve) servis ediyor. /api/books isteğini karşılayan bir mock yok ve 4200 portunda bir API de yok. Sayfa ne gösterir, test nasıl kalır? İstek "connection refused" ile mi düşer?

Cevap: Geliştirme Sunucusu index.html Döndü

Test 5 saniye bekleyip kaldı:

Error: expect(locator).toHaveText(expected) failed

Locator: getByRole('article').getByRole('heading')
Timeout: 5000ms
- Expected  - 4
+ Received  + 1

- Array [
-   "The Hobbit",
-   "Dune",
- ]
+ Array []

Playwright'ın test klasörüne yazdığı error-context.md dosyası sayfanın o anki erişilebilirlik ağacını veriyor ve asıl sebep orada görünüyor:

- complementary "Cart status" [ref=e10]: "Cart: 0 books · Free"
- heading "Catalog" [level=1] [ref=e11]
- searchbox "Search books" [ref=e12]
- combobox "Sort by" [active] [ref=e18]:
  - generic [ref=e19]: "Price: low to high"
- alert [ref=e27]: Could not load the catalog.
- button "Try again" [ref=e28]

İstek düşmedi. Geliştirme sunucusu, tanımadığı her yolu Angular router'ına bırakmak için index.html döndürüyor; /api/books da bunlardan biri. Aynı isteğin cevabını page.waitForResponse ile yazdırdığımda 200 text/html <!doctype html> çıktı. httpResource 200 alıp JSON beklerken HTML görünce hata durumuna geçti ve sayfa "Could not load the catalog." dedi. Taklit edilmemiş bir istek böylece sessizce yanlış bir cevaba dönüşüyor. Daha kötüsü, hata durumunu test eden bir test yanlış sebeple geçebilirdi.

Çözüm iki parçalı: mock'u her testte otomatik kurmak ve karşılanmayan her isteği test sonunda hata saymak. 5. yazıda MSW (Mock Service Worker; HTTP isteklerini network katmanında yakalayan library) için onUnhandledFrame: 'error' ile yaptığımızın Playwright karşılığı:

export const test = base.extend<{ api: MockApi }>({
  api: [
    async ({ page }, use) => {
      const api = new MockApi();
      await page.route('**/api/**', (route) => api.handle(route));
      await use(api);
      expect(api.unhandled, 'API requests without a mock').toEqual([]);
    },
    { auto: true },
  ],
});

{ auto: true } fixture'ı, test onu parametre olarak istemese de her testte çalıştırıyor. use()'dan sonraki satır test bittikten sonra çalışıyor. Bu yazının geri kalanında bu fixture'ın üzerine Bookstore'un "kitap ara, sepete ekle, checkout" akışını kuruyoruz.

Playwright'ın kendisini, yani locator'ları, auto-wait'i, web-first assertion'ları ve trace viewer'ı Integration ve E2E Test serisinin "Playwright'a Giriş" yazısı anlatıyor. Gerçek API ve SQL Server'la full-stack E2E, test verisi ve setup project ile gerçek login o serinin 7. yazısında; flaky testler, page.clock ve görsel karşılaştırma da 8. yazısında. Bu yazıda backend yok: derlenmiş Angular uygulamasını gerçek tarayıcıda açıyoruz, API'yi taklit ediyoruz ve yalnız UI akışını test ediyoruz. Böyle bir test hızlı ve deterministik; ama API ile arayüzün birbirine uyduğunu göstermiyor. Bu ikisi birbirini tamamlıyor.

ng e2e ile Kurulum: playwright-ng-schematics

Angular CLI artık bir E2E aracıyla gelmiyor; ng e2e, e2e target'ı yoksa Playwright için topluluk paketi playwright-ng-schematics'i öneriyor. Bu seride paketi kullanıyoruz, çünkü testleri Angular projesinin bir target'ı olarak çalıştırmak istiyoruz:

npx ng add playwright-ng-schematics@22.1.4
    Adding @playwright/test 1.65.1
CREATE playwright.config.ts (1933 bytes)
CREATE e2e/example.spec.ts (208 bytes)
CREATE e2e/tsconfig.json (64 bytes)
UPDATE angular.json (3176 bytes)
UPDATE package.json (1479 bytes)
UPDATE .gitignore (760 bytes)

angular.json'a eklenen target şu:

"e2e": {
  "builder": "playwright-ng-schematics:playwright",
  "options": {
    "devServerTarget": "bookstore-web:serve"
  },
  "configurations": {
    "production": {
      "devServerTarget": "bookstore-web:serve:production"
    }
  }
}

Builder önce devServerTarget'ı, yani ng serve'ü başlatıyor, sonra playwright test'i çalıştırıyor ve sunucunun adresini PLAYWRIGHT_TEST_BASE_URL ortam değişkeniyle veriyor; testler bitince sunucuyu kapatıyor. Integration ve E2E Test serisindeki webServer ayarına bu yüzden gerek kalmıyor. ng e2e --configuration production ise testleri production build'e karşı çalıştırıyor (Bookstore'da 9 test 4,5 saniye); CI'da tercih edilmesi gereken bu, çünkü kullanıcıya giden kod o.

Schematic @playwright/test'in en son sürümünü ekledi. Biz onu 1.65.0'a sabitledik: 7. yazıdaki browser mode'un kullandığı playwright paketi de 1.65.0 ve her Playwright sürümü kendi tarayıcı build'ini istiyor. İki paket farklı sürümde olursa iki ayrı tarayıcı seti indirmen gerekir. Tarayıcılar bir kez npx playwright install chromium webkit ile kuruluyor.

playwright.config.ts'i Bookstore'a göre sadeleştirdik:

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env['CI'],
  retries: process.env['CI'] ? 2 : 0,
  workers: process.env['CI'] ? 1 : undefined,
  reporter: process.env['CI'] ? [['list'], ['html', { open: 'never' }]] : 'list',
  use: {
    baseURL: process.env['PLAYWRIGHT_TEST_BASE_URL'] ?? 'http://localhost:4200',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], storageState: signedInState },
      dependencies: ['setup'],
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'], storageState: signedInState },
      dependencies: ['setup'],
    },
  ],
});

Builder'ın kendi seçenekleri var; Playwright'ın --grep'i doğrudan geçmedi (Error: Unknown argument: grep). Tek dosyayı tek projede çalıştırmak için npx ng e2e --files e2e/checkout.spec.ts --test-project chromium kullandık; --project adı Angular projesi için ayrıldığından Playwright projesinin seçeneği --test-project.

API'yi page.route ile Taklit Etmek

MockApi, Bookstore API'sinin küçük bir sahte hâli: kitap listesi ve arama, puan, giriş, sipariş verme ve sipariş detayı. Her test kendi örneğini aldığı için testler birbirinin verisini görmüyor:

export class MockApi {
  books: Book[] = [dune, emma, hobbit];
  readonly placedOrders: PlaceOrderRequest[] = [];
  readonly unhandled: string[] = [];
  failOrders = false;

  async handle(route: Route): Promise<void> {
    const request = route.request();
    const url = new URL(request.url());
    const method = request.method();

    if (method === 'GET' && url.pathname === '/api/books') {
      const search = url.searchParams.get('search')?.toLowerCase() ?? '';
      return route.fulfill({ json: this.books.filter((book) => book.title.toLowerCase().includes(search)) });
    }
    if (method === 'POST' && url.pathname === '/api/orders') {
      if (this.failOrders) {
        return route.fulfill({ status: 503, json: { title: 'Service Unavailable', status: 503 } });
      }
      this.placedOrders.push(request.postDataJSON() as PlaceOrderRequest);
      return route.fulfill({ status: 201, json: this.lastOrder() });
    }
    // puan, giriş ve sipariş detayı aynı şekilde

    this.unhandled.push(`${method} ${url.pathname}${url.search}`);
    return route.fulfill({ status: 404, json: { title: 'Not Found', status: 404 } });
  }
}

Tipler (Book, PlaceOrderRequest) ve kitap verisi uygulamanın ve unit testlerin kullandığı dosyalardan import ediliyor; Playwright TypeScript dosyalarını kendisi derliyor. Uygulamadaki bir model değişikliği mock'ta derleme hatası olarak görünüyor. placedOrders, testin API'ye ne gönderildiğini kontrol etmesini sağlıyor; failOrders hata senaryosunu tek satıra indiriyor. page.route'un ne zaman meşru olduğu, gerçek bir backend'le çalışan bir suite'te hangi durumlarda kullanılması gerektiği Integration ve E2E Test serisinin flaky testler yazısında tartışılıyor; burada backend olmadığı için bütün API taklit.

Akışın Testi: Rol Tabanlı Locator'lar

Ana akış, Bookstore'un en değerli yolu: kitabı bul, sepete ekle, siparişi ver:

test('a customer finds a book, adds it to the cart and places an order', async ({ page, api }) => {
  await page.goto('/');

  await page.getByRole('searchbox', { name: 'Search books' }).fill('hobbit');
  const results = page.getByRole('list', { name: 'Books' }).getByRole('article');
  await expect(results).toHaveCount(1);
  await results.getByRole('button', { name: 'Add to cart' }).click();
  await expect(page.getByRole('link', { name: 'Cart (1)' })).toBeVisible();

  await page.getByRole('link', { name: 'Cart (1)' }).click();
  await page.getByRole('link', { name: 'Checkout' }).click();
  await page.getByLabel('Full name').fill('Ada Lovelace');
  await page.getByLabel('Email').fill('ada@example.com');
  await page.getByLabel('City').selectOption('Izmir');
  await page.getByRole('button', { name: 'Place order' }).click();

  await expect(page).toHaveURL('/orders/42');
  await expect(page.getByRole('heading', { name: 'Order BS-1042' })).toBeVisible();
  await expect(page.getByRole('list', { name: 'Order lines' })).toHaveText('1 × The Hobbit');
  expect(api.placedOrders).toEqual([
    { fullName: 'Ada Lovelace', email: 'ada@example.com', city: 'Izmir', lines: [{ isbn: '9780261103344', quantity: 1 }] },
  ]);
});

Arama kutusu 2. yazıdaki 300 ms'lik debounced() ile çalışıyor ama testte bekleme yok. toHaveCount(1) liste tek karta inene kadar tekrar deniyor; zaman ayarı değişse de test değişmiyor. Locator'lar 4. yazıdaki Testing Library sorgularıyla aynı rolleri ve isimleri kullanıyor: searchbox, list "Books", link "Cart (1)". Component testlerinde erişilebilir isimleri düzelttiğimiz için E2E testinde data-testid'ye ihtiyaç kalmadı.

Angular Material'da da durum aynı. 6. yazıda mat-select'i harness ile kullandık; Playwright'ta harness'a gerek yok, çünkü Material select doğru ARIA (ekran okuyucuların kullandığı rol ve isim bilgisi) rollerini veriyor: kapalı hâli combobox "Sort by", açılan paneldeki seçenekler option. Panelin açılma animasyonunu da Playwright'ın actionability kontrolü bekliyor; tıklama element görünür, stabil ve tıklanabilir olunca yapılıyor. Yazının başındaki test, fixture otomatik olunca iki tarayıcıda da geçti.

Login'i Bir Kez Yapmak: storageState

Checkout ve sipariş sayfaları authGuard arkasında; oturum bilgisi Session service'inde, localStorage'daki bookstore.session anahtarında duruyor. Her testte giriş formunu doldurmak yerine girişi bir setup project'inde bir kez yapıp tarayıcı state'ini dosyaya yazıyoruz:

setup('sign in once', async ({ page, api }) => {
  await page.goto('/sign-in');
  await page.getByLabel('Email').fill('ada@example.com');
  await page.getByLabel('Password').fill('not-checked-by-the-mock');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page).toHaveURL('/');

  await page.context().storageState({ path: signedInState });
});

signedInState, playwright/.auth/customer.json. Config'te chromium ve webkit projeleri dependencies: ['setup'] ile önce bu adımı çalıştırıyor ve storageState: signedInState ile her teste girişli bir tarayıcı context'i açıyor. Playwright dokümanına göre storageState cookie'leri ve localStorage'ı saklıyor; IndexedDB isteğe bağlı. sessionStorage ise saklanmıyor. Bookstore oturumu sessionStorage'da tutsaydı testler girişsiz başlar ve guard her birini /sign-in'e gönderirdi; doküman bu durum için addInitScript ile elle geri yükleme öneriyor. Dosya oturum bilgisini taşıdığı için playwright/.auth/ klasörü .gitignore'da.

Girişsiz davranışı test eden dosya state'i boşaltıyor:

test.use({ storageState: { cookies: [], origins: [] } });

test('a signed-out visitor is sent to sign in before checkout', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('article').filter({ hasText: 'Dune' }).getByRole('button', { name: 'Add to cart' }).click();
  await page.getByRole('link', { name: 'Cart (1)' }).click();
  await page.getByRole('link', { name: 'Checkout' }).click();

  await expect(page).toHaveURL('/sign-in?returnUrl=%2Fcheckout');
});

Setup dahil 9 test, 5 worker ile 3,7 saniyede geçti; ng e2e komutunun tamamı, geliştirme sunucusunun build'i dahil, 5,2 saniye.

Kırmızı Testi Okumak ve Flaky Kaynakları

Config'teki trace: 'on-first-retry', ilk denemede trace toplamıyor; test kalıp yeniden denendiğinde topluyor. Böylece geçen testler yavaşlamıyor, kalan testin kaydı elde oluyor. Sipariş satırının beklenen metnini bilerek bozup --retries 1 ile çalıştırınca çıktı şöyleydi:

  ✘  3 [chromium] › e2e/checkout.spec.ts:3:1 › a customer finds a book, adds it to the cart and places an order (6.3s)
  ✘  4 [chromium] › e2e/checkout.spec.ts:3:1 › a customer finds a book, adds it to the cart and places an order (retry #1) (6.4s)
    Error: expect(locator).toHaveText(expected) failed
    Locator:  getByRole('list', { name: 'Order lines' })
    Expected: "2 × The Hobbit"
    Received: "1 × The Hobbit"
    Timeout:  5000ms
    attachment #2: trace (application/zip) ──────────────────────────────
    test-results/checkout-a-customer-finds--b0964-he-cart-and-places-an-order-chromium-retry1/trace.zip
    Usage:
        npx playwright show-trace test-results/checkout-a-customer-finds--b0964-he-cart-and-places-an-order-chromium-retry1/trace.zip

Trace viewer'da her adımın DOM (tarayıcının sayfayı tuttuğu element ağacı) snapshot'ı, network istekleri ve console bulunuyor; okunuşunu Integration ve E2E Test serisinin "Playwright'a Giriş" yazısı anlatıyor. Angular tarafında işe yarayan iki ipucu: page.route ile karşılanan istekler network sekmesinde "fulfilled" olarak görünüyor, gerçek bir sunucuya gitmediğini oradan anlıyorsun; hata çoğu zaman bir önceki adımda oluyor, kalan assertion'dan geriye doğru git.

Flaky testlerin genel sebepleri ve çözümleri Integration ve E2E Test serisinin 8. yazısında. Mock'lanmış bir Angular arayüzünde sık gördüğümüz dört kaynak var:

  • Sıra bağımlılığı. Testler birbirinin bıraktığı state'e dayanırsa paralel çalışınca ya da sıra değişince kalıyor. Her test kendi MockApi örneğini ve kendi tarayıcı context'ini alıyor; fullyParallel: true bu bağımsızlığı her koşuda sınıyor.
  • Animasyonlar. Material panelleri ve dialog'ları açılıp kapanırken element'ler hareket ediyor. Playwright tıklamadan önce element'in stabil olmasını bekliyor; sabit waitForTimeout ekleme. Gerekirse context'te reducedMotion: 'reduce' kullan.
  • Saat ve tarih. Sipariş tarihi, kampanya bitişi, "3 gün içinde kargoda" gibi metinler testin çalıştığı güne bağlıysa test bir gün kırılıyor. Mock'taki tarihleri sabit tut; tarayıcının saatini dondurmak için page.clock.
  • Paylaşılan test verisi. Burada veri her testte sıfırdan oluşuyor; gerçek bir backend'e karşı çalışan suite'te bu en büyük sorun ve Integration ve E2E Test serisinin konusu.

Cypress Ne Zaman Hâlâ Mantıklı?

Cypress npm'de 16.3 sürümünde ve Angular için @cypress/schematic ile ng e2e'ye bağlanabiliyor. Testleri tarayıcının içinde çalıştırıyor; komutlar bir kuyruğa ekleniyor, async/await yazılmıyor. Uygulama adım adım geri sarılıp izlenebilen interaktif runner'ı hâlâ güçlü yanı. Bookstore'da Playwright'ı seçmemizin sebepleri: WebKit desteği (Cypress'te experimentalWebKitSupport ile deneysel), birden çok sekme ve tarayıcı context'iyle çalışabilmesi, Promise tabanlı API'nin TypeScript'le doğal uyumu ve 7. yazıdaki Vitest browser mode'la aynı tarayıcı altyapısı. Ekibin büyük bir Cypress suite'i varsa ve WebKit ihtiyacı yoksa yalnız araç değiştirmek için taşımaya değmez; yeni bir projede Playwright'la başlamak daha mantıklı.

Üretimde Dikkat

E2E testleri az ve değerli tut. Bookstore'da E2E'de yalnız birden çok sayfayı ve gerçek tarayıcı davranışını birlikte gerektiren akışlar var: arama → sepet → checkout, girişsiz checkout, sipariş hatasında sepetin korunması, Material select. Form validasyonu, fiyat hesabı ya da guard'ın ayrıntıları component ve routed feature testlerinde, çok daha hızlı test ediliyor.

CI'da ng e2e --configuration production ile production build'i test et ve CI değişkeniyle retry ve tek worker'ı aç. Tarayıcı kurulumu, cache ve HTML raporu artifact olarak yüklemek GitHub Actions serisinin "GitHub Actions ile Angular CI" yazısının konusu.

Mock'un API'den uzaklaşması burada da risk. Mock tipleri uygulamanın tiplerinden geldiği için alan adı değişiklikleri derlemede yakalanıyor; ama API'nin gerçek davranışı (sıralama, hata kodları) ancak gerçek backend'le görülüyor. Ana akışın bir de gerçek API'yle çalışan sürümü Integration ve E2E Test serisinde.

Sık Düşülen Tuzaklar

page.goto ile sayfalar arasında atlamak. Bir denemede kitabı sepete ekledikten sonra checkout'a page.goto('/checkout') ile gittim. goto sayfayı baştan yüklüyor; CartStore bellekte tuttuğu için sepet boşaldı ve API'ye giden sipariş lines: [] ile gitti:

Error: expect(received).toEqual(expected) // deep equality
- Expected  - 6
+ Received  + 1
- Array [
-   Object {
-     "isbn": "9780441013593",
-     "quantity": 1,
-   },
- ]
+ Array []

Kullanıcının yaptığı gibi link'lere tıkla; goto'yu yalnız akışın başında kullan. Bu deneme bir şeyi daha gösterdi: checkout sayfası boş sepetle sipariş vermeye izin veriyor.

Mock'u unutmak ve geliştirme sunucusunun index.html cevabını görmemek. Yazının başındaki durum: auto fixture ve unhandled kontrolü olmadan taklit edilmeyen istek 200 ile cevaplanıyor.

storageState'in sessionStorage'ı da sakladığını sanmak. Saklamıyor; oturum sessionStorage'daysa addInitScript gerekiyor.

Auth dosyasını commit'lemek. playwright/.auth/ içindeki dosya oturum bilgisini taşıyor; gerçek bir ortama karşı üretildiyse bir hesabı taklit etmek için yeterli.

Küçük Bir Deneme

★☆☆ MockApi'ye kategori filtresini ekle (?category=classics) ve kategori seçildiğinde listenin daraldığını test et. Filtreyi eklemeden önce testi çalıştır: unhandled kontrolü ne raporluyor?

★★☆ Boş sepetle checkout'u engelle: sepet boşsa checkout sayfası formu değil "Your cart is empty." mesajını göstersin. Önce component testini, sonra page.goto('/checkout') ile bir E2E testini yaz. Bu davranışı hangi seviyede test etmek yeterli?

★★★ Setup project'i kaldırıp oturumu test.use({ storageState: { cookies: [], origins: [{ origin, localStorage: [...] }] } }) ile doğrudan ver. Hangi durumda bu yaklaşım yeterli, hangi durumda giriş formunun kendisini test etmen gerekir?

Bu yazıyla Angular 22'nin bugünkü test araçları tamamlandı: Vitest, Testing Library, harness'lar, browser mode ve Playwright. Serinin son yazısında geriye bakıyoruz: Karma ve Jasmine ile yazılmış bir suite'i Vitest'e taşımak ve Angular 12'den 22'ye testte nelerin değiştiği.