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

Vitest Browser Mode: Angular Testlerini Gerçek Chromium, Firefox ve WebKit'te Çalıştırmak

Mert Özen 14 Kas 2026 11 dk 10 görüntülenme
Vitest Browser Mode: Angular Testlerini Gerçek Chromium, Firefox ve WebKit'te Çalıştırmak

Layout'a bağlı davranışları (IntersectionObserver, @defer on viewport, sticky konum, görünüm) jsdom değil, Vitest browser mode ile gerçek tarayıcıda test et. Bookstore'da jsdom testi geçerken geniş ekranda 30 kitabın yalnız 24'ünü yükleyen sonsuz kaydırma hatasını, ayrı test target'ı, MSW worker'ı, toMatchScreenshot ve Chromium/WebKit koşularıyla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar Angular 22.2 (TypeScript 6.0), Vitest 5.1 (browser mode, @vitest/browser-playwright 5.1), Playwright 1.65, Mock Service Worker 3.0 ve @testing-library/angular 19.6 ile Node.js 24 üzerinde derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un (sitedeki serilerin ortak örnek uygulaması olan online kitapçı) katalog sayfası API'den gelen listeyi DOM'a (tarayıcının sayfayı tuttuğu element ağacı) 12'şer kitap olarak ekliyor. Listenin sonunda 1 piksellik bir "sentinel" element var; görünür olduğunda bir sonraki 12 kitap ekleniyor. Görünürlüğü takip eden directive şöyle:

@Directive({ selector: '[appInViewport]' })
export class InViewport {
  readonly enter = output<void>();

  constructor() {
    const element: HTMLElement = inject(ElementRef).nativeElement;
    const observer = new IntersectionObserver((entries) => {
      if (entries.some((entry) => entry.isIntersecting)) {
        this.enter.emit();
      }
    });
    observer.observe(element);
    inject(DestroyRef).onDestroy(() => observer.disconnect());
  }
}
@if (visibleCount() < books().length) {
  <div class="sentinel" appInViewport (enter)="showMore()"></div>
}

jsdom'da IntersectionObserver olmadığı için test onu vi.stubGlobal ile taklit ediyor ve "liste sonuna gelindi" anını elle tetikliyor:

it('shows twelve more books when the end of the list is reached', async () => {
  let reachEnd = (): void => {};
  vi.stubGlobal(
    'IntersectionObserver',
    class {
      constructor(callback: IntersectionObserverCallback) {
        reachEnd = () => callback([{ isIntersecting: true } as IntersectionObserverEntry], this as never);
      }
      observe(): void {}
      unobserve(): void {}
      disconnect(): void {}
    },
  );
  const { fixture } = await render(CatalogPage, {
    providers: [provideHttpClientTesting()],
    deferBlockBehavior: DeferBlockBehavior.Manual,
  });
  TestBed.inject(HttpTestingController).expectOne('/api/books').flush(catalogOf(30));
  expect(await screen.findAllByRole('heading', { level: 3 })).toHaveLength(12);

  reachEnd();
  await fixture.whenStable();

  expect(screen.getAllByRole('heading', { level: 3 })).toHaveLength(24);
});

Test geçiyor. Katalogda 30 kitap var. 1920×1080 bir monitörde sayfayı açıp en alta kaydıran müşteri kaç kitap görür?

Cevap: 24, Çünkü Sentinel Görünür Kaldı

Aynı senaryoyu Vitest browser mode ile gerçek bir Chromium'da çalıştırdım:

 FAIL   bookstore-web (chromium)  src/app/catalog/catalog-page.browser.spec.ts > CatalogPage in a real browser > shows every book after scrolling to the end on a large screen
AssertionError: expected 24 to be 30 // Object.is equality
Failure screenshot:
  - .vitest/attachments/failure-screenshots/catalog-page.browser.spec.ts/CatalogPage-in-a-real-browser---shows-every-book-after-scrolling-to-the-end-on-a-large-screen.png

Geniş ekranda bir satıra yedi kart sığıyor. İlk 12 kitap iki satır tutuyor ve sentinel daha sayfa açılırken görünür; observer bir kez bildiriyor, 24 kitap oluyor. 24 kitap da ekrana sığıyor ve sentinel görünür kalıyor. IntersectionObserver yalnız kesişme durumu değiştiğinde bildirim gönderiyor; "hâlâ görünür" bir değişiklik olmadığı için üçüncü bildirim hiç gelmiyor. 375×812 ve 800×600 boyutlarında aynı test geçti: kartlar daha az sütuna dizildiği için yeni eklenen kitaplar sentinel'i ekranın dışına itiyor.

jsdom testi bu hatayı yakalayamazdı. Taklit ettiğimiz observer, gerçeğin en önemli davranışını, yani ne zaman bildirim gönderdiğini taşımıyor; test yalnız "bildirim gelirse 12 kitap eklenir" diyor. 5. yazıda mock'ların gerçek davranışı taşımamasından doğan hatanın aynısı, bu kez tarayıcı API'sinde. Düzeltme, gösterilen kitap sayısı her değiştiğinde sentinel'i yeniden gözlemlemek. observe() yeni eklenen bir hedef için o anki durumu bir kez bildiriyor; sentinel hâlâ görünürse bir sonraki 12 kitap da yükleniyor:

export class InViewport {
  readonly appInViewport = input<unknown>();
  readonly enter = output<void>();

  constructor() {
    const element: HTMLElement = inject(ElementRef).nativeElement;
    const observer = new IntersectionObserver((entries) => {
      if (entries.some((entry) => entry.isIntersecting)) {
        this.enter.emit();
      }
    });
    effect(() => {
      // Değer değişince yeniden gözlemle: observe() o anki durumu tekrar bildirir
      this.appInViewport();
      observer.unobserve(element);
      observer.observe(element);
    });
    inject(DestroyRef).onDestroy(() => observer.disconnect());
  }
}

Template'te [appInViewport]="visibleCount()" yazınca browser testi geçti. jsdom testi iki sürümde de yeşildi; onu silmedik, çünkü sayfanın "bildirim gelince 12 kitap ekle" mantığını 43 ms'de test ediyor. Layout'a bağlı kısmı ise artık gerçek tarayıcıda test ediliyor.

jsdom Neyi Göremez?

1. yazıda jsdom ve happy-dom'u karşılaştırırken ikisinin de layout hesaplamadığını gördük: element'lerin yüksekliği 0, getBoundingClientRect() hep sıfır dönüyor. Bunun sonucu olarak jsdom'da şunlar test edilemiyor:

  • Layout ve CSS'in sonucu: element'in görünür olup olmadığı, sticky ya da fixed konumlandırma, media query'ler, bir şeyin başka bir şeyin üstünü örtmesi.
  • Görünürlüğe bağlı API'ler: IntersectionObserver, ResizeObserver, Angular'ın @defer (on viewport) trigger'ı.
  • Gerçek focus ve scroll: scrollIntoView, scroll event'leri, focus'un nereye gittiği.
  • Tarayıcı motoruna özgü davranışlar ve görünüm: Chromium, Firefox ve WebKit farkları, ekran görüntüsü.

Vitest browser mode, aynı Vitest testlerini Node.js yerine gerçek bir tarayıcıda çalıştırıyor. Test dosyası tarayıcıdaki bir iframe'de yükleniyor; DOM, CSS ve Web API'leri gerçek. Tarayıcıyı açıp kapatan bir provider gerekiyor; Bookstore'da Playwright'ı kullanıyoruz.

Kurulum: Ayrı Bir Test Target'ı

Angular dokümanı browser mode için provider paketini ve Playwright'ı kurmayı söylüyor:

npm install --save-dev @vitest/browser-playwright playwright@1.65.0
npx playwright install chromium webkit

Builder'ın browsers seçeneği tarayıcıları açıyor. Bütün suite'i tarayıcıya taşımak istemediğimiz için angular.json'a ikinci bir test target'ı ekledik; jsdom suite'i .browser.spec.ts dosyalarını dışarıda bırakıyor:

"test": {
  "builder": "@angular/build:unit-test",
  "options": {
    "setupFiles": ["src/test-setup.ts"],
    "providersFile": "src/test-providers.ts",
    "runnerConfig": "vitest-base.config.mts",
    "exclude": ["**/*.browser.spec.ts"]
  }
},
"test-browser": {
  "builder": "@angular/build:unit-test",
  "options": {
    "include": ["**/*.browser.spec.ts"],
    "browsers": ["chromiumHeadless"],
    "setupFiles": ["src/test-setup.browser.ts"],
    "providersFile": "src/test-providers.ts",
    "runnerConfig": "vitest-base.config.mts"
  }
}
"test:ci": "ng test --no-watch --no-progress",
"test:browser": "ng run bookstore-web:test-browser --no-watch --no-progress"

Tarayıcı adının sonundaki Headless tarayıcıyı pencere açmadan çalıştırıyor. Angular dokümanına göre varsayılan pencereli çalışmak; CI ortam değişkeni varsa headless'a geçiliyor. Lokalde testin ne yaptığını görmek istersen --browsers chromium ile pencereli çalıştırabilirsin.

Ayrı bir setup dosyası gerekmesinin sebebi ilk koşuda çıktı. jsdom suite'inin test-setup.ts'i @testing-library/jest-dom/vitest'i yüklüyor ve bu paket tarayıcıda yüklenemedi:

Error: Failed to import test file /home/user/bookstore-web/src/test-setup.ts
Caused by: ReferenceError: process is not defined
 ❯ node_modules/picocolors/picocolors.js:1:9

Vitest browser mode toBeInTheDocument, toHaveTextContent, toBeVisible gibi matcher'ları zaten kendisi sağlıyor; test-setup.browser.ts yalnız Türkçe locale verisini kaydediyor.

HTTP için 5. yazıdaki MSW (Mock Service Worker; HTTP isteklerini network katmanında yakalayan library) handler'larını kullanıyoruz. Tarayıcıda MSW bir Service Worker ile çalışıyor; worker dosyası npx msw init public --save ile public/ klasörüne kopyalanıyor ve handler'lar msw/browser'daki setupWorker ile bağlanıyor:

// src/testing/msw-browser.ts
export const worker = setupWorker(...handlers);

export function useMockApiInBrowser(): void {
  beforeAll(() => worker.start({ quiet: true, onUnhandledFrame: 'error' }));
  afterEach(() => worker.resetHandlers());
  afterAll(() => worker.stop());
}

handlers.ts'e /api/books için 30 kitaplık bir katalog ve puan endpoint'i eklendi; jsdom ve tarayıcı testleri aynı sahte API'yi kullanıyor. Sonsuz kaydırma testi:

it('shows every book after scrolling to the end on a large screen', async () => {
  await page.viewport(1920, 1080);
  await render(CatalogPage);
  expect(await screen.findAllByRole('heading', { level: 3 })).not.toHaveLength(0);

  window.scrollTo(0, document.body.scrollHeight);

  await expect.poll(() => screen.getAllByRole('heading', { level: 3 }).length).toBe(30);
});

page, vitest/browser'dan geliyor ve testin çalıştığı iframe'i temsil ediyor; page.viewport() boyutunu değiştiriyor. expect.poll verilen fonksiyonu varsayılan olarak 1 saniye boyunca tekrar çalıştırıp değerin beklenene ulaşmasını bekliyor; asenkron yüklemelerde sleep yazmaya gerek kalmıyor. Testing Library sorguları tarayıcıda da aynı şekilde çalışıyor.

Gerçek Tarayıcıda @defer ve Sticky Sepet

3. yazıda BookCard'daki @defer (on viewport) bloğunu jsdom'da DeferBlockBehavior.Manual ile elle tetiklemiştik, çünkü IntersectionObserver yoktu. Tarayıcıda trigger'ın kendisini test edebiliyoruz: ilk kartın puanı yüklenmeli, ekranın dışındaki 12. kartınki ise kaydırılana kadar yüklenmemeli:

it('loads the ratings of a card only when it scrolls into view', async () => {
  await page.viewport(800, 600);
  await render(CatalogPage, { deferBlockBehavior: DeferBlockBehavior.Playthrough });
  const cards = await screen.findAllByRole('article');

  expect(await within(cards[0]).findByText('4.5 / 5 (12 ratings)')).toBeInTheDocument();
  expect(cards[11]).not.toHaveTextContent('12 ratings');

  cards[11].scrollIntoView();

  expect(await within(cards[11]).findByText('4.5 / 5 (12 ratings)')).toBeInTheDocument();
});

deferBlockBehavior: DeferBlockBehavior.Playthrough satırı önemli. TestBed'in varsayılanı Playthrough olsa da Angular Testing Library'nin (ATL) render()'ı, kaynağına göre, bu seçenek verilmezse Manual kullanıyor. Satır olmadan test 15 saniye bekleyip kaldı; ARIA (ekran okuyucuların kullandığı rol ve isim bilgisi) ağacında kart hâlâ "Ratings" placeholder'ını gösteriyordu.

Katalog sayfasının üstündeki sepet durumu (Cart: 2 books · ₺900,00) position: sticky ile ekranın tepesinde kalıyor. Bu da yalnız layout'la görülebilen bir davranış:

it('keeps the cart status visible while the list scrolls', async () => {
  await page.viewport(800, 600);
  await render(CatalogPage);
  await screen.findAllByRole('article');

  window.scrollTo(0, 1500);

  const cartStatus = page.getByRole('complementary', { name: 'Cart status' });
  await expect.element(cartStatus).toBeInViewport();
  expect(cartStatus.element().getBoundingClientRect().top).toBe(0);
});

expect.element locator'ı alıp assertion geçene kadar tekrar deniyor; toBeInViewport, Vitest dokümanına göre görünürlüğü IntersectionObserver ile ölçüyor. Testin neyi koruduğunu görmek için sayfa component'ine sık yapılan bir değişikliği ekledim: yatay taşmayı gizlemek için :host { display: block; overflow-x: hidden; }. Uygulama derlendi, jsdom suite'i yeşil kaldı, browser testi kaldı:

Error: expect(element).toBeInViewport()
Received element is not in viewport (actual ratio: 0.000):
  <aside
  aria-label="Cart status"
  class="cart-bar"
/>

overflow değeri visible dışında olan bir ata element, sticky element için yeni bir scroll container oluşturuyor; sepet artık pencereye değil, hiç kaymayan component'e yapışıyor ve sayfayla birlikte yukarı kayıp gidiyor.

Görsel Karşılaştırma: toMatchScreenshot

Bazı hatalar yalnız görünüşte: yıldızların rengi, boyutu, hizası. Vitest browser mode'un toMatchScreenshot assertion'ı bir element'in ekran görüntüsünü saklanan referansla karşılaştırıyor. Vitest dokümanı bu API'yi deneysel olarak işaretliyor:

it('fills the stars up to the selected rating', async () => {
  await render(RatingInput, { inputs: { label: 'Rate the book', value: 3 } });

  await expect.element(page.getByRole('group', { name: 'Rate the book' })).toMatchScreenshot('three-of-five');
});

İlk koşuda referans yok; Vitest onu oluşturup testi bilerek kaldırıyor:

Error: expect(element).toMatchScreenshot()
No existing reference screenshot found; a new one was created. Review it before running tests again.

Referans, test dosyasının yanındaki __screenshots__/rating-input.browser.spec.ts/ klasörüne yazılıyor; dosya adı isim, tarayıcı ve işletim sistemi platformundan oluşuyor (three-of-five-chromium-<platform>.png). Görüntüyü kontrol edip commit'lemek gerekiyor. Builder'da Vitest'in --update seçeneğinin karşılığı yok; kasıtlı bir görünüm değişikliğinden sonra referansı silip testi tekrar çalıştırıyoruz.

İki deneme yaptım. Yıldız boyutunu 1.5rem'den 1.25rem'e indirince test kaldı:

Screenshot does not match the stored reference.
Expected image dimensions to be 410×51px, but received 410×46px.

Dolu yıldızın rengini #f5a623'ten #e8a33d'ye çekince ise test geçti. Varsayılan karşılaştırıcı pixelmatch ve renk farkı eşiği (threshold) 0,1; gözle zor ayırt edilen renk kaymalarını bilerek tolere ediyor. Eşiği düşürmek mümkün, ama bu kez font ve anti-aliasing farkları da hata olarak görünmeye başlar.

Bir tuzak da .gitignore'da. Angular 22.2'nin ng new ile ürettiği .gitignore dosyasında __screenshots__/ satırı var. Bu satır dururken referanslar repo'ya hiç girmiyor ve CI'daki her koşu "No existing reference" ile kalıyor. Bookstore'da satırı kaldırdık; başarısız testlerin ekran görüntüleri ve karşılaştırmadaki gerçek ve fark görüntüleri ise .vitest/ altında duruyor, onu ignore ettik.

Farklı Motorlarda Çalıştırmak

Playwright provider'ı Chromium, Firefox ve WebKit'i destekliyor. Hepsini angular.json'a yazmak yerine komut satırından verebilirsin:

npx ng run bookstore-web:test-browser --no-watch --browsers chromiumHeadless --browsers webkitHeadless

Bookstore'un dört browser testi Chromium ve WebKit'te 2,4–2,5 saniyede geçti; WebKit için ayrı bir referans görüntü oluştu. İki referans aynı boyutta (410×51) ama içerikleri aynı değil; motorlar metni ve karakterleri piksel piksel aynı çizmiyor. Referansın tarayıcıya ve platforma göre ayrı tutulmasının sebebi bu. Aynı sebeple referansları CI'ın kullandığı işletim sisteminde, ideal olarak CI'ın kendi container'ında üretmek gerekiyor; Windows'ta üretilmiş bir referansı Linux runner'ı bulamaz, çünkü dosya adında platform var. Firefox için tarayıcı adı firefoxHeadless.

Motor farkı her zaman görselde çıkmıyor. Bizim testlerimizde davranış farkı çıkmadı, ama IntersectionObserver'ın eşik hesapları, form kontrollerinin focus davranışı ve tarih/saat input'ları motorlar arasında farklılaşabiliyor. Her testi üç motorda çalıştırmak yerine, motorlara özgü riski olan birkaç testi seçip yalnız onları çoklu tarayıcıda koşturmak daha ucuz.

Üretimde Dikkat

Süre ve kaynak. Bu makinede dört browser testi tek Chromium'da 1,1–1,6 saniye sürdü; 18 dosyalık jsdom suite'i 67 testle 1,8–2,6 saniye (makinedeki yüke göre değişiyor). Testlerin kendileri hızlı: sonsuz kaydırma testi 106–124 ms, sticky testi 13–18 ms. Asıl maliyet tarayıcının başlaması ve CI'da tarayıcıların kurulup cache'lenmesi. Pipeline'a tarayıcı kurulumu ve Playwright cache'i eklemek GitHub Actions serisinin "GitHub Actions ile Angular CI" yazısının konusu.

mockServiceWorker.js public/ klasöründe olduğu için production build'e de kopyalanıyor. Uygulama onu kaydetmediği sürece bir şey yapmıyor; yine de istemiyorsan production configuration'ında asset'lerden çıkar.

Browser testlerini ayrı bir job olarak çalıştır. jsdom suite'i her commit'te ve watch modunda hızlı geri bildirim veriyor; browser suite'i pull request'te bir kez çalışsa yetiyor.

Sık Düşülen Tuzaklar

Bütün suite'i browser mode'a taşımak. Denemek için jsdom testlerini de test-browser target'ıyla çalıştırdım. 21 dosyanın 18'i geçti; MSW'yi msw/node ile kuran üç dosya tarayıcıda yüklenemedi:

Caused by: TypeError: import_browser_external_node_async_hooks.AsyncLocalStorage is not a constructor
 ❯ node_modules/@mswjs/interceptors/lib/node/net-D5G58O2w.js:1415:18

Node'a özgü kodu ayırmak, vi.useFakeTimers gibi jsdom'da rahat çalışan araçları tarayıcı için gözden geçirmek ve CI'a tarayıcı eklemek gerekiyor; karşılığında layout'a bağlı olmayan testlerde hiçbir şey kazanmıyorsun. Browser mode'u yalnız jsdom'un göremediği davranışlar için kullan.

Tarayıcı API'sini taklit edip gerçek davranışı test ettiğini sanmak. Yazının başındaki stub, IntersectionObserver'ın ne zaman bildirim gönderdiğini bilmiyordu. Taklit ettiğin API'nin zamanlaması ya da koşulları davranışın parçasıysa o testi gerçek tarayıcıda da yaz.

ATL'nin deferBlockBehavior varsayılanını unutmak. render() seçenek verilmezse @defer bloklarını tetiklemiyor; trigger'ı test etmek için Playthrough vermek gerekiyor.

sleep ile beklemek. Tarayıcıda her şey asenkron ve süreler makineye göre değişiyor. expect.element, expect.poll ve Testing Library'nin findBy* sorguları koşul sağlanana kadar bekliyor; sabit süreli bekleme hem yavaş hem kırılgan.

Küçük Bir Deneme

★☆☆ Sticky testine ratio: 1 ekle (toBeInViewport({ ratio: 1 })) ve sepet durumunun tamamının görünür olduğunu doğrula. top: 0 yerine top: -10px yazınca ne oluyor?

★★☆ RatingInput için ikinci bir görsel test yaz: disabled hâlini çek ve referansı incele. Disabled yıldızlar görsel olarak ayırt edilebiliyor mu?

★★★ page.viewport() ile 375×812 (telefon) ve 1920×1080 (geniş ekran) için sonsuz kaydırma testini it.each ile iki kez çalıştır. Düzeltmeyi geri alıp hangi boyutta kaldığını gözle.

Browser mode component'leri gerçek tarayıcıda test etti ama hâlâ tek tek, TestBed içinde. Bir sonraki yazıda derlenmiş uygulamanın tamamını Playwright ile açıyoruz: kitap ara, sepete ekle, checkout; API'yi page.route ile taklit edip login'i storageState ile bir kez yapıyoruz.