İçeriğe geç

Playwright’ta görsel regresyon testi: ekran görüntüsü karşılaştırmayı kararlı hale getirmek

Gözle kontrol etmek bir test stratejisi değil

Bir CSS değişkenini değiştirdiniz, buton dolgusu 12px’ten 8px’e düştü ve bunu üç sprint sonra bir müşteri bildirdi. Fonksiyonel testleriniz yeşildi, çünkü buton hâlâ DOM’daydı, hâlâ tıklanabiliyordu ve tıklanınca doğru isteği atıyordu. Playwright’ın bildiği her şey doğruydu; ekranda görünen şey değildi.

Manuel görsel kontrol ölçeklenmez çünkü maliyeti ekran sayısı × tarayıcı sayısı × release sayısı ile çarpılır ve insan dikkati bu çarpımın altında ilk haftada çöker. Görsel regresyon testi bu boşluğun tamamını değil, net bir hata sınıfını kapatır: CSS kuralı kayması, yanlış veya yüklenmemiş font, flex/grid kırılmasından doğan bozuk layout, overflow: hidden yüzünden kırpılmış metin, z-index sırası bozulmuş overlay, responsive breakpoint’te üst üste binen bileşenler. Hepsinin ortak özelliği şu: DOM yapısı ve erişilebilirlik ağacı değişmeden render sonucu değişir. Locator tabanlı bir assertion bunu tanım gereği göremez — expect(button).toBeVisible() bir pikselin rengine bakmaz.

Görsel regresyonun yakalayamadığı şeyi de baştan söyleyeyim: kullanılabilirlik. Hizalı ve tutarlı görünen bir ekran kötü tasarlanmış olabilir. Bu ayrımın nerede durduğunu kullanılabilirlik ve UI testlerinin otomatikleştirilmesi üzerine yazdığım yazıda ayrıntılandırdım. Görsel regresyon “güzel mi” sorusunu değil, “dün neye benziyorsa bugün ona mı benziyor” sorusunu yanıtlar.

toHaveScreenshot ilk çalıştırmada ne yapar

İlk koşuda beklenen snapshot dosyası yoksa Playwright ekran görüntüsünü diske yazar ve testi fail eder. Bu bilinçli bir tasarım: baseline’ı gözle onaylamadan yeşil sonuç vermez. İkinci koşuda dosya yerindedir ve karşılaştırma başlar.

CI ortamında (process.env.CI set edilmişse) davranış değişir — eksik snapshot yazılmaz, test doğrudan hata verir. Yani baseline’ı CI’da “kazara” üretemezsiniz.

Dosya adlandırma şeması varsayılan olarak şudur: <test-dosyası>-snapshots/<snapshot-adı>-<proje-adı>-<platform>.png. Platform eki process.platform değeridir: darwin, linux, win32. Bu ek, bölümün ilerisinde konuşacağımız platform farkının kök nedenine işaret eder.

// tests/visual/pricing.spec.ts
import { test, expect } from '@playwright/test';

test('fiyatlandırma kartları görsel olarak değişmedi', async ({ page }) => {
  await page.goto('https://example.com/pricing');
  await expect(page.getByTestId('pricing-grid')).toHaveScreenshot('pricing-grid.png');
});

İlk koşu sonrası dosya şurada oluşur:

tests/visual/pricing.spec.ts-snapshots/pricing-grid-chromium-darwin.png

Baseline’ı kasıtlı güncellemek istediğinizde:

# Eksik olanları üret, mevcutları ellemе
npx playwright test --update-snapshots=missing

# Değişenleri yeniden yaz (bilinçli tasarım değişikliği sonrası)
npx playwright test --update-snapshots=changed

--update-snapshots bayrağını düşünmeden çalıştırmak, görsel regresyon testini anlamsızlaştıran bir numaralı davranıştır. Bu bayrağı yalnızca diff raporuna baktıktan sonra kullanın.

Kararsızlığın beş kaynağı

Görsel testler kötü ünlerini hak ediyor ama sebebi yöntem değil, kurulum. Kararsızlığın beş somut kaynağı var:

1. Animasyon ve geçişler. transition: opacity 300ms olan bir modal, screenshot anında %40 opaklıkta yakalanabilir. Playwright ekran görüntüsünü sabit bir “animasyon bitti” sinyaline göre almaz; siz söylemezseniz o anın rastgeleliğini yakalar.

2. Font yükleme yarışı. Webfont yüklenmeden önce tarayıcı fallback font ile render eder (FOUT). Screenshot bu ara duruma denk gelirse metin genişlikleri, satır kırılmaları ve dolayısıyla tüm layout kayar. Tek bir fontun geç gelmesi yüzlerce piksel fark üretir.

3. Dinamik içerik. Bugünün tarihi, “3 dakika önce” biçimli göreli zaman, rastgele avatar, A/B test varyantı, reklam slotu, kullanıcı sayacı. Bunlar her koşuda farklıdır; test bunu regresyon sanır.

4. Lazy-loaded görseller. loading="lazy" taşıyan görseller viewport’a girmeden yüklenmez. Full-page screenshot sırasında Playwright sayfayı kaydırır; bazı görseller yetişir, bazıları yetişmez. Sonuç: aynı testin iki koşusunda farklı görseller.

5. Scrollbar ve viewport farkları. İçerik viewport’u aştığında dikey scrollbar çıkar ve kullanılabilir genişliği daraltır. Scrollbar genişliği platforma ve tarayıcıya göre değişir. Ayrıca deviceScaleFactor farkı aynı sayfayı iki farklı çözünürlükte üretir.

Bu listenin hiçbiri “Playwright güvenilmez” demiyor. Hepsi deterministik olmayan bir ortamda deterministik bir çıktı beklemekten doğuyor. Aynı kök nedenin fonksiyonel testlerdeki karşılığı için flaky testleri teşhis etmek yazısına bakın; teşhis yöntemi birebir aynı.

Animasyonu ve font yüklemesini deterministik hale getirmek

animations: 'disabled' seçeneği CSS animasyonlarını ve transition’ları bitmiş duruma zorlar, caret: 'hide' ise input’lardaki yanıp sönen imleci gizler. İmleci gizlemezseniz, odaklanmış bir alan içeren her snapshot %50 ihtimalle farklı çıkar — bu tek başına testi kullanılamaz hale getirir.

Font tarafında document.fonts.ready beklemek şart ama tek başına yeterli değil: bu promise, henüz talep edilmemiş fontlar için beklemez. Kritik webfont’ları test ortamında preload etmek, yükleme isteğinin erken başlamasını garanti eder.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  expect: {
    toHaveScreenshot: {
      animations: 'disabled',
      caret: 'hide',
      scale: 'css',
    },
  },
  use: {
    viewport: { width: 1280, height: 800 },
    deviceScaleFactor: 1,
  },
});
// tests/fixtures/visual.ts
import { test as base, expect } from '@playwright/test';

export const test = base.extend<{ visualPage: import('@playwright/test').Page }>({
  visualPage: async ({ page }, use) => {
    await page.addInitScript(() => {
      const link = document.createElement('link');
      link.rel = 'preload';
      link.as = 'font';
      link.type = 'font/woff2';
      link.crossOrigin = 'anonymous';
      link.href = '/fonts/inter-latin-400.woff2';
      document.head.appendChild(link);
    });

    await use(page);
  },
});

export async function waitForFonts(page: import('@playwright/test').Page) {
  await page.evaluate(() => document.fonts.ready.then(() => undefined));
}

export { expect };
// tests/visual/header.spec.ts
import { test, expect, waitForFonts } from '../fixtures/visual';

test('başlık bileşeni', async ({ visualPage }) => {
  await visualPage.goto('https://example.com/');
  await waitForFonts(visualPage);
  await expect(visualPage.getByRole('banner')).toHaveScreenshot('header.png');
});

Hangisini atlarsanız ne patlar: animations atlanırsa modal ve dropdown içeren her test rastgele fail eder. caret atlanırsa form ekranları. document.fonts.ready atlanırsa metin yoğun sayfalarda tüm layout kayar ve diff binlerce piksel çıkar. deviceScaleFactor sabitlenmezse Retina ekranlı bir makinede üretilen baseline iki katı çözünürlükte olur ve CI’da boyut uyuşmazlığı hatası alırsınız.

Maskeleme: dinamik içeriği testin dışına çıkarmak

mask seçeneği verilen locator’ları düz bir renkle (varsayılan pembe, maskColor ile değiştirilebilir) kapatır. Kapatılan alan artık karşılaştırmaya girmez.

import { test, expect } from '@playwright/test';

test('sipariş özeti — dinamik alanlar maskeli', async ({ page }) => {
  await page.goto('https://example.com/orders/1');

  await expect(page.getByTestId('order-summary')).toHaveScreenshot('order-summary.png', {
    mask: [
      page.getByTestId('order-date'),
      page.getByTestId('user-avatar'),
    ],
    maskColor: '#000000',
  });
});

Maskelemenin maliyeti şu: maskelediğiniz alan artık test edilmiyor. Avatar’ın yanlış boyutta render edilmesi, tarih alanının taşması, o alandaki bir layout kırılması — hiçbiri yakalanmaz. Maskeyi genişlettikçe testin kapsamı daralır ve bir noktada yeşil kalan ama hiçbir şey doğrulamayan bir snapshot’a dönüşür.

Bu yüzden tercih sırası nettir: önce veriyi sabitle, sabitlenemiyorsa maskele. Tarih ve göreli zaman için page.clock, API’den gelen rastgele içerik için page.route ile sabit yanıt:

import { test, expect } from '@playwright/test';

test('sipariş özeti — veri sabitlenmiş', async ({ page }) => {
  await page.clock.setFixedTime(new Date('2026-01-15T10:00:00Z'));

  await page.route('**/api/orders/1', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        id: 1,
        createdAt: '2026-01-10T08:30:00Z',
        total: 249.9,
        currency: 'TRY',
        user: { name: 'Test Kullanıcı', avatarUrl: '/static/avatar-fixture.png' },
      }),
    });
  });

  await page.goto('https://example.com/orders/1');
  await expect(page.getByTestId('order-summary')).toHaveScreenshot('order-summary.png');
});

İkinci yaklaşımda maske yok, yani tarih alanının hizası ve avatar’ın boyutu da test ediliyor. Genel kural: kaynağı kontrol edebiliyorsanız sabitleyin; kontrol edemediğiniz üçüncü taraf içerik (reklam iframe’i, harita tile’ı) için maskeleyin. Test verisini testin kendisine ait kılmanın neden zorunlu olduğunu ayrı bir yazıda ele almıştım; görsel testlerde bu zorunluluk daha da keskin.

Threshold ayarını gerçek sayılarla yapmak

Üç seçenek var ve ikisi piksel sayar, biri renk ölçer:

  • threshold: Bir pikselin “farklı” sayılması için gereken renk mesafesi. YIQ renk uzayında 0–1 arası bir değer, varsayılan 0.2. Bu bir piksel sayısı değil, piksel başına renk toleransıdır.
  • maxDiffPixels: Kaç tane farklı pikselin tolere edileceği. Mutlak sayı.
  • maxDiffPixelRatio: Aynı şey ama toplam piksele oran olarak. 0–1 arası.
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  expect: {
    toHaveScreenshot: {
      animations: 'disabled',
      caret: 'hide',
      threshold: 0.2,
      maxDiffPixels: 100,
    },
  },
});
// Yalnızca antialiasing gürültüsü beklenen, metin yoğun bir bileşende
await expect(page.getByTestId('article-body')).toHaveScreenshot('article-body.png', {
  maxDiffPixelRatio: 0.002,
});

Buradaki kritik nokta şu: tolerans yükseltmek bir çözüm değil, sorunu saklamaktır. maxDiffPixelRatio: 0.05 yazan bir test, 1280×800’lük bir snapshot’ta 51.200 pikselin değişmesini görmezden gelir — bu, ekranın tamamına yayılmış bir buton kaymasını rahatça yutacak bir alandır. Test yeşil kalır, regresyon üretime gider.

Doğru yaklaşım: test kararsızsa toleransı değil kaynağı düzeltin. Diff raporuna bakın, farkın nerede olduğunu görün. Fark metin kenarlarındaki ince gri halkalarsa antialiasing’dir ve küçük bir maxDiffPixels değeriyle geçilebilir. Fark bir bloğun tamamen kaymasıysa bu zaten yakalanması gereken şeydir. İkisini aynı ayarla çözmeye çalışmak testin amacını yok eder.

Tam sayfa mı, bileşen mi: karşılaştırma kapsamını seçmek

Full-page snapshot’un bakım maliyeti katlanarak artar çünkü sayfadaki her bileşen o dosyanın paydaşıdır. Footer’daki telif yılını güncellediniz: ana sayfa, fiyatlandırma, blog listesi, iletişim — hepsinin baseline’ı kırmızıya döner. Hiçbiri gerçek bir regresyon değildir ama hepsini elden geçirmeniz gerekir. Bir süre sonra ekip --update-snapshots çalıştırmayı refleks haline getirir ve test ölür.

Bileşen bazlı locator snapshot’ları daha uzun ömürlüdür çünkü değişimin yarıçapı bileşenle sınırlıdır. Buton stili değişince yalnızca buton snapshot’u kırılır ve bu tam olarak istediğiniz sinyaldir.

// tests/visual/design-system.spec.ts
import { test, expect } from '@playwright/test';

const components = ['button-primary', 'button-secondary', 'input-text', 'badge-status'];

test.describe('tasarım sistemi bileşenleri', () => {
  for (const name of components) {
    test(name, async ({ page }) => {
      await page.goto(`https://example.com/storybook/${name}`);
      await page.evaluate(() => document.fonts.ready.then(() => undefined));
      await expect(page.getByTestId('component-root')).toHaveScreenshot(`${name}.png`);
    });
  }
});

Full-page hâlâ mantıklı olduğu iki yer var: pazarlama landing page’leri (tek amacı görünmek olan, bileşen değil kompozisyon satan sayfalar) ve print/PDF layout’ları (sayfa kırılımı ve kenar boşlukları ancak bütünde doğrulanabilir).

Snapshot kapsamı seçimi: Full-page ve Bileşen (locator) seçenekleri Bakım maliyeti, Sinyal keskinliği ve Uygun olduğu yer kriterlerine göre karşılaştırılıyor. Full-page: Bakım maliyeti Katlanarak artar, Sinyal keskinliği Düşük ve Uygun olduğu yer Landing page, print layout. Bileşen (locator): Bakım maliyeti Bileşenle sınırlı, Sinyal keskinliği Yüksek ve Uygun olduğu yer Tasarım sistemi, kritik ekran.

Platform farkı: yerelde geçen test CI’da neden patlıyor

macOS ve Linux aynı fontu aynı piksellerle çizmez. CoreText ile FreeType farklı hinting ve rasterizasyon algoritmaları kullanır; macOS’ta subpixel antialiasing ve farklı gamma düzeltmesi devrededir. Aynı 14px metin, iki sistemde birkaç piksel farklı genişlikte ve kenarları farklı tonlarda çıkar. Buna GPU/software rendering farkı, yüklü sistem fontlarının farklılığı ve emoji font setinin farklı olması eklenir.

Tipik görünüm şudur: yerelde üretilmiş bir baseline, Linux CI’da metin kenarlarındaki antialiasing farkı yüzünden binlerce piksellik diff üretir — kod değişmemiş olmasına rağmen. Bunu tolerans yükselterek çözmeye çalışmak, önceki bölümde anlattığım tuzağa düşmektir.

Tek doğru çözüm: baseline’ı CI’ın koştuğu ortamın birebir aynısında üretmek. Playwright resmi Docker imajı bunu sağlar.

Geliştirici makinesi → Playwright Docker imajı → baseline üret → baseline repoda → CI koşusu aynı imaj → piksel farkı yok.
# Baseline'ı Docker içinde üret (macOS/Windows'tan çalıştırılır)
docker run --rm \
  -v "$(pwd)":/work \
  -w /work \
  --ipc=host \
  mcr.microsoft.com/playwright:v1.56.0-noble \
  npx playwright test --update-snapshots=missing
{
  "scripts": {
    "test:visual": "playwright test tests/visual",
    "test:visual:docker": "docker run --rm -v \"$PWD\":/work -w /work --ipc=host mcr.microsoft.com/playwright:v1.56.0-noble npm run test:visual",
    "test:visual:update": "docker run --rm -v \"$PWD\":/work -w /work --ipc=host mcr.microsoft.com/playwright:v1.56.0-noble npm run test:visual -- --update-snapshots=changed"
  }
}

İmaj etiketini package.json‘daki Playwright sürümüyle kilitleyin. Sürüm sürüklenmesi, Chromium güncellemesi üzerinden sessizce yeni piksel farkları getirir.

CI’da baseline yönetimi ve inceleme akışı

Baseline’lar repoda tutulmalı. Artifact olarak saklamak ilk bakışta repo boyutunu korur ama diff’i code review’a getiremezsiniz; “bu görsel değişikliği kim onayladı” sorusunun cevabı git geçmişinde olmaz. Baseline bir kaynak dosyadır ve PR’da incelenmelidir.

Baseline güncellemesini otomatikleştirmek — örneğin CI’ın fail durumunda snapshot’ları yeniden yazıp commit atması — görsel regresyon testini tamamen işlevsiz bırakır. Çünkü her regresyon, tanım gereği, yeni baseline olarak kabul edilir. Test hiçbir zaman kırmızı olmaz ve hiçbir zaman bir şey söylemez. Güncelleme her zaman bir insanın diff’e bakıp onaylamasıyla olmalı.

PR açılınca CI görsel testleri koşar; yeşilse merge, kırmızıysa diff incelenip kod düzeltilir ya da baseline güncellenir.
# .github/workflows/visual.yml
name: Görsel Regresyon

on:
  pull_request:
    branches: [main]

jobs:
  visual:
    runs-on: ubuntu-latest
    container:
      image: mcr.microsoft.com/playwright:v1.56.0-noble
      options: --ipc=host
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci

      - name: Görsel testleri koş
        run: npx playwright test tests/visual --update-snapshots=none

      - name: Diff raporunu yayınla
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: visual-diff-report
          path: |
            playwright-report/
            test-results/
          retention-days: 14

--update-snapshots=none bayrağı, CI’ın hiçbir koşulda baseline yazmamasını garanti eder. if: failure() koşuluyla yayınlanan artifact, HTML raporu ve test-results altındaki *-actual.png / *-diff.png dosyalarını içerir; inceleyen kişi tarayıcıda yan yana görür.

Görsel regresyonun mesai maliyeti ve nerede durmalı

Her bileşeni snapshot’lamak ters teper. Nedeni basit: görsel testin bakım maliyeti, testin sayısıyla değil, kapsadığı alanın değişim sıklığıyla doğru orantılıdır. Haftada üç kez stili değişen bir bileşenin snapshot’u haftada üç kez kırmızıya döner ve üçü de gerçek regresyon değildir. Bu, boy boy “incele ve onayla” mesaisi üretir; ekip kısa sürede onaylamayı otomatikleştirmek ister ve test ölür.

Görsel teste değen ekranlar:

  • Tasarım sistemi bileşenleri. Stabil, çok yerde kullanılır, bozulduğunda etkisi geniştir. En yüksek getiri burada.
  • Kritik dönüşüm ekranları. Ödeme formu, kayıt akışı, fiyatlandırma. Buradaki bir layout kırılması doğrudan gelir kaybıdır.
  • Responsive breakpoint’ler. Tek bir kritik ekranın 3 viewport’ta snapshot’ı, 20 ekranın tek viewport’ta snapshot’ından daha değerlidir.

Değmeyenler:

  • Sürekli yeniden tasarlanan ekranlar. Baseline, kod tamamlanmadan eskiyor.
  • İçeriği tamamen dinamik, kontrol edilemeyen sayfalar (akış, feed, arama sonuçları). Sabitlemek için kurmanız gereken mock katmanı, testin değerinden pahalı.
  • Fonksiyonel testle zaten kapsanan davranışlar. Snapshot bir assertion yerine geçmez.

Yeni kurulan bir görsel suite’te ilk hafta tipik olarak yanlış pozitif sağanağı görülür ve bunların büyük çoğunluğu üç kaynaktan gelir: font yükleme, animasyon ve dinamik tarih. Bu üçünü kurulum aşamasında kapatırsanız kalan gürültü yönetilebilir boyuta iner. Yanlış pozitif oranı düşmüyorsa sorun yöntemde değil, henüz kapatılmamış bir determinizm kaynağındadır — tolerans yükseltmeden önce diff raporunu okuyun.

Bundan sonra ne yapacaksın

Görsel regresyonu mevcut suite’e tek seferde değil, sırayla sok. İlk hafta planı:

1. gün — Kapsamı seç. Tek bir ekran belirle: en kritik dönüşüm ekranın ya da tasarım sistemindeki 4–5 temel bileşen. Daha fazlasıyla başlama.

2. gün — Determinizmi kur. playwright.config.ts‘e animations: 'disabled', caret: 'hide', sabit viewport ve deviceScaleFactor: 1 ekle. Font bekleme yardımcısını yaz. Dinamik alanlar için page.clock ve page.route ile veriyi sabitle; sabitleyemediğin üçüncü taraf içeriği maskele.

3. gün — Docker baseline’ı üret. package.json‘a test:visual:docker ve test:visual:update scriptlerini ekle, imaj etiketini Playwright sürümüne kilitle. Baseline’ları Docker içinde üret ve repoya commit’le. Yerelde de bundan sonra hep o imajla koş.

4. gün — Aynı testi 20 kez koş. --repeat-each=20 ile kararlılığı ölç. Bir tane bile fail varsa toleransı yükseltme; diff’e bak ve kaynağı bul.

5. gün — CI’ı bağla. Workflow’u --update-snapshots=none ile ekle, if: failure() koşullu diff artifact’ını aç. Ekibe artifact’ı nasıl indireceğini göster.

İki sprint boyunca bu tek ekranı izle. Yanlış pozitif sayısı sıfıra yaklaştıysa kapsamı genişlet; yaklaşmadıysa genişletme, determinizm eksiğini bul. Araç seçimi hâlâ masadaysa Playwright, Cypress ve Selenium’u gerçek kriterlerle karşılaştırdığım yazı işini kolaylaştırır.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir

Bu site istenmeyenleri azaltmak için Akismet kullanır. Yorum verilerinizin nasıl işlendiğini öğrenin.