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

Playwright Locator ve Action Rehberi: getByRole'den filter/and/or'a, Frame, Shadow DOM, Upload, Download ve Dialog

Mert Özen 15 Kas 2026 11 dk 6 görüntülenme
Playwright Locator ve Action Rehberi: getByRole'den filter/and/or'a, Frame, Shadow DOM, Upload, Download ve Dialog

Strict mode hatasını first() ile susturmak sorunu çözmez: Bookstore'da stokta olmayan baskıyı seçip timeout'a düşen bir test bunu gösteriyor. getByRole seçeneklerini, filter, and ve or ile daraltmayı, setInputFiles, selectOption ve check gibi action'ları, shadow DOM, iframe, dialog ve download'u kurumsal sipariş ekranı üzerinde gerçek koşularla anlatıyoruz; .NET karşılıkları tabloda.

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

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) aynı kitabın birden çok baskısını satıyor. Test, API'den iki Dune baskısı oluşturuyor: stokta olmayan bir ciltli baskı ve beş adet stoğu olan bir karton kapak. Katalogda ikisi de görünüyor; testin amacı stoktaki baskıyı sepete atmak. 6. yazıda gördüğümüz strict mode hatasını almamak için şöyle yazılmış:

await data.book('Dune Hardcover', 89.9, 0);
await data.book('Dune Paperback', 64.9, 5);
await catalog.open();
await catalog.search(data.suffix);

const dune = catalog.results.filter({ hasText: 'Dune' });
await expect(dune).toHaveCount(2);
await dune.first().getByRole('button', { name: 'Add to cart' }).click({ timeout: 3_000 });

Katalog kitapları başlığa göre sıralıyor. Bu test geçer mi? Kalırsa hangi hatayla kalır?

Cevap: first() Hatayı Susturur, Sorunu Değil

    TimeoutError: locator.click: Timeout 3000ms exceeded.
    Call log:
      - waiting for getByRole('list', { name: 'Search results' }).getByRole('listitem').filter({ hasText: 'Dune' }).first().getByRole('button', { name: 'Add to cart' })
        - locator resolved to <button disabled type="button" class="btn-primary"> Add to cart </button>
      - attempting click action
        2 × waiting for element to be visible, enabled and stable
          - element is not enabled
        - retrying click action
        ...
        6 × waiting for element to be visible, enabled and stable
          - element is not enabled

"Dune Hardcover" alfabetik olarak önce geldiği için first() ciltli baskıyı seçti; düğmesi disabled olduğu için Playwright tıklayabileceği anı bekledi ve süre doldu. Strict mode hatası "iki element buldum, hangisi?" diye soruyordu; first() bu soruyu sıralamaya bırakıyor. Sıralama, veri ya da ekran düzeni değişince test sessizce başka bir element'le çalışıyor. Doğru düzeltme kartı kullanıcının ayırt ettiği şeyle daraltmak:

const inStock = dune.filter({ hasNotText: 'Out of stock' });
await inStock.getByRole('button', { name: 'Add to cart' }).click();

6. yazı locator seçiminin mantığını, auto-wait'i ve trace viewer'ı anlattı. Bu yazı o temelin üstüne günlük test yazarken açtığın API'yi koyuyor: locator ailesinin seçenekleri, daraltma araçları, action'lar, shadow DOM (bir component'in sayfanın element ağacından yalıtılmış kendi ağacı), frame, dialog ve download. Örneklerin hepsi Bookstore'un katalog sayfasından ve yeni kurumsal sipariş ekranından.

Locator Ailesi ve Seçenekleri

getByRole rol ve accessible name'in yanında, ekran okuyucunun duyurduğu durumlarla da eşleşebiliyor: checked, disabled, expanded, pressed, selected ve başlıklar için level. Katalog testinde stokta olmayan baskının düğmesini saymak için ayrı bir filtreye gerek kalmadı:

await expect(dune.getByRole('button', { name: 'Add to cart', disabled: true })).toHaveCount(1);

name varsayılan olarak büyük/küçük harfe duyarsız ve alt metin eşleşmesi yapıyor: { name: 'Cart' }, "Cart (2)" bağlantısını da bulur. Tam eşleşme için exact: true, değişen bir kısım varsa regex veriyorsun; 7. yazıdaki CatalogPage.card başlığı exact: true ile arıyor, çünkü "Dune" araması "Dune Messiah"ı da yakalar. Aynı kural getByText ve getByLabel için de geçerli.

getByLabel form alanını bağlı olduğu label'dan, aria-label'dan ya da aria-labelledby'dan buluyor. getByPlaceholder label'ı olmayan alanlar içindir; placeholder kullanıcı yazmaya başlayınca kaybolduğu için label'ın yerini tutmaz ve bunu testte değil markup'ta düzeltmek gerekir. getByText rolü olmayan metinler için, getByTitle ise title attribute'u için; az sonra iframe'i onunla bulacağız. getByTestId varsayılan olarak data-testid'e bakıyor; ekip başka bir attribute kullanıyorsa config'de use: { testIdAttribute: 'data-qa' } ile değiştirilir.

Daraltmak: Zincirleme, filter, and ve or

Bir locator'ın üstünde locator çağırmak aramayı o element'in içine indiriyor: catalog.results, "Search results" listesinin listitem'ları; onun üstündeki getByRole('button') yalnız o kartların içinde arıyor. filter ise element'in kendisini değil içeriğini sorguluyor. Dört seçeneği var: hasText ve hasNotText metin, has ve hasNot içte başka bir locator arar. 1.51'den beri visible: true da yalnız görünen element'leri bırakıyor. Stoktaki baskıyı bulan testin tamamı:

test('finds the Dune edition that is in stock', async ({ data, catalog, page }) => {
  await data.book('Dune Hardcover', 89.9, 0);
  const paperback = await data.book('Dune Paperback', 64.9, 5);

  await catalog.open();
  await catalog.search(data.suffix);

  const dune = catalog.results.filter({ hasText: 'Dune' });
  await expect(dune).toHaveCount(2);

  // filter, kartın kendisini değil içeriğini sorgular: metin kartın herhangi bir yerinde geçebilir
  const inStock = dune.filter({ hasNotText: 'Out of stock' });
  await expect(inStock.getByRole('heading')).toHaveText(paperback.title);
  await expect(dune.getByRole('button', { name: 'Add to cart', disabled: true })).toHaveCount(1);

  await inStock.getByRole('button', { name: 'Add to cart' }).click();
  await expect(page.getByRole('link', { name: 'Cart (1)' })).toBeVisible();
  await expect(page.getByRole('status')).toHaveText(`${paperback.title} added to cart`);
});

data.suffix, 7. yazıdaki BookstoreData fixture'ının her teste verdiği benzersiz ek; bu yazıda dışarıya açtım. Aramayı bu ekle yapınca katalogda yalnız bu testin oluşturduğu kitaplar kalıyor, başka worker'ların Dune'ları sayıyı bozmuyor.

and, iki locator'ın aynı element'i göstermesini istiyor. İç içe arama değil, kesişim:

await expect(page.getByRole('searchbox').and(page.getByLabel('Search books'))).toBeVisible();

Bu assertion, "Search books" label'ının gerçekten bir arama kutusuna bağlı olduğunu söylüyor. or ise iki locator'dan herhangi birine uyan element'leri topluyor. Kurumsal sipariş ekranında dosya yüklendikten sonra ya önizleme tablosu ya hata mesajı çıkıyor; başarılı ve başarısız senaryoların ortak helper'ı ikisinden birini bekliyordu:

await expect(page.getByRole('table', { name: 'Order lines' }).or(page.getByRole('alert'))).toBeVisible();

20 adetten az kitap içeren dosyada test şöyle kaldı:

    Error: expect(locator).toBeVisible() failed

    Locator: getByRole('table', { name: 'Order lines' }).or(getByRole('alert'))
    Expected: visible
    Error: strict mode violation: getByRole('table', { name: 'Order lines' }).or(getByRole('alert')) resolved to 2 elements:
        1) <table class="cart-lines" aria-label="Order lines">…</table> aka getByRole('table', { name: 'Order lines' })
        2) <p role="alert" class="error">Institutional orders need at least 20 copies.</p> aka getByText('Institutional orders need at')

Ekran tabloyu da hatayı da gösteriyordu. or bir birleşim olduğu için ikisi birden göründüğünde strict mode devreye giriyor. Burada first() doğru araç: helper'ın sorusu "hangi element?" değil, "en az biri göründü mü?". Ayrıntıyı her senaryo kendi assertion'ıyla kontrol ediyor.

const processed = page.getByRole('table', { name: 'Order lines' }).or(page.getByRole('alert'));
await expect(processed.first()).toBeVisible();

Action'lar: Kurumsal Sipariş Ekranı

Bookstore'un Kurumsal Sipariş akışında okul, kütüphane ya da şirket en az 20 adetlik toplu sipariş veriyor ve %10 indirim alıyor; ödeme kartla değil faturayla. Bookstore'a bu yazıda bu akışın ekranı eklendi: satın alma sorumlusu Nevin Hanım Excel'den CSV (virgülle ayrılmış düz metin) olarak kaydettiği dosyayı yüklüyor, kargo firmasının gömülü widget'ından teslimat günü seçiyor, satış koşullarını onaylıyor, siparişi gönderiyor ve faturayı PDF olarak indiriyor. API tarafında iki endpoint var: POST /api/institutional/orders (yalnız institution-buyer rolü) ve GET /api/orders/{id}/invoice. 7. yazıdaki setup project'e Nevin Hanım için ikinci bir storageState eklendi. upload, yukarıdaki or satırlarını içeren helper. Testin tamamı:

test.use({ storageState: buyerStorageState });

function orderFile(rows: [string, number][]): { name: string; mimeType: string; buffer: Buffer } {
  const csv = ['isbn,quantity', ...rows.map(([isbn, quantity]) => `${isbn},${quantity}`)].join('\r\n');
  return { name: 'order.csv', mimeType: 'text/csv', buffer: Buffer.from(csv) };
}

test('a school uploads a bulk order and downloads the invoice', async ({ data, page }, testInfo) => {
  const emma = await data.book('Emma', 39.5, 50);
  const hobbit = await data.book('The Hobbit', 52, 50);

  await page.goto('/institutional/bulk-order');
  await upload(page, orderFile([[emma.isbn, 20], [hobbit.isbn, 10]]));
  await expect(page.getByText('2 lines, 30 copies, 10% institutional discount')).toBeVisible();

  const widget = page.getByTitle('Delivery slot').contentFrame();
  await widget.getByLabel('Delivery day').selectOption('Wednesday afternoon');
  await widget.getByRole('button', { name: 'Reserve slot' }).click();
  await expect(page.getByText('Delivery slot: Wednesday afternoon')).toBeVisible();

  await page.getByLabel('I accept the terms of sale').check();

  // Handler action'dan ÖNCE bağlanır; dialog açıldığında Playwright onu bekler
  let question = '';
  page.once('dialog', (dialog) => {
    question = dialog.message();
    return dialog.accept();
  });
  await page.getByRole('button', { name: 'Submit order' }).click();
  await expect(page.getByRole('status')).toContainText('submitted. Total: ₺1,179.00');
  expect(question).toBe('Submit a bulk order for 30 copies?');

  const downloading = page.waitForEvent('download');
  await page.getByRole('button', { name: 'Download invoice' }).click();
  const download = await downloading;

  expect(download.suggestedFilename()).toMatch(/^invoice-INS-[0-9A-F]{12}\.pdf$/);
  const path = testInfo.outputPath(download.suggestedFilename());
  await download.saveAs(path);
  expect((await readFile(path)).subarray(0, 5).toString()).toBe('%PDF-');
});

setInputFiles diskteki bir dosyanın yolunu ya da bellekte üretilmiş bir içeriği alıyor. Testin verisi her koşuda yeni ISBN'lerle (kitabın uluslararası standart numarası) oluştuğu için dosyayı testin içinde Buffer olarak üretmek, repo'ya sabit bir CSV koymaktan daha doğru. Boş bir dizi vermek seçimi temizliyor. selectOption option'ı görünen metinden ya da value'dan seçiyor, check ise tıkladıktan sonra checkbox'ın gerçekten işaretlendiğini de doğruluyor. Toplam 20 × 39,50 + 10 × 52 = 1.310 TL'nin %10 eksiği.

Testte olmayan action'lar da aynı mantıkla çalışıyor. fill alana değeri tek seferde yazıp input event'ini tetikliyor; neredeyse her zaman doğru seçim bu. pressSequentially karakterleri tek tek yazıyor; yalnız sayfa her tuş vuruşunu ayrıca işliyorsa (otomatik tamamlama, maskeli alan) gerekiyor. hover tooltip ve açılır menüler, dragTo sayfa içi sürükle-bırak için; 1.60 ile gelen drop ise sayfanın dışından dosya sürüklenmiş gibi dragenter, dragover ve drop event'lerini gönderiyor, dosya bırakma alanlarını test etmek için. .NET tarafında karşılıkların hepsi var; aşağıdaki tablo Microsoft.Playwright 1.64.1'e karşı derlendi:

Playwright Test (TypeScript)Microsoft.Playwright (.NET)
filter({ hasText, hasNotText, has, hasNot })Filter(new() { HasText = ..., HasNotText = ..., Has = ..., HasNot = ... })
a.and(b), a.or(b)a.And(b), a.Or(b)
first(), last(), nth(i)First, Last, Nth(i)
getByRole('button', { name, exact, disabled })GetByRole(AriaRole.Button, new() { Name = ..., Exact = true, Disabled = true })
setInputFiles({ name, mimeType, buffer })SetInputFilesAsync(new FilePayload { Name = ..., MimeType = ..., Buffer = ... })
locator.contentFrame()locator.ContentFrame (property)
selectOption, check, fill, pressSequentiallySelectOptionAsync, CheckAsync, FillAsync, PressSequentiallyAsync
page.once('dialog', ...)page.Dialog += ...
page.waitForEvent('download')page.RunAndWaitForDownloadAsync(...)

Shadow DOM ve Frame

10-locator-boundaries-tr.png

Kurumsal sipariş ekranında üç ayrı DOM ağacı var. Dosya seçici, tasarım sistemlerindeki web component'ler gibi kendi shadow root'unda render ediliyor; Angular'da bunu encapsulation: ViewEncapsulation.ShadowDom sağlıyor. Testte page.getByLabel('Order file') bunu hiç fark etmedi. Aynı sayfada üç locator'ın kaç element bulduğunu yazdırdım:

css   #file: 1
xpath //input: 0
label Order file: 1

Playwright'ın locator'ları açık (open) shadow root'ların içine kendiliğinden iniyor; CSS selector'ı da iniyor. İki istisna var: XPath shadow root'a inmiyor, kapalı (closed) shadow root'lar ise hiç desteklenmiyor. Eski bir suite'te shadow DOM'lu bir component'e geçince sessizce sıfır element bulan bir XPath, 6. yazıdaki "CSS ve XPath'e bağlanma" tavsiyesinin bir nedeni daha.

Iframe ise ayrı bir document. page.getByLabel('Delivery day') iframe'deki select'i görmüyor; önce frame'e geçmek gerekiyor. 1.43'ten beri bunun en okunur yolu, iframe element'ini bulan bir locator'dan contentFrame() çağırmak: dönen FrameLocator üzerinde bütün getBy* method'ları aynı. Eski testlerde görülen page.frameLocator('iframe[title="Delivery slot"]') aynı işi CSS ile yapıyor. Widget production'da kargo firmasının kendi origin'inden gelecek; Playwright cross-origin iframe'lerde de aynı API'yle çalışıyor, test bu yüzden değişmiyor.

Dialog ve Download

Playwright, dinleyicisi olmayan alert, confirm ve prompt dialog'larını otomatik olarak kapatıyor (dismiss). confirm için bu "Cancel"a basmak demek. Handler'ı tıklamadan sonra bağlayan bir deneme şöyle bitti:

await page.getByRole('button', { name: 'Submit order' }).click();
page.once('dialog', (dialog) => dialog.accept());

await expect(page.getByRole('status')).toContainText('submitted');
    Error: expect(locator).toContainText(expected) failed

    Locator: getByRole('status')
    Expected substring: "submitted"
    Timeout: 5000ms
    Error: element(s) not found

click döndüğünde dialog açılmış ve kapatılmıştı; uygulama "vazgeçildi" diye siparişi göndermedi. Hata mesajında dialog'dan hiç söz edilmemesi bu tuzağı zorlaştırıyor. Ters yönde de bir kural var: dinleyici bağladıysan dialog'u accept ya da dismiss ile kapatmak zorundasın. Kapatmayan bir handler, dialog sayfayı bloke ettiği için action'ı askıda bırakıyor. page.once yalnız bir sonraki dialog'u yakalıyor; testte beklenmeyen ikinci bir dialog çıkarsa o yine otomatik kapanıyor.

Download için aynı kural tersinden: event'i bekleyen promise action'dan önce oluşturuluyor, sonra tıklanıyor, sonra bekleniyor. Bookstore faturayı token'lı bir istekle blob olarak alıp geçici bir a[download] ile kaydediyor; Playwright için fark etmiyor, download event'i yine geliyor. İndirilen dosya browser context kapanınca siliniyor; saklamak istiyorsan saveAs ile testin çıktı klasörüne kopyala. testInfo.outputPath her teste ayrı bir klasör verdiği için paralel testler birbirinin dosyasını ezmiyor.

Üretimde Dikkat

Yeni testlerle birlikte bütün suite'i beş worker'la çalıştırdım; 14 test 6,3 saniyede geçti. Toplu sipariş testi 1,5 saniye sürdü: iframe yüklenmesi, iki API isteği ve bir dosya indirmesi bir E2E (uçtan uca) testi için ucuz. Asıl maliyet test verisinde. Kurumsal sipariş stok düşürdüğü için her test kendi kitaplarını 50 adet stokla oluşturuyor; seed'deki Dune'u kullanan bir test, paralel çalışan başka bir testin stoğunu tüketebilirdi.

Gömülü üçüncü taraf widget'lar için karar ver: gerçek sağlayıcının test ortamını mı yükleyeceksin, kendi sahte sayfanı mı? Gerçek sağlayıcı yavaş, rate limit'li ve senin kontrolünde olmayan bir değişiklikle testi kırabilir. Bookstore'un widget'ı, 7. yazıdaki WireMock sahteleri gibi, sınırda duran bir taklit; gerçek widget'la sözleşme (postMessage mesajının şekli) ayrıca belgelenmeli.

İndirilen dosyaları CI'da artifact olarak saklamak istersen testInfo.outputPath altına kaydetmek yeterli; Playwright o klasörü test sonuçlarıyla birlikte tutuyor. Fatura gibi içerik doğrulaması gereken dosyalarda yalnız adı değil ilk byte'ları ya da sayfa sayısını kontrol et; boş bir PDF de doğru adla inebilir.

Sık Düşülen Tuzaklar

Strict mode hatasını first() ya da nth(0) ile susturmak. Tahmin bölümündeki test, sıralamanın değişmesiyle bir gün geçip bir gün kalabilirdi. first()'ün meşru olduğu yer, "en az biri" sorusunun sorulduğu yer: yukarıdaki or helper'ı gibi.

CSS ve XPath'e geri dönmek. Shadow DOM'a geçen bir component'te XPath sıfır element buldu; class adları da tasarım sistemi güncellenince değişiyor. Rol, label ya da metinle bulunamayan bir element varsa önce markup'ın erişilebilir olup olmadığına bak, sonra test id ekle.

Dialog handler'ını action'dan sonra bağlamak. Hata mesajı dialog'dan söz etmediği için en geç fark edilen tuzak bu. Bağlantı ile action arasındaki sırayı kural olarak koru: önce page.once('dialog') ya da waitForEvent promise'i, sonra action.

Iframe'deki element'i page üzerinden aramak. page.getByLabel('Delivery day').selectOption(...) denemesi "Timeout 2000ms exceeded" ve tek satırlık bir call log'la (waiting for getByLabel('Delivery day')) bitti; hata mesajında frame'den söz edilmiyor. Bulamadığın bir element için trace viewer'daki DOM snapshot'ında element'in bir iframe'in içinde olup olmadığına bak.

Küçük Bir Deneme

★☆☆ Katalog testindeki filter({ hasNotText: 'Out of stock' }) satırını filter({ has: page.getByRole('button', { name: 'Add to cart', disabled: false }) }) ile değiştir. İki yaklaşımdan hangisi stok metninin çevirisi değiştiğinde de çalışır?

★★☆ FilePicker'ı ViewEncapsulation.ShadowDom yerine kendi içinde attachShadow({ mode: 'closed' }) kullanan bir custom element'e çevir ve toplu sipariş testini çalıştır. Hangi satır, hangi hatayla kaldı?

★★★ Faturayı indiren adımı .NET'e taşı: Bookstore.E2E.Tests projesine Nevin Hanım'ın token'ını AddInitScriptAsync ile localStorage'a yazan bir test ekle ve RunAndWaitForDownloadAsync ile PDF'in ilk byte'larını doğrula.

Bu yazıdaki testlerin her biri birkaç expect satırıyla bitiyor: toHaveCount, toHaveText, toContainText, toBeDisabled. Bir sonraki yazıda bu matcher'ların tam listesine, toHaveText ile toContainText farkına, soft assertion'a, expect.poll ve toPass ile tekrar eden kontrollere ve kendi matcher'ını yazmaya bakıyoruz.