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

Angular Testing Library ile Kullanıcı Gibi Test Etmek: render, Role Sorguları ve user-event

Mert Özen 14 Kas 2026 12 dk 11 görüntülenme
Angular Testing Library ile Kullanıcı Gibi Test Etmek: render, Role Sorguları ve user-event

Kullanıcı gibi test etmek, element'i rol ve etiketle bulup kullanıcının yaptığı sırayla etkileşmek demek: fireEvent.input ile yazılan test, alanın blur'da touched olduğunu atladığı için kaldı, user-event ile yazılan geçti. Bookstore'un Signal Forms checkout formunda render(), sorgu önceliği, aria-describedby ile bağlanan hata mesajları, findBy ile asenkron içerik, routes ile navigasyon ve aynı testin TestBed ile karşılaştırması gerçek koşularla.

Sürüm notu: Bu yazıdaki kodlar Angular 22.2 (TypeScript 6.0), Vitest 5.1, jsdom 30, @testing-library/angular 19.6, @testing-library/user-event 14.6 ve @testing-library/jest-dom 7.0 ile Node.js 24 üzerinde derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un (sitedeki serilerin ortak örnek uygulaması olan online kitapçı) checkout sayfasında Angular 22 ile stable olan Signal Forms'la yazılmış bir form var: ad soyad, e-posta ve şehir. Hata mesajı, kullanıcı alana dokunup çıktıktan sonra görünüyor. Aynı davranış için iki test yazıldı; ikisi de Angular Testing Library'nin render() ve screen API'sini kullanıyor, fark yalnız alanı nasıl doldurdukları:

it('shows an error for an invalid email (fireEvent)', async () => {
  await render(CheckoutForm);

  fireEvent.input(screen.getByLabelText('Email'), { target: { value: 'ada.example.com' } });

  expect(screen.getByText('Enter a valid email address.')).toBeTruthy();
});

it('shows an error for an invalid email (user-event)', async () => {
  await render(CheckoutForm);
  const user = userEvent.setup();

  await user.type(screen.getByLabelText('Email'), 'ada.example.com');
  await user.tab();

  expect(screen.getByText('Enter a valid email address.')).toBeTruthy();
});

İkisi de geçersiz bir e-posta yazıyor ve aynı hata mesajını arıyor. Hangisi geçer? Kalan olursa neden?

Cevap: fireEvent Bir Event, user-event Bir Kullanıcı

fireEvent ile yazılan test kaldı, user-event ile yazılan geçti:

 ×  bookstore-web  src/app/checkout/checkout-form-events.spec.ts > CheckoutForm > shows an error for an invalid email (fireEvent) 27ms
 ✓  bookstore-web  src/app/checkout/checkout-form-events.spec.ts > CheckoutForm > shows an error for an invalid email (user-event) 68ms

TestingLibraryElementError: Unable to find an element with the text: Enter a valid email address.. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

Formun hata mesajını göstermesi için alanın hem geçersiz hem touched olması gerekiyor; Signal Forms'un FormField directive'i alanı blur event'inde touched yapıyor. fireEvent.input yalnız tek bir input event'i gönderiyor: değer forma ulaşıyor, alan geçersiz oluyor ama hiç odak almadığı ve bırakılmadığı için touched değil. user.type ise bir kullanıcının yaptığı sırayı tekrarlıyor: element'e tıklayıp odaklanıyor, her karakter için klavye ve input event'leri gönderiyor. user.tab() odağı bir sonraki alana taşıyor ve e-posta alanında blur oluşuyor. Gerçek kullanıcı da tam olarak bunu yapıyor.

fireEvent ile yazılmış test bir hatayı yakalamadı, sadece yanlış senaryoyu test etti. Bu yazının konusu tam olarak bu: testi component'in iç yapısına göre değil, kullanıcının yaptığına göre yazmak. Angular Testing Library (ATL) ve user-event bunun için var.

Testing Library'nin Yaklaşımı

Testing Library'nin yazarı Kent C. Dodds, kütüphanenin rehber ilkesini 2018'deki bir paylaşımında şu cümleyle anlatmıştı: "The more your tests resemble the way your software is used, the more confidence they can give you." Pratikteki karşılığı üç kural: element'i kullanıcının gördüğü rol, etiket ve metinle bul; kullanıcının yaptığı etkileşimi taklit et; kullanıcının gördüğü sonucu kontrol et. Class adı, component'in field'ları, private method'lar teste girmez.

ATL'nin render() fonksiyonu TestBed'i kendisi kuruyor, component'i oluşturuyor ve ilk change detection'ı çalıştırıyor. Seçeneklerinden Bookstore'da kullandıklarımız:

await render(CheckoutForm, { on: { placeOrder } });
await render(CatalogPage, { providers: [provideHttpClientTesting()] });
await render(CartPage, { routes: [{ path: 'checkout', component: CheckoutForm }] });

inputs input'ları, on output'lara abone olacak fonksiyonları, providers TestBed provider'larını, routes router'ın route'larını veriyor. Sorgular screen üzerinden yapılıyor; screen her zaman document.body'ye bakıyor. ATL'nin kaynak kodunda render()'ın Testing Library'nin eventWrapper ayarını değiştirdiği görülüyor: autoDetectChanges açıkken (varsayılan) her DOM (tarayıcının sayfayı tuttuğu element ağacı) event'inden sonra mount edilmiş bütün fixture'lar için detectChanges çağrılıyor. Bu yüzden user.click ya da user.type sonrasında, 3. yazıdaki gibi await fixture.whenStable() yazmak gerekmiyor.

Kurulum üç paket: @testing-library/angular 19.6 (peer dependency olarak @angular/core >= 21.0.0 istiyor), @testing-library/dom 10.4 ve @testing-library/user-event 14.6. toBeInTheDocument, toBeInvalid, toHaveAccessibleDescription gibi DOM matcher'ları için de @testing-library/jest-dom 7.0'ı kurup 1. yazıda açtığımız setupFiles dosyasına tek satır ekledik:

// src/test-setup.ts
import { registerLocaleData } from '@angular/common';
import localeTr from '@angular/common/locales/tr';
import '@testing-library/jest-dom/vitest';

registerLocaleData(localeTr);

/vitest alt yolu hem matcher'ları Vitest'in expect'ine ekliyor hem de tiplerini getiriyor; bu satır olmadan toHaveAccessibleDescription derleme hatası verdi: TS2339: Property 'toHaveAccessibleDescription' does not exist on type 'Assertion<void, HTMLElement>'.

Sorgu Önceliği ve Erişilebilirlik

Testing Library dokümanı sorguları üç gruba ayırıyor ve bu sırayla kullanmayı öneriyor. Önce herkesin erişebildiği sorgular: getByRole, getByLabelText, getByPlaceholderText, getByText, getByDisplayValue. Sonra semantik sorgular: getByAltText, getByTitle. En son getByTestId. data-testid kullanıcının görmediği bir şey; teste özel bir attribute'a bağlanan test, ekrandaki düğmenin adı değişse ya da düğme kaybolsa bile o attribute durduğu sürece geçer. Bu yüzden son çare.

getByRole ilk sırada, çünkü ekran okuyucunun gördüğü ağaca bakıyor: element'in rolü ve erişilebilir adı. Bunun bir yan etkisi var: erişilebilir adı olmayan bir element'i bu sorgularla bulamıyorsun. Etiketi for ile input'a bağlanmamış bir alanı denediğimde ATL şu hatayı verdi:

TestingLibraryElementError: Found a label with the text of: Email, however no form control was found associated to that label. Make sure you're using the "for" attribute or "aria-labelledby" attribute correctly.

Bu hata aynı zamanda bir erişilebilirlik hatası: ekran okuyucu da bu alanı "Email" diye okuyamıyor. Testi yazmaya çalışırken ortaya çıkan bu tür sorunlar, Testing Library kullanmanın en somut yan faydası. Erişilebilirliği sistematik olarak test etmek (axe ile otomatik kontroller) ilerideki Accessibility (a11y) serisinin konusu.

Checkout formunda hata mesajını input'a aria-describedby ile bağladık ve alan geçersizken aria-invalid koyduk. Böylece test, mesajın ekranda bir yerde olmasını değil, doğru alana ait olmasını kontrol edebiliyor: toHaveAccessibleDescription, alanın erişilebilir açıklamasını okuyor.

Signal Forms ile Checkout Formu

Form component'i, Signal Forms'un form(), FormField ve FormRoot API'leriyle yazıldı. FormRoot <form> element'ine novalidate ekliyor ve submit'te submission.action'ı yalnız form geçerliyse çalıştırıyor:

@Component({
  selector: 'app-checkout-form',
  imports: [FormField, FormRoot],
  template: `
    <form [formRoot]="checkoutForm" aria-labelledby="checkout-title">
      <h2 id="checkout-title">Shipping details</h2>

      <label for="email">Email</label>
      <input
        id="email"
        type="email"
        autocomplete="email"
        [formField]="checkoutForm.email"
        [attr.aria-invalid]="errorFor(checkoutForm.email) ? true : null"
        [attr.aria-describedby]="errorFor(checkoutForm.email) ? 'email-error' : null"
      />
      @if (errorFor(checkoutForm.email); as message) {
        <p id="email-error" class="error">{{ message }}</p>
      }

      <button type="submit">Place order</button>
    </form>
  `,
})
export class CheckoutForm {
  readonly placeOrder = output<CheckoutDetails>();

  private readonly details = signal<CheckoutDetails>({ fullName: '', email: '', city: '' });
  protected readonly checkoutForm = form(
    this.details,
    (path) => {
      required(path.fullName, { message: 'Enter your full name.' });
      required(path.email, { message: 'Enter your email address.' });
      email(path.email, { message: 'Enter a valid email address.' });
      required(path.city, { message: 'Choose a city.' });
    },
    {
      submission: {
        action: async (field) => {
          this.placeOrder.emit(field().value());
        },
      },
    },
  );

  protected errorFor(field: FieldTree<string>): string | undefined {
    const state = field();
    return state.touched() && state.invalid() ? state.errors()[0]?.message : undefined;
  }
}

Template'te ad soyad ve şehir alanları da e-postayla aynı kalıpta; yer kaplamasın diye burada yalnız e-postayı gösteriyorum. Testler:

describe('CheckoutForm', () => {
  it('shows an error after the user leaves an invalid email', async () => {
    await render(CheckoutForm);
    const user = userEvent.setup();

    await user.type(screen.getByLabelText('Email'), 'ada.example.com');
    await user.tab();

    const emailInput = screen.getByRole('textbox', { name: 'Email' });
    expect(emailInput).toHaveAccessibleDescription('Enter a valid email address.');
    expect(emailInput).toBeInvalid();
  });

  it('shows every missing field when an empty form is submitted', async () => {
    const placeOrder = vi.fn();
    await render(CheckoutForm, { on: { placeOrder } });
    const user = userEvent.setup();

    await user.click(screen.getByRole('button', { name: 'Place order' }));

    expect(screen.getByRole('textbox', { name: 'Full name' })).toHaveAccessibleDescription('Enter your full name.');
    expect(screen.getByRole('textbox', { name: 'Email' })).toHaveAccessibleDescription('Enter your email address.');
    expect(screen.getByRole('combobox', { name: 'City' })).toHaveAccessibleDescription('Choose a city.');
    expect(placeOrder).not.toHaveBeenCalled();
  });

  it('places the order with the details the user entered', async () => {
    const placeOrder = vi.fn();
    await render(CheckoutForm, { on: { placeOrder } });
    const user = userEvent.setup();

    await user.type(screen.getByLabelText('Full name'), 'Ada Lovelace');
    await user.type(screen.getByLabelText('Email'), 'ada@example.com');
    await user.selectOptions(screen.getByLabelText('City'), 'Izmir');
    await user.click(screen.getByRole('button', { name: 'Place order' }));

    expect(placeOrder).toHaveBeenCalledExactlyOnceWith({ fullName: 'Ada Lovelace', email: 'ada@example.com', city: 'Izmir' });
  });
});

İkinci test Signal Forms'un submit davranışını kullanıcı gözünden kontrol ediyor: boş form gönderilince submit() bütün alanları touched yapıyor, üç hata birden görünüyor ve placeOrder hiç çağrılmıyor. select element'inin rolü combobox; rolleri ezberlemek gerekmiyor, bir getByRole sorgusu kaldığında hata mesajı sayfadaki rolleri ve adlarını listeliyor.

Testleri yazarken bir şey öğrendim: e-posta alanına "ada@example" yazınca hata çıkmadı. Signal Forms'un email validator'ının kaynağındaki regular expression alan adında nokta şart koşmuyor. Bookstore'un ihtiyacı daha sıkı bir kuralsa bunu pattern ile ya da API tarafında doğrulamak gerekiyor; test, framework'ün ne kabul ettiğini varsaymak yerine gösterdi.

findBy ile Asenkron İçeriği Beklemek

Katalog sayfası kitapları HTTP'den getiriyor. Aynı sayfayı Testing Library ile render edip isteği cevapladıktan hemen sonra getByRole ile kitabı aradım:

TestingLibraryElementError: Unable to find an accessible element with the role "heading" and name "Dune"

Here are the accessible roles:

  heading:

  Name "Catalog":
  <h1 />

  --------------------------------------------------
  searchbox:

  Name "Search books":
  <input
    aria-label="Search books"
    type="search"
  />

Sayfa hâlâ "Loading books…" gösteriyordu. 2. yazıda gördüğümüz gibi resource cevabı bir promise üzerinden işliyor; getBy* ise o anki DOM'a bakıp hemen karar veriyor. Asenkron gelen içerik için findBy* var:

it('shows the books once the catalog arrives', async () => {
  await render(CatalogPage, {
    providers: [provideHttpClientTesting()],
    deferBlockBehavior: DeferBlockBehavior.Manual,
  });
  TestBed.inject(HttpTestingController).expectOne('/api/books').flush([dune, emma]);

  expect(await screen.findByRole('heading', { name: 'Dune' })).toBeInTheDocument();
  expect(screen.queryByRole('heading', { name: 'Emma' })).not.toBeInTheDocument();
});

deferBlockBehavior seçeneği, 3. yazıda kartlardaki @defer (on viewport) blokları için seçtiğimiz Manual modu; render() bunu TestBed'e iletiyor. findBy* bir promise dönüyor ve element görünene kadar sorguyu tekrarlıyor; dokümana göre varsayılan süre 1000 ms. ATL bu bekleme sırasında da detectChanges çağırıyor. Üç ailenin farkı kısaca şöyle: getBy element yoksa hemen hata fırlatır; queryBy yoksa null döner, bu yüzden "görünmüyor" assertion'ları için doğru araç; findBy görünmesini bekler. Stokta olmayan Emma'nın listede olmadığını queryByRole ile kontrol ediyoruz; getByRole ile yazılsaydı assertion'a gelmeden hata fırlatırdı.

routes ile Navigasyon

Sepet sayfasındaki "Checkout" bağlantısının checkout'a götürdüğünü test etmek için render()'ın routes seçeneği yeterli:

it('takes the user to checkout', async () => {
  await render(CartPage, { routes: [{ path: 'checkout', component: CheckoutForm }] });
  TestBed.inject(CartStore).add(dune);
  const user = userEvent.setup();

  await user.click(await screen.findByRole('link', { name: 'Checkout' }));

  expect(TestBed.inject(Router).url).toBe('/checkout');
});

Bağlantı, 3. yazıdaki content projection yüzünden yalnız sepet doluyken görünüyor. Sepeti render()'dan sonra dolduruyoruz, çünkü render() TestBed'i kendisi yapılandırıyor. Önce TestBed.inject(CartStore) yazdığımda test şu hatayla kaldı: "Cannot configure the test module when the test module has already been instantiated." Değişiklik bir event'ten değil signal'dan geldiği için bağlantıyı findByRole ile bekliyoruz. Route parametreleri, guard'lar ve resolver'larla daha kapsamlı routing testleri 5. yazıda RouterTestingHarness ile.

Aynı Test, İki Yaklaşım

Karşılaştırmak için e-posta testini yalnız TestBed ile de yazdım:

it('shows an error after the user leaves an invalid email', async () => {
  const fixture = TestBed.createComponent(CheckoutForm);
  await fixture.whenStable();
  const input: HTMLInputElement = fixture.nativeElement.querySelector('#email');

  input.value = 'ada.example.com';
  input.dispatchEvent(new Event('input'));
  input.dispatchEvent(new Event('blur'));
  await fixture.whenStable();

  expect(fixture.nativeElement.querySelector('#email-error').textContent).toBe('Enter a valid email address.');
  expect(input.getAttribute('aria-describedby')).toBe('email-error');
});

İki test de geçiyor ve aynı davranışı kontrol ediyor. Farkları:

KonuTestBedTesting Library
Element'i bulmak#email, .error gibi selector'larrol, etiket ve metin
EtkileşimdispatchEvent ile tek tek eventuser-event ile kullanıcı sırası
Change detectionher adımda whenStableevent'ten sonra otomatik
Kalan testin mesajıCannot read properties of nullaradığı şey, sayfadaki roller ve DOM
Süre (bu suite'te)3–5 ms14–93 ms
Neyi kırarid ya da class değişikliğigörünen metin ya da erişilebilir ad değişikliği

TestBed testinde blur event'ini elle göndermeyi unutmak, yazının başındaki fireEvent hatasının aynısını yaratıyor; denediğimde test TypeError: Cannot read properties of null (reading 'textContent') ile kaldı ve mesaj sebebi söylemiyordu. Yani test yazarının formun touched mantığını bilmesi gerekiyor. Testing Library testinde bu bilgi gerekmiyor, kullanıcı ne yapıyorsa o yazılıyor. Bookstore'da kullanıcının gördüğü ekranlar (formlar, listeler, sayfalar) için Testing Library'yi, DOM'dan bağımsız davranışlar (output'un hangi değerle çağrıldığı, @defer durumları, signal'lar) ve küçük yapı taşları için TestBed'i kullanıyoruz. İkisi aynı dosyada birlikte durabiliyor; Bookstore'un catalog-page.spec.ts dosyası da öyle.

Üretimde Dikkat

user-event yavaştır ve bu bilinçli bir seçim. "places the order" testi varsayılan ayarla 66–70 ms sürdü; userEvent.setup({ delay: null }) ile 14–15 ms'ye indi. delay, karakterler arasında bir setTimeout beklenip beklenmeyeceğini belirliyor; null bu beklemeyi tamamen kaldırıyor. Yüzlerce form testinde bu fark hissedilir. Ama beklemeyi kaldırmak, debounce gibi zamana bağlı davranışları test eden senaryoları değiştirebilir; Bookstore'da varsayılanı bıraktık.

Erişilebilir isimleri bir sözleşme olarak gör. "Place order" metni değişirse testler kalıyor ve bu doğru: kullanıcı da artık başka bir düğme görüyor. Metinler çeviri dosyalarından gelen çok dilli bir uygulamada testleri tek bir locale'de çalıştırmak ve o locale'in metinlerini kullanmak gerekiyor.

Paket uyumuna bak. ATL'nin sürüm numarası Angular'la aynı değil; 19.6, Angular 21 ve sonrasını destekliyor. Angular'ın major sürümünü yükseltmeden önce ATL'nin peer dependency aralığını kontrol et. Paket ayrıca @testing-library/angular/zoneless adında daha sade bir entry point içeriyor, ancak yazım günü resmi API sayfası bu entry point'i anlatmıyor; Bookstore'da dokümante edilen ana entry point'i kullanıyoruz.

Sık Düşülen Tuzaklar

Asenkron içeriği getBy ile aramak. Yukarıdaki katalog örneğinde olduğu gibi hata, içerik henüz gelmediği için çıkıyor. HTTP, resource ya da timer'dan sonra görünen her şey için findBy kullan.

Her şeyi bir bekleme sarmalayıcısına koymak. React dünyasındaki act alışkanlığıyla her etkileşimi waitFor ya da whenStable ile sarmak gereksiz; ATL event'lerden sonra change detection'ı kendisi çalıştırıyor. waitFor'u yalnız gerçekten beklediğin bir koşul için kullan.

fireEvent'i varsayılan araç yapmak. Yazının başındaki test kullanıcı senaryosunu değil, tek bir event'i test etti. fireEvent yalnız user-event'in desteklemediği event'ler için kalmalı.

Geniş metin sorguları yazmak. Bir denemede hata mesajlarını queryAllByText(/Enter|Choose/) ile topladım; sonuçta şehir listesindeki "Choose…" seçeneği de vardı. Rol ve ad ile daraltılmış sorgu, sayfaya eklenen yeni bir metinle yanlışlıkla eşleşmez.

Her element'e data-testid eklemek. Rolü ve adı olmayan bir element'i bulmak zorlaşıyorsa sorun çoğu zaman testte değil markup'ta: düğme yerine div ya da etiketsiz bir input.

Küçük Bir Deneme

★☆☆ Checkout formuna "Phone" alanı ekle (zorunlu değil). Etiketini ve autocomplete="tel" attribute'unu ekle, getByRole('textbox', { name: 'Phone' }) ile bulan ve boş bırakıldığında hata göstermediğini kontrol eden bir test yaz.

★★☆ E-posta alanındaki for attribute'unu sil ve testleri çalıştır. Hangi testler, hangi mesajla kaldı? Aynı denemeyi aria-label ile düzeltip getByLabelText'in davranışını gözle.

★★★ Katalog sayfasındaki arama kutusu için bir Testing Library testi yaz: kullanıcı "dune" yazınca 300 ms sonra search=dune isteği gitsin. Vitest fake timer'larını userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) ile birleştirmen gerekecek; whenStable'ın fake timer'la takıldığını 2. yazıda görmüştük.

Bu yazıdaki testler bağımlılıkları ya gerçek hâliyle (CartStore, PricePipe) ya da HttpTestingController ile kullandı. Bir sonraki yazıda bağımlılıkları taklit etmenin bütün seçeneklerine bakıyoruz: provider override ile elle yazılmış fake'ler, vi.fn ve vi.spyOn, child component'leri ng-mocks ile taklit etmek, network seviyesinde MSW (Mock Service Worker; HTTP isteklerini yakalayıp sahte cevap döndüren library) ve sipariş detay sayfası için RouterTestingHarness.