Component Harness'ları: CDK Harness Kullanmak, Kendi Harness'ını Yazmak ve Testleri Markup'tan Bağımsızlaştırmak
Component harness, test ile DOM arasına bir API koyar: markup değişince testler değil yalnız harness değişir. Bookstore'da Angular Material'ın select ve dialog harness'larını, documentRootLoader'ı ve animasyon ayarını, RatingInput için HarnessPredicate'li kendi harness'ımızı ve harness ile Testing Library'yi ne zaman seçeceğimizi gerçek koşularla gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar Angular 22.2 (TypeScript 6.0), Angular Material 22.2 ve @angular/cdk 22.2, Vitest 5.1, jsdom 30 ve @testing-library/angular 19.6 ile Node.js 24 üzerinde derlenip denenmiştir.
Önce Tahmin Et
Bookstore'da (sitedeki serilerin ortak örnek uygulaması olan online kitapçı) siparişi teslim alan müşteri kitabı ve teslimatı yıldızla puanlıyor. Puanı veren RatingInput component'inin ilk sürümü tıklanabilir span'lardan oluşuyordu:
template: `
<div class="stars" [attr.aria-label]="label()">
@for (star of stars(); track star) {
<span class="star" [class.filled]="star <= value()" (click)="select(star)">★</span>
}
</div>
`,
Component'in kendi testleri ve onu kullanan değerlendirme formunun (ReviewForm) testleri yıldızları DOM'dan (tarayıcının sayfayı tuttuğu element ağacı) buluyordu:
it('fills the stars up to the clicked one', async () => {
const fixture = await create();
fixture.nativeElement.querySelectorAll('.star')[3].click();
await fixture.whenStable();
expect(fixture.nativeElement.querySelectorAll('.star.filled')).toHaveLength(4);
});
// review-form.spec.ts
function rate(label: string, stars: number): void {
const group = document.querySelector(`[aria-label="${label}"]`)!;
(group.querySelectorAll('.star')[stars - 1] as HTMLElement).click();
}
Klavyeyle kullanılamadığı ve ekran okuyucu yıldızları okuyamadığı için component erişilebilir hâle getirildi: span'lar role="radio" taşıyan düğmelere döndü, ok tuşlarıyla puan değiştirmek eklendi. Tıklayınca seçilen puan, formun gönderdiği değer, hiçbiri değişmedi:
template: `
<div role="radiogroup" [attr.aria-label]="label()" (keydown)="onKeydown($event)">
@for (star of stars(); track star) {
<button
type="button"
role="radio"
[attr.aria-checked]="star === value()"
[attr.aria-label]="star === 1 ? '1 star' : star + ' stars'"
[class.filled]="star <= value()"
[tabIndex]="star === (value() || 1) ? 0 : -1"
[disabled]="disabled()"
(click)="select(star)"
>
★
</button>
}
</div>
`,
İki dosyadaki beş testten kaçı kalır? Davranış bozulmadıysa kalan testler sana ne söylüyor?
Cevap: Testler Davranışı Değil Markup'ı Biliyordu
Beş testin beşi de kaldı:
❯ bookstore-web src/app/catalog/review-form.spec.ts (2 tests | 2 failed) 84ms
× sends both ratings and the comment 72ms
× asks for the missing rating 11ms
❯ bookstore-web src/app/shared/rating-input.spec.ts (3 tests | 3 failed) 56ms
× shows five empty stars by default 38ms
× fills the stars up to the clicked one 9ms
× ignores clicks when disabled 9ms
TypeError: Cannot read properties of undefined (reading 'click')
❯ rate src/app/catalog/review-form.spec.ts:8:47
Uygulamada kırılan bir şey yok; kırılan, testlerin .star class'ına ve yıldızların span olduğuna dair bilgisi. Üstelik bu bilgi iki dosyaya dağılmış. Gerçek bir projede RatingInput on farklı formda kullanılıyorsa aynı düzeltmeyi on spec dosyasında yapman gerekir. 4. yazıda Testing Library'nin rol ve isimle sorgulayarak bu bağımlılığı azalttığını gördük. Bu yazıda Angular'ın kendi cevabına bakıyoruz: component harness'ları.
Harness Fikri: Test ile DOM Arasına Bir API
Harness, bir component'le testin arasına giren küçük bir class. Test "dördüncü yıldıza tıkla" demiyor, setValue(4) diyor; harness bunun DOM'da ne demek olduğunu biliyor. Markup değişince yalnız harness değişiyor, testler aynı kalıyor. Angular dokümanı harness'ların en çok paylaşılan UI component'lerinde işe yaradığını söylüyor; testler aksi hâlde component'in kendine ait DOM yapısına ve CSS class'larına bağımlı oluyor.
Altyapı CDK'da (Angular'ın Component Dev Kit kütüphanesi), @angular/cdk/testing paketinde. Üç parça var:
ComponentHarness: harness class'larının base class'ı.hostSelectorile hangi element'e bağlandığını,locatorForvelocatorForAllile içinde neyi bulacağını tanımlıyor.HarnessLoader: sayfada harness arayan nesne.getHarnessilk eşleşmeyi döndürüyor, hiç bulamazsa hata veriyor;getAllHarnesseshepsini döndürüyor.- Harness environment: harness'ın hangi ortamda çalıştığı. Unit testte
TestbedHarnessEnvironment; CDK bir de Selenium WebDriver ortamı veriyor, yani aynı harness uçtan uca (E2E) testte de kullanılabiliyor.
TestbedHarnessEnvironment her DOM okumasından önce ve her etkileşimden sonra change detection çalıştırıp uygulamanın stabil olmasını bekliyor. Zoneless bir Angular 22 uygulamasında, 3. yazıda elle yazdığımız await fixture.whenStable() satırları harness kullanan testlerde gerekmiyor.
Angular Material Harness'ları: Select ve Dialog
Bookstore'un katalog sayfasına Angular Material'ın mat-select'iyle bir sıralama seçimi, sepet satırlarına da silmeden önce onay isteyen bir MatDialog ekledik. Material'ın DOM'u senin kontrolünde değil: mat-select native bir select değil, seçenekleri sayfanın sonuna eklenen bir overlay'de duruyor. Bir sonraki Material sürümünün bu yapıyı değiştirmeyeceğinin garantisi yok. Material bu yüzden her component'i için bir harness yayımlıyor, @angular/material/select/testing gibi ayrı bir entry point'te:
<mat-form-field>
<mat-label>Sort by</mat-label>
<mat-select [value]="sort()" (valueChange)="sort.set($event)">
<mat-option value="relevance">Relevance</mat-option>
<mat-option value="title">Title</mat-option>
<mat-option value="price">Price: low to high</mat-option>
</mat-select>
</mat-form-field>
it('sorts the books by price', async () => {
const { fixture } = await render(CatalogPage, {
providers: [provideHttpClientTesting()],
deferBlockBehavior: DeferBlockBehavior.Manual,
});
TestBed.inject(HttpTestingController).expectOne('/api/books').flush([dune, hobbit, { ...emma, stock: 2 }]);
const loader = TestbedHarnessEnvironment.loader(fixture);
const sort = await loader.getHarness(MatSelectHarness.with({ label: 'Sort by' }));
await sort.clickOptions({ text: 'Price: low to high' });
expect(await sort.getValueText()).toBe('Price: low to high');
const titles = screen.getAllByRole('heading', { level: 3 }).map((heading) => heading.textContent);
expect(titles).toEqual(['Emma', 'The Hobbit', 'Dune']);
});
clickOptions select'i açıyor, overlay'deki seçeneği metniyle bulup tıklıyor. Testin geri kalanı Testing Library ile: sayfanın sonucu yine kullanıcının gördüğü başlıklardan okunuyor. İkisi aynı testte rahatça birlikte çalışıyor; Angular Testing Library'nin (ATL) render() sonucundaki fixture, harness loader'ını kurmak için yeterli.
Dialog'da bir fark var: dialog component'in içinde değil, document.body'nin altındaki overlay container'ında açılıyor. loader(fixture) yalnız fixture'ın içinde aradığı için dialog'u bulamıyor. Fixture dışındaki element'ler için documentRootLoader kullanılıyor:
async function openRemoveDialog(): Promise<MatDialogHarness> {
const { fixture } = await render(CartSummary);
const cart = TestBed.inject(CartStore);
cart.add(dune);
cart.add(hobbit);
await userEvent.setup().click(await screen.findByRole('button', { name: 'Remove Dune' }));
return TestbedHarnessEnvironment.documentRootLoader(fixture).getHarness(MatDialogHarness);
}
it('removes the book after confirmation', async () => {
const dialog = await openRemoveDialog();
expect(await dialog.getTitleText()).toBe('Remove Dune?');
await (await dialog.getHarness(MatButtonHarness.with({ text: 'Remove' }))).click();
expect(TestBed.inject(CartStore).lines().map((line) => line.book.title)).toEqual(['The Hobbit']);
});
Bu test ilk koşuda kaldı: "Remove"a basıldıktan sonra sepette hâlâ iki kitap vardı. Sebep Material'ın kapanma animasyonu. Dialog afterClosed()'u animasyon bittikten sonra yayıyor; Material 22'nin kaynağında kapanış süresi 75 ms ve bekleme düz bir setTimeout ile yapılıyor. Harness'ın beklediği Angular stabilitesi bu timer'ı kapsamıyor. Çözüm, Material'ın kendi test yardımcılarının da yaptığı gibi animasyonları testte kapatmak. 1. yazıda kurduğumuz providersFile bunun için doğru yer, çünkü ayar bütün testlere uygulanıyor:
// src/test-providers.ts
import { MATERIAL_ANIMATIONS } from '@angular/material/core';
import { localeProviders } from './app/locale';
export default [...localeProviders, { provide: MATERIAL_ANIMATIONS, useValue: { animationsDisabled: true } }];
Material'ın tablosu için de aynı mantıkta bir MatTableHarness var; getRows() satırları, getCellTextByIndex() hücre metinlerini veriyor. Bookstore'da şimdilik Material tablosu olmadığı için burada kullanmadık.
Kendi Harness'ını Yazmak: RatingInputHarness
RatingInput'un harness'ı, testlerin component hakkında bilmesi gereken her şeyi tek yerde topluyor. Erişilebilir markup'a göre yazılmış hâli:
import { BaseHarnessFilters, ComponentHarness, HarnessPredicate, parallel, TestKey } from '@angular/cdk/testing';
export interface RatingInputHarnessFilters extends BaseHarnessFilters {
label?: string | RegExp;
value?: number;
}
export class RatingInputHarness extends ComponentHarness {
static hostSelector = 'app-rating-input';
private readonly group = this.locatorFor('[role="radiogroup"]');
private readonly stars = this.locatorForAll('[role="radio"]');
static with(options: RatingInputHarnessFilters = {}): HarnessPredicate<RatingInputHarness> {
return new HarnessPredicate(RatingInputHarness, options)
.addOption('label', options.label, (harness, label) => HarnessPredicate.stringMatches(harness.getLabel(), label))
.addOption('value', options.value, async (harness, value) => (await harness.getValue()) === value);
}
async getLabel(): Promise<string | null> {
return (await this.group()).getAttribute('aria-label');
}
async getMax(): Promise<number> {
return (await this.stars()).length;
}
async getValue(): Promise<number> {
const stars = await this.stars();
const checked = await parallel(() => stars.map((star) => star.getAttribute('aria-checked')));
return checked.indexOf('true') + 1;
}
async setValue(value: number): Promise<void> {
const stars = await this.stars();
if (value < 1 || value > stars.length) {
throw new Error(`RatingInput has ${stars.length} stars; cannot select ${value}.`);
}
await stars[value - 1].click();
}
async increase(): Promise<void> {
await this.pressOnCurrentStar(TestKey.RIGHT_ARROW);
}
async decrease(): Promise<void> {
await this.pressOnCurrentStar(TestKey.LEFT_ARROW);
}
async isDisabled(): Promise<boolean> {
const [first] = await this.stars();
return first.getProperty<boolean>('disabled');
}
private async pressOnCurrentStar(key: TestKey): Promise<void> {
const stars = await this.stars();
const current = Math.max((await this.getValue()) - 1, 0);
await stars[current].sendKeys(key);
}
}
Birkaç ayrıntı önemli. locatorFor ve locatorForAll element'i hemen bulmuyor, çağrıldığında bulan bir fonksiyon döndürüyor; böylece harness her seferinde DOM'un o anki hâlini okuyor. Dönen element'ler TestElement tipinde: click, sendKeys, getAttribute, getProperty, text gibi async method'ları var ve hepsi ortamdan bağımsız. Harness'ın içinde nativeElement ya da document.querySelector yok, bu yüzden aynı class WebDriver ortamında da çalışabiliyor. parallel beş getAttribute çağrısını tek seferde çözüyor ve aradaki change detection turlarını birleştiriyor. Bütün public method'lar Promise döndürüyor, çünkü E2E ortamında her DOM okuması tarayıcıya giden bir çağrı.
Component'in kendi testleri artık yalnız harness'ı konuşuyor:
async function create(): Promise<RatingInputHarness> {
fixture = TestBed.createComponent(RatingInput);
fixture.componentRef.setInput('label', 'Rate the book');
return TestbedHarnessEnvironment.harnessForFixture(fixture, RatingInputHarness);
}
it('moves with the arrow keys and stops at the last star', async () => {
const rating = await create();
await rating.setValue(4);
await rating.increase();
await rating.increase();
expect(await rating.getValue()).toBe(5);
await rating.decrease();
expect(await rating.getValue()).toBe(4);
});
harnessForFixture, fixture'ın kök element'i için doğrudan harness veriyor; component'in kendisini test ederken loader'a gerek yok.
HarnessPredicate ile Filtrelemek ve Markup Değişince Olan
Değerlendirme formunda iki RatingInput var: "Rate the book" ve "Rate the delivery". getHarness(RatingInputHarness) ilk bulduğunu döndürür; hangisinin döneceği DOM sırasına bağlı olur. Harness'taki static with() method'u bir HarnessPredicate kuruyor ve testin harness'ı etiketine ya da değerine göre seçmesini sağlıyor. Material harness'larının with({ label: 'Sort by' }) seçeneği de aynı mekanizma:
it('sends both ratings and the comment', async () => {
const { loader, send } = await setup();
const user = userEvent.setup();
await (await loader.getHarness(RatingInputHarness.with({ label: 'Rate the book' }))).setValue(4);
await (await loader.getHarness(RatingInputHarness.with({ label: 'Rate the delivery' }))).setValue(5);
await user.type(screen.getByLabelText('Comment'), 'Arrived well packed.');
await user.click(screen.getByRole('button', { name: 'Send review' }));
expect(send).toHaveBeenCalledExactlyOnceWith({ book: 4, delivery: 5, comment: 'Arrived well packed.' });
});
it('asks for the missing rating', async () => {
const { loader, send } = await setup();
await (await loader.getHarness(RatingInputHarness.with({ label: 'Rate the book' }))).setValue(4);
await userEvent.setup().click(screen.getByRole('button', { name: 'Send review' }));
const unrated = await loader.getAllHarnesses(RatingInputHarness.with({ value: 0 }));
expect(await Promise.all(unrated.map((rating) => rating.getLabel()))).toEqual(['Rate the delivery']);
expect(await screen.findByText('Rate the delivery.')).toBeInTheDocument();
expect(send).not.toHaveBeenCalled();
});
RatingInput, Signal Forms'un FormValueControl<number> interface'ini uyguluyor: value bir model(), disabled bir input, touch bir output. Bu yüzden formda [formField]="reviewForm.book" ile bağlanıyor ve "Rate the delivery." hatası, form gönderilince kendiliğinden görünüyor. Harness'ın tıklaması gerçek bir DOM event'i olduğu için testte formun değeri de gerçekten değişiyor.
Asıl kazanç bir sonraki markup değişikliğinde görüldü. Ekip role="radio" taşıyan düğmeler yerine tarayıcının kendi radio input'larını kullanmaya karar verdi: fieldset ve legend grubun adını, gizlenmiş input type="radio" elemanları da yıldızları taşıyor. Testlere dokunmadan çalıştırınca harness kullanan altı testin altısı kaldı. Hata mesajları bu kez harness'tan geliyordu:
Error: RatingInput has 0 stars; cannot select 4.
AssertionError: expected +0 to be 5 // Object.is equality
Düzeltme yalnız harness'ta, beş satır:
private readonly legend = this.locatorFor('legend');
private readonly stars = this.locatorForAll('input[type="radio"]');
async getLabel(): Promise<string | null> {
return (await this.legend()).text();
}
// getValue içinde
const checked = await parallel(() => stars.map((star) => star.getProperty<boolean>('checked')));
return checked.indexOf(true) + 1;
Altı test tekrar yeşil. Spec dosyalarının hiçbiri değişmedi; snapshot'taki RatingInput bu son hâli.
Harness mı, Testing Library mi?
Radio input'lara geçince bir şey daha oldu: component'in erişilebilir yapısı netleşti ve Testing Library'nin rol sorguları harness olmadan da aynı işi yapabilir hâle geldi:
it('can also be rated with role queries', async () => {
const { send } = await setup();
const user = userEvent.setup();
await user.click(within(screen.getByRole('group', { name: 'Rate the book' })).getByRole('radio', { name: '4 stars' }));
await user.click(within(screen.getByRole('group', { name: 'Rate the delivery' })).getByRole('radio', { name: '5 stars' }));
await user.click(screen.getByRole('button', { name: 'Send review' }));
expect(send).toHaveBeenCalledExactlyOnceWith({ book: 4, delivery: 5, comment: '' });
});
Bu test de markup'tan bağımsız; yalnız erişilebilir isimlere bağlı. O zaman ikisi arasında nasıl seçim yapacağız? Bookstore'daki ayrım şöyle:
| Durum | Tercih | Neden |
|---|---|---|
| Kendi component'in, erişilebilir rolleri net | Testing Library | ek kod yok, test kullanıcının gördüğünü okuyor |
| Angular Material ya da başka bir kütüphanenin component'i | kütüphanenin harness'ı | overlay ve iç DOM senin kontrolünde değil |
| Çok yerde kullanılan paylaşılan UI component'i | kendi harness'ın | etkileşim bilgisi tek yerde, okunan değer tipli |
| Rolle ifade edilemeyen durum (seçili değer, açık/kapalı) | harness | getValue() gibi method'lar DOM ayrıntısını gizliyor |
| Aynı etkileşim E2E'de de lazım | harness | WebDriver ortamında da çalışıyor |
İkisi birbirinin alternatifi değil. Yukarıdaki katalog testinde select'i harness ile değiştirip sonucu rol sorgusuyla okuduk. Genel kural: kullanıcının yaptığını ve gördüğünü Testing Library ile, bir component'in iç yapısını bilmeyi gerektiren etkileşimi harness ile ifade et.
Üretimde Dikkat
Harness'ı component'le birlikte yayımla. RatingInput ileride ayrı bir UI kütüphanesine taşınırsa harness'ı da Material'ın yaptığı gibi bir testing entry point'ine koymak gerekir; bizde şimdilik src/app/shared/testing/rating-input-harness.ts. Kütüphaneyi kullanan ekipler kendi testlerinde aynı harness'ı import eder, component'in markup'ı değişince harness'ı kütüphanenin sahibi günceller. Harness dosyası Vitest'in vi, expect gibi global'lerini kullanmamalı ve @angular/cdk/testing dışında bir test aracına bağımlı olmamalı. Böylece hem uygulama build'inde derleniyor hem de farklı test runner'larıyla çalışıyor.
Harness'ın bir maliyeti var. Her okuma ve etkileşimde change detection ve stabilite beklemesi yapılıyor. Verbose çıktıda her dosyanın ilk testi, component'in ve Material'ın ilk yüklenmesi dahil, 56–254 ms; sonrakiler 5–50 ms sürdü. Değerlendirme formunda harness'lı ikinci test 30 ms, rol sorgulu test 51 ms; fark ölçülebilir değil. Suite 18 dosya ve 66 testle üç koşuda 1,58–1,84 saniye. Çok sayıda okuma yapan testlerde parallel kullanmak gereksiz stabilite turlarını azaltıyor.
Material animasyonlarını kapatmayı unutma. Dialog, bottom sheet ve snack bar gibi component'ler kapanırken timer kullanıyor; animasyonlar açıkken testler bazen geçip bazen kalabilir.
Sık Düşülen Tuzaklar
await'i unutmak. Harness method'larının hepsi Promise döndürüyor. rating.setValue(4) satırının başında await olmayınca test bitmeden etkileşim yarıda kaldı:
AssertionError: expected Promise{…} to be 4 // Object.is equality
- Expected:
4
+ Received:
Promise {}
Error: Harness is attempting to use a fixture that has already been destroyed.
İkinci hata, await edilmemiş click'in test bittikten sonra çalışmaya çalışmasından geliyor; Vitest bunu "Unhandled Rejection" olarak raporladı.
Overlay'deki harness'ı fixture loader'ıyla aramak. Dialog'u TestbedHarnessEnvironment.loader(fixture) ile aradığımda şu hatayı aldım:
Error: Failed to find element matching one of the following queries:
(MatDialogHarness with host element matching selector: ".mat-mdc-dialog-container")
Dialog, menü, select'in seçenek paneli ve tooltip fixture'ın dışında açılıyor; onlar için documentRootLoader.
Harness'a iş mantığı koymak. RatingInputHarness'a isValidReview() gibi bir method eklemek ya da beklenen puanı harness'ta hesaplamak cazip geliyor. Harness yalnız "component'le nasıl konuşulur" sorusunun cevabı olmalı; doğru davranışın ne olduğuna test karar veriyor. Harness karar vermeye başlarsa testteki hata harness'ın içinde kayboluyor ve iki yerde düzeltilmesi gerekiyor. Harness'ta expect de olmamalı.
Kendi component'in için gereksiz harness yazmak. Tek formda kullanılan ve rolleri net bir component için harness, Testing Library'nin zaten verdiğini tekrar eden ek kod. Harness'ı paylaşılan, çok yerde kullanılan ya da iç yapısı karmaşık component'lere ayır.
Küçük Bir Deneme
★☆☆ RatingInputHarness'a getStarLabels() method'u ekle ve "1 star", "2 stars" … etiketlerinin doğru üretildiğini test et.
★★☆ CartSummary'deki silme onayını, "Keep it" seçeneğini MatDialogHarness.close() ile kapatan bir testle de dene. Escape tuşuyla kapanan dialog afterClosed()'da hangi değeri veriyor, sepet ne oluyor?
★★★ RatingInputHarness.with()'e disabled seçeneği ekle. Ardından ReviewForm'u gönderim sırasında iki RatingInput'u da kapatacak şekilde değiştir ve bunu yalnız harness'la test et.
Bu yazıdaki bütün testler hâlâ jsdom'da çalışıyor; jsdom'da layout olmadığı için 3. yazıdaki @defer (on viewport) bloğunu elle tetiklemiştik ve IntersectionObserver hiç yoktu. Bir sonraki yazıda Vitest browser mode ile aynı testlerden bir kısmını gerçek Chromium, Firefox ve WebKit'te çalıştırıyoruz: sonsuz kaydırmalı kitap listesi, sticky sepet ve görsel karşılaştırma.