Playwright Assertion Rehberi: Web-first expect Matcher'ları, Soft Assertion, expect.poll, toPass, Snapshot ve Custom Matcher
Değeri textContent() ile alıp toBe ile karşılaştıran bir assertion tekrar denemez; Bookstore'da kupon sonrası toplamı eski değerle okuyup kalıyor. Web-first matcher kataloğunu, toHaveText ile toContainText farkını, expect.soft, expect.poll ve toPass'i, toMatchAriaSnapshot'ı ve para biçimini tek yerde tutan custom matcher'ı 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'da (bir online kitapçı) sepette iki Dune ve bir Emma var: ara toplam 169,30 TL. Test %10'luk bir kupon uyguluyor ve toplamı kontrol ediyor:
await cart.applyCoupon(coupon);
expect(await cart.total.textContent()).toBe('Total: ₺152.37');
applyCoupon, kupon kutusunu dolduruyor ve "Apply" düğmesine basıyor; ikisi de await'li. Kupon geçerli, beklenen tutar da doğru: 169,30 − 16,93 = 152,37. Test geçer mi?
Cevap: Generic Assertion Bir Kez Bakar
Error: expect(received).toBe(expected) // Object.is equality
Expected: "Total: ₺152.37"
Received: "Total: ₺169.30"
Kupon, Angular'ın API'ye attığı bir HTTP isteğiyle doğrulanıyor ve toplam ancak cevap gelince değişiyor. click düğmeye basılınca dönüyor; textContent() o anki metni okuyor, toBe da bu string'i bir kez karşılaştırıyor. Değer henüz eski. Aynı kontrolün doğru hâli tek satır:
await expect(cart.total).toHaveText('Total: ₺152.37');
6. yazı bu ayrımı count() üzerinden gösterdi: expect'e bir değer verirsen generic assertion, bir locator ya da page verirsen web-first assertion kurulur. Generic olanlar (toBe, toEqual, toMatchObject, toContain…) Jest'ten tanıdığın, bir kez çalışan matcher'lar. Web-first olanlar koşul sağlanana ya da timeout dolana kadar locator'ı tekrar değerlendiriyor. Bu yazı ikinci grubun kataloğunu, tekrar eden kontrolleri (expect.soft, expect.poll, toPass) ve kendi matcher'ını yazmayı Bookstore'un sepeti üzerinden anlatıyor.
Web-First Matcher Kataloğu
Locator ve page için gelen matcher'ları üç gruba ayırmak işe yarıyor: durum (görünür mü, enabled mı), içerik (metin, değer, attribute, sayı) ve sayfa (adres, başlık). Hepsinin .NET karşılığı aynı adın Async sonekli hâli; aşağıdakiler Microsoft.Playwright 1.64.1'e karşı derlendi:
| Playwright Test | .NET | Ne zaman |
|---|---|---|
toBeVisible, toBeHidden | ToBeVisibleAsync, ToBeHiddenAsync | element görünüyor ya da yok |
toBeEnabled, toBeDisabled, toBeChecked | ToBeEnabledAsync, ToBeDisabledAsync, ToBeCheckedAsync | form element'inin durumu |
toHaveText, toContainText | ToHaveTextAsync, ToContainTextAsync | metnin tamamı ya da bir parçası |
toHaveValue, toHaveAttribute | ToHaveValueAsync, ToHaveAttributeAsync | input değeri, attribute |
toHaveCount | ToHaveCountAsync | eşleşen element sayısı |
toHaveURL, toHaveTitle | ToHaveURLAsync, ToHaveTitleAsync | sayfa adresi ve başlığı |
toMatchAriaSnapshot | ToMatchAriaSnapshotAsync | accessibility tree'nin yapısı |
expect.soft, expect.poll, toPass, expect.extend | yok | .NET'te test framework'ünün araçlarıyla |
En sık karıştırılan ikili toHaveText ve toContainText. String verdiğinde toHaveText element'in metninin tamamını, toContainText bir parçasını arıyor. İkisi de boşlukları ve satır sonlarını normalize ediyor; ignoreCase ve useInnerText seçenekleri var. Fark gerçek koşuda şöyle görünüyor:
await expect(cart.total).toContainText('₺152.37');
await expect(cart.total).toHaveText('₺152.37', { timeout: 2_000 });
Error: expect(locator).toHaveText(expected) failed
Locator: getByText(/^Total:/)
Expected: "₺152.37"
Received: "Total: ₺152.37"
Timeout: 2000ms
Call log:
- Expect "toHaveText" getByText(/^Total:/) with timeout 2000ms
- waiting for getByText(/^Total:/)
21 × locator resolved to <p class="total">Total: ₺152.37</p>
- unexpected value "Total: ₺152.37"
İlk satır geçti, ikincisi iki saniye boyunca 21 kez denedi ve kaldı. Varsayılanım toHaveText: metne sonradan eklenen bir kelime (Total (incl. VAT): gibi) testin dikkatinden kaçmasın. toContainText'i değişken bir parçanın yanındaki sabit kısmı kontrol ederken kullanıyorum. Locator birden çok element'e çözülüyorsa ikisi de dizi alıyor: toHaveText element sayısının dizinin uzunluğuna eşit olmasını ve her element'in sırasıyla tam eşleşmesini, toContainText her beklenen parçanın sırasıyla bir element'te bulunmasını istiyor.
Her matcher .not ile tersine dönüyor ve tersini de bekliyor: await expect(page.getByRole('alert')).not.toBeVisible() uyarı kaybolana kadar tekrar deniyor. Varsayılan timeout 5 saniye. Tek bir assertion için { timeout } seçeneği, bütün suite için config'de expect: { timeout }, bir grup assertion için expect.configure({ timeout }) kullanılıyor. .NET tarafında Assertions.SetDefaultExpectTimeout aynı işi görüyor.
Bookstore: Sepet Sayısı ve Toplam
Sepetin bütün ekranını kontrol eden test, katalogdaki üç tıklamadan sonra başlıktaki sayıyı, sepet tablosunu, ara toplamı ve kupon sonrası toplamı doğruluyor:
test('the cart count and total follow the cart and the coupon', async ({ data, catalog, cart, page }) => {
const dune = await data.book('Dune', 64.9);
const emma = await data.book('Emma', 39.5);
const coupon = await data.coupon(10);
await catalog.open();
await catalog.search(data.suffix);
await catalog.addToCart(dune.title);
await catalog.addToCart(dune.title);
await catalog.addToCart(emma.title);
await expect(page.getByRole('link', { name: 'Cart (3)' })).toBeVisible();
await cart.open();
await expect(page).toHaveURL('/cart');
await expect(page).toHaveTitle('Cart | Bookstore');
await expect(cart.lines).toHaveCount(2);
await expect(cart.lines).toContainText([dune.title, emma.title]);
await expect(cart.subtotal).toHaveText('Subtotal: ₺169.30');
await expect(page.getByRole('table')).toMatchAriaSnapshot(`
- table:
- rowgroup:
- row "Book Quantity Price"
- rowgroup:
- row /Dune .* 2 ₺129\\.80/
- row /Emma .* 1 ₺39\\.50/
`);
await cart.applyCoupon(coupon);
// Kupon HTTP isteğiyle doğrulanır; toplam cevap gelince değişir, assertion o anı bekler
await expect(cart.total).toHaveText('Total: ₺152.37');
await expect(cart.total).toShowPrice(152.37);
await expect(page.getByLabel('Coupon code')).toHaveValue(coupon);
});
cart.lines ve cart.subtotal, 7. yazıdaki CartPage'e bu yazıda eklenen iki locator: başlık satırını dışarıda bırakmak için hücre içeren satırları alıyor. toHaveURL('/cart') göreli adresi config'deki baseURL'e göre tamamlıyor.
toMatchAriaSnapshot 1.49'da geldi ve tablonun accessibility tree'deki yapısını YAML (girintiyle yazılan düz metin veri biçimi) olarak karşılaştırıyor. Şablon kısmi: satırların çocuklarını yazmadım, yine de eşleşiyor. Kitap adları her testte benzersiz ek taşıdığı için satır adlarını regex'le yazdım. Şablonu sıfırdan yazmak yerine await locator.ariaSnapshot()'un çıktısını yazdırıp sadeleştirmek daha hızlı; Bookstore'da çıktı tablonun üç satırını, sütun başlıklarını ve hücreleri listeledi. Görsel karşılaştırma (toHaveScreenshot) 8. yazıda anlatıldı; aria snapshot ondan farklı olarak piksele değil yapıya bakıyor ve platform farkından etkilenmiyor.
Soft Assertion: Bir Hatada Durmamak
Sipariş sayfası bir özet gösteriyor: durum, tutar, kargo takip numarası. Bu alanların her biri ayrı bir bilgi; birindeki hata ötekileri kontrol etmeye engel olmamalı. expect.soft hata verdiğinde testi durdurmuyor, hatayı kaydediyor ve test sonunda testi başarısız sayıyor:
await expect(page).toHaveURL(/\/orders\/\d+$/);
await expect(page).toHaveTitle('Order | Bookstore');
await expect.soft(page.getByRole('heading', { level: 1 })).toHaveText('Order placed');
await expect.soft(page.getByRole('definition').nth(1)).toShowPrice(129.8);
await expect.soft(page.getByRole('definition').nth(2)).toHaveText('TRK-E2E-1');
Soft assertion'ın bir bedeli var: test devam ediyor ve sonraki adımlar yanlış bir durumda çalışıyor. Ödeme hatasını page.route ile ürettiğim bir denemede sipariş verilemedi, soft assertion kaldı ve test bir sonraki adıma geçti:
Error: expect(locator).toHaveText(expected) failed
Locator: getByRole('heading', { level: 1 })
Expected: "Order placed"
Received: "Cart"
...
TimeoutError: locator.click: Timeout 2000ms exceeded.
Call log:
- waiting for getByRole('button', { name: 'Cancel order' })
Raporda iki hata var ve ikincisi yanıltıcı: "Cancel order" düğmesi gerçekten eksik değil, test sipariş sayfasına hiç gelmedi. Kural olarak sayfanın neresinde olduğunu belirleyen kontrolleri (adres, başlık) normal, aynı sayfadaki bağımsız alanları soft yazıyorum. Soft kontrollerden sonra devam etmek anlamsızsa expect(test.info().errors).toHaveLength(0) satırı testi orada durduruyor.
Tekrar Eden Kontroller: expect.poll ve toPass
Web-first assertion yalnız locator ve page için var. Sayfada olmayan bir değeri, örneğin siparişten sonra API'nin bildirdiği stoğu beklemek için expect.poll bir fonksiyonu matcher geçene kadar tekrar çağırıyor:
// Sayfada olmayan bir değeri, değişene kadar API'den tekrar tekrar oku
await expect
.poll(async () => (await (await adminApi.get(`/api/books/${dune.id}`)).json()).stock, {
message: 'stock after the order',
})
.toBe(8);
Varsayılan timeout 5 saniye, denemeler arası bekleme 100, 250, 500 ve 1000 milisaniye; intervals ile değişiyor. Bookstore stoğu sipariş isteğinin içinde düşürüyor; sayfa sipariş ekranına geçtiğinde değer zaten güncel. Stok güncellemesi bir background job'a ya da bir mesaja taşındığında test değişmeden çalışmaya devam edecek.
toPass ise bir bloğun tamamını tekrar ediyor: birden çok assertion birlikte doğru olmalıysa ya da kontrolden önce bir action'ın da tekrarlanması gerekiyorsa:
// Birden çok kontrol ya da action birlikte tekrar edilecekse toPass; varsayılan timeout'u 0, açıkça ver
await expect(async () => {
const response = await adminApi.get(`/api/books/${dune.id}`);
expect(response.status()).toBe(200);
expect(await response.json()).toMatchObject({ title: dune.title, stock: 8 });
}).toPass({ intervals: [250, 500, 1_000], timeout: 10_000 });
Dokümanın uyardığı ayrıntı önemli: toPass'in varsayılan timeout'u 0 ve config'deki expect timeout'unu dikkate almıyor. Timeout vermezsen blok, test timeout'u dolana kadar tekrar edebilir. Bloğun içinde web-first assertion kullanma: içerideki assertion kendi timeout'unu doldurduktan sonra hata verir, dışarıdaki tekrar da bunun üstüne eklenir. Bu sipariş testinin tamamı 1,6 saniyede geçti.
Kendi Matcher'ın: expect.extend
Bookstore fiyatları Angular'ın currency pipe'ıyla, en-US yerel ayarı ve TL'nin dar sembolüyle gösteriyor: "₺152.37". Testlere "₺" ve nokta ile string yazmak, biçim değiştiğinde yüzlerce satırı değiştirmek demek. Biçimi tek yerde tutan bir matcher:
import { expect as baseExpect, type ExpectMatcherState, type Locator } from '@playwright/test';
// Uygulamanın para biçimiyle aynı: Angular'daki currency pipe (en-US, TRY, symbol-narrow)
const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'TRY', currencyDisplay: 'narrowSymbol' });
export function formatPrice(amount: number): string {
return money.format(amount);
}
export const priceMatchers = {
// Web-first kalır: içerideki toContainText, timeout dolana kadar tekrar dener
async toShowPrice(this: ExpectMatcherState, locator: Locator, amount: number, options?: { timeout?: number }) {
const expected = formatPrice(amount);
const assertion = this.isNot ? baseExpect(locator).not : baseExpect(locator);
try {
await assertion.toContainText(expected, { timeout: options?.timeout ?? this.timeout });
return { pass: !this.isNot, name: 'toShowPrice', message: () => `${locator} shows ${expected}` };
} catch (error) {
const actual = (error as { matcherResult?: { actual?: unknown } }).matcherResult?.actual;
return {
pass: this.isNot,
name: 'toShowPrice',
expected,
actual,
message: () =>
`${this.utils.matcherHint('toShowPrice', undefined, undefined, { isNot: this.isNot })}\n\n` +
`Locator: ${locator}\n` +
`Expected: ${this.isNot ? 'not ' : ''}${this.utils.printExpected(expected)}\n` +
`Received: ${this.utils.printReceived(actual)}`,
};
}
},
};
fixtures.ts artık @playwright/test'in expect'ini değil, baseExpect.extend(priceMatchers)'ın sonucunu dışarıya veriyor; testler aynı import'la yeni matcher'ı da alıyor ve TypeScript onu tanıyor. Matcher kendi içinde web-first bir assertion çağırdığı için tekrar deneme davranışı korunuyor. Yanlış bir tutarla kaldığında çıktı yerleşik matcher'larınkine benziyor:
Error: expect(received).toShowPrice(expected)
Locator: getByText(/^Total:/)
Expected: "₺150.00"
Received: "Total: ₺152.37"
Uygulama bir gün tr-TR yerel ayarına geçerse yalnız Intl.NumberFormat satırı değişiyor: tr-TR ile 1250 tutarı "₺1.250,00" olarak biçimleniyor. .NET tarafında expect.extend'in karşılığı yok; aynı fikir ILocator için bir extension method'la, içeride Expect(locator).ToContainTextAsync çağırarak kuruluyor. Soft assertion için xUnit'in Assert.Multiple'ı, poll ve toPass için de sınırlı bir döngü gerekiyor.
Üretimde Dikkat
Varsayılan expect timeout'unu büyütmeden önce neden beklediğini bul. CI runner'ı yavaşsa 5 saniye dar gelebilir ve config'de 10 saniye makul bir değer; ama tek bir assertion düzenli olarak 5 saniyeye yaklaşıyorsa sorun çoğu zaman uygulamada ya da test verisindedir. Uzun süren tek bir adım için o assertion'a ayrı timeout vermek, bütün suite'in hatalarını yavaşlatmaktan iyidir.
Aria snapshot şablonlarını küçük tut. Bütün sayfanın snapshot'ı her metin değişikliğinde kırılır ve review'da kimse 200 satırlık YAML farkını okumaz. Tablo, menü ya da form gibi yapısı önemli bir bölgeyi seç, değişken metinleri regex'le yaz.
Custom matcher'lar suite'in diline dönüşüyor. toShowPrice gibi, uygulamanın bir kuralını (para biçimi) tek yere toplayanlar değerli. Tek bir testte kullanılan, iki satırlık bir kontrolü saklayan matcher ise okuru tanımına gitmeye zorluyor.
Sık Düşülen Tuzaklar
await'siz expect. Web-first assertion bir promise döndürüyor; await edilmezse satır hemen geçiyor ve kontrol arka planda sürüyor. Denememde Playwright bunu test sonunda yakaladı, call log'un son satırı - Test ended. oldu. Ama hata artık o satırın anına bağlı değil, sonuç zamanlamaya göre değişebilir. eslint-plugin-playwright'ın missing-playwright-await kuralı bunu kod yazılırken gösteriyor.
textContent(), inputValue() ya da isVisible() ile değeri alıp generic assertion yapmak. Tahmin bölümündeki test tam olarak buydu: değer bir kez okunuyor, tekrar deneme yok. Aynı eklentinin prefer-web-first-assertions kuralı bu kalıbı işaretliyor.
Soft assertion'ı her yerde kullanmak. Sayfanın yanlış yerde olduğunu söyleyen bir kontrol soft olursa test devam ediyor ve rapordaki sonraki hatalar asıl nedeni gölgeliyor. Raporu okurken her zaman ilk hatadan başla.
toPass'i timeout'suz bırakmak ya da içine web-first assertion koymak. İlkinde blok test timeout'una kadar döner, ikincisinde her başarısız deneme önce kendi 5 saniyesini doldurur ve toplam süre katlanır.
Küçük Bir Deneme
★☆☆ Sepet testindeki toHaveText('Subtotal: ₺169.30') satırını toContainText('169.30') ile değiştir ve sepete üçüncü bir Dune ekle. Hangi assertion farkı yakaladı, hangisi kaçırdı?
★★☆ toShowPrice'ı .not ile kullanan bir test yaz: kupon uygulandıktan sonra toplamın artık ara toplamı göstermediğini doğrula. this.isNot dalı doğru mesajı veriyor mu?
★★★ Sipariş sayfasındaki üç soft assertion'ı tek bir toMatchAriaSnapshot ile değiştir. Kargo takip numarası ve referans gibi değişen değerleri regex'le yaz ve iki yaklaşımın hata çıktılarını karşılaştır.
Bu yazının testleri adminApi, data, catalog ve cart fixture'larını kullandı, timeout'ları tek tek ayarladı ve setup project'e bağlıydı. Bir sonraki yazıda bunların hepsinin tanımlandığı yere, playwright.config.ts'e ve fixture yazmaya geçiyoruz: project'ler, timeout katmanları, test ve worker scope'lu fixture'lar, test.step, tag'ler ve reporter'lar.