Angular Component Testleri: setInput, output, Zoneless'ta whenStable, @defer ve Host Component
Angular 22'de componentInstance üzerinden değer atayıp detectChanges çağıran eski testler kalır, çünkü varsayılan OnPush ve zoneless fixture yalnız Angular'a haber veren değişiklikleri işler; değeri setInput ile vermek ve DOM'u whenStable'dan sonra okumak gerekir. Bookstore'un BookCard, QuantityInput, CartSummary ve CatalogPage component'leriyle output, model(), content projection, @defer ve getLastFixture testlerini gerçek koşularla anlatıyoruz.
Sürüm notu: Bu yazıdaki kodlar Angular 22.2 (TypeScript 6.0), Vitest 5.1 ve jsdom 30 ile Node.js 24 üzerinde derlenip denenmiştir.
Önce Tahmin Et
Angular 12 döneminden kalma bir component ve testi düşün. Bookstore'un (sitedeki serilerin ortak örnek uygulaması olan online kitapçı) eski arayüzünde sepetteki ürün sayısını gösteren küçük bir rozet vardı:
@Component({
selector: 'app-cart-badge',
template: `<span>Cart ({{ count }})</span>`,
})
export class CartBadge {
@Input() count = 0;
}
Testi de o dönemin en yaygın kalıbıyla yazılmış: önce ilk render, sonra componentInstance üzerinden değer ve tekrar detectChanges:
it('shows the number of items', () => {
const fixture = TestBed.createComponent(CartBadge);
fixture.detectChanges();
fixture.componentInstance.count = 2;
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toBe('Cart (2)');
});
Bu kodu Angular 22.2 ile ng new ile açılmış bir projeye kopyaladın. Test geçer mi? Kalırsa hata mesajı ne olur? Bonus soru: component'e changeDetection: ChangeDetectionStrategy.Eager eklersen sonuç değişir mi?
Cevap: Zoneless'ta detectChanges Haber Almamış View'ı Yenilemiyor
Test kaldı:
FAIL bookstore-web src/app/legacy/cart-badge.spec.ts > CartBadge > shows the number of items
AssertionError: expected 'Cart (0)' to be 'Cart (2)' // Object.is equality
Eager eklenmiş kopyası da kaldı, ama başka bir hatayla:
Error: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: '0'. Current value: '2'. Expression location: _CartBadge component.
İki sebep üst üste biniyor. Birincisi Angular 22'de OnPush varsayılan: changeDetection yazılmamış bir component yalnız input'u Angular üzerinden değiştiğinde, içinde bir event olduğunda ya da okuduğu bir signal değiştiğinde yenileniyor. componentInstance.count = 2 bunların hiçbiri değil; Angular'ın haberi olmayan düz bir property ataması. İkincisi uygulama zoneless. ComponentFixture.detectChanges()'in kaynak koduna bakınca zoneless dalında yalnız ApplicationRef.tick() çağırdığı görülüyor. Bu tick, Angular'a değiştiğini bildirmiş view'ları yeniliyor. Eager component de haber vermediği için yenilenmedi; ama geliştirme modunda tick'in ardından çalışan ek kontrol bütün binding'leri tekrar okuyup farkı buldu ve NG0100 (kontrolden sonra değeri değişmiş bir binding bulunduğunu söyleyen hata) fırlattı. Angular 12'de Zone.js her detectChanges'te bütün ağacı baştan sona kontrol ettiği için bu test geçiyordu.
Düzeltme testi Angular'a haber verecek şekilde yazmak. Input'u componentRef.setInput() ile vermek, OnPush component'i yenilenmesi gereken view olarak işaretliyor:
it('shows the number of items', async () => {
const fixture = TestBed.createComponent(CartBadge);
await fixture.whenStable();
fixture.componentRef.setInput('count', 2);
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toBe('Cart (2)');
});
Bu hâliyle test geçti; whenStable yerine detectChanges yazdığım varyant da geçti. Fark testte: artık component'e, bir parent template'in yaptığı gibi input üzerinden değer veriyor. Bu yazıda Bookstore'un component'lerini bu yaklaşımla test ediyoruz: setInput ile input, output()'a abone olarak event, host component ile model() ve content projection, getDeferBlocks() ile @defer.
Zoneless Bir Fixture Nasıl Çalışıyor?
Angular 22'de ng new ile açılan projede Zone.js yok; uygulama zoneless. Bunun testteki karşılığı, fixture'ın uygulama gibi davranması. ComponentFixture'ın kaynak kodunda zoneless iken autoDetect'in varsayılanı true: bir signal değiştiğinde ya da template'teki bir event çalıştığında Angular change detection'ı kendisi planlıyor. Planlamanın asenkron olduğunu küçük bir denemeyle gördüm. Template'inde count() signal'ını gösteren bir component'te düğmeye tıklayıp DOM'u (tarayıcının sayfayı tuttuğu element ağacı) üç kez okudum:
["Cart (0)add","Cart (0)add","Cart (1)add"]
İlk değer tıklamadan önce, ikincisi click()'ten hemen sonra, üçüncüsü await fixture.whenStable()'dan sonra. Tıklama signal'ı anında değiştirdi ama DOM bir sonraki change detection'da güncellendi. Kural bu yüzden şöyle: DOM'u okumadan önce await fixture.whenStable(). whenStable() Angular'ın planladığı işi ve bekleyen task'ları (yüklenen bir resource gibi) bitirmesini bekliyor. fixture.detectChanges() hâlâ var ve senkron; bir signal ya da setInput sonrasında çalışıyor. Ama planlanmış işin tamamını beklemiyor ve yukarıda görüldüğü gibi haber vermemiş bir değişikliği de yakalamıyor. Bookstore testlerinde varsayılan olarak whenStable kullanıyoruz.
Input ve Output: BookCard
Katalogdaki her kitap bir BookCard ile gösteriliyor. Input'u signal tabanlı input.required(), output'u output():
@Component({
selector: 'app-book-card',
imports: [PricePipe, RatingSummary],
template: `
<article>
<h3>{{ book().title }}</h3>
<p class="author">{{ book().author }}</p>
<p class="price">{{ book().price | price }}</p>
@if (book().stock > 0) {
<button type="button" (click)="addToCart.emit(book())">Add to cart</button>
} @else {
<p class="stock">Out of stock</p>
}
@defer (on viewport) {
<app-rating-summary [isbn]="book().isbn" />
} @placeholder {
<p class="rating-placeholder">Ratings</p>
}
</article>
`,
})
export class BookCard {
readonly book = input.required<Book>();
readonly addToCart = output<Book>();
}
Testlerin ortak hazırlığı beforeEach'te. Fixture'ı bir değişkende taşımak yerine Angular 22 ile gelen TestBed.getLastFixture()'ı kullanıyoruz; en son oluşturulan fixture'ı dönüyor:
describe('BookCard', () => {
beforeEach(async () => {
TestBed.configureTestingModule({
providers: [provideHttpClientTesting()],
deferBlockBehavior: DeferBlockBehavior.Manual,
});
const fixture = TestBed.createComponent(BookCard);
fixture.componentRef.setInput('book', dune);
await fixture.whenStable();
});
function element(): HTMLElement {
return TestBed.getLastFixture<BookCard>().nativeElement;
}
it('shows the title, the author and the price', () => {
expect(element().querySelector('h3')?.textContent).toBe('Dune');
expect(element().querySelector('.author')?.textContent).toBe('Frank Herbert');
expect(element().querySelector('.price')?.textContent).toBe('₺450,00');
});
it('emits the book when "Add to cart" is clicked', () => {
const fixture: ComponentFixture<BookCard> = TestBed.getLastFixture();
const added = vi.fn();
fixture.componentInstance.addToCart.subscribe(added);
element().querySelector('button')!.click();
expect(added).toHaveBeenCalledExactlyOnceWith(dune);
});
it('replaces the button with "Out of stock" when there is no stock', async () => {
const fixture = TestBed.getLastFixture<BookCard>();
fixture.componentRef.setInput('book', emma);
await fixture.whenStable();
expect(element().querySelector('button')).toBeNull();
expect(element().querySelector('.stock')?.textContent).toBe('Out of stock');
});
});
dune ve emma, src/testing/books.ts'teki test verisi; 2. yazıdaki CatalogService testi de artık oradan alıyor. Fiyatın "₺450,00" olarak görünmesi, 1. yazıda providersFile ile verdiğimiz locale'in ve 2. yazıdaki PricePipe'ın template'te birlikte çalıştığını gösteriyor.
output() bir OutputEmitterRef dönüyor ve subscribe method'u var; testte dinlemek için vi.fn()'i abone yapmak yeterli. toHaveBeenCalledExactlyOnceWith hem bir kez çağrıldığını hem argümanı kontrol ediyor. Burada event'i tetiklemek için düğmeye tıklıyoruz, componentInstance.addToCart.emit(...) çağırmıyoruz. İkincisi yalnız emit'in çalıştığını test eder, template'teki (click) binding'inin doğru olduğunu değil.
Bu testlerde whenStable tıklamadan sonra yok, çünkü assertion DOM'a değil output'a bakıyor; event handler senkron çalışıyor. DOM'da bir değişiklik bekliyorsan araya await fixture.whenStable() girmeli.
model() ve İki Yönlü Binding: Host Component ile Test
Sepetteki miktarı değiştiren QuantityInput, model() kullanıyor; parent [(quantity)] ile bağlanıyor:
@Component({
selector: 'app-quantity-input',
template: `
<button type="button" aria-label="Decrease quantity" [disabled]="quantity() <= 1" (click)="quantity.set(quantity() - 1)">
−
</button>
<span class="quantity">{{ quantity() }}</span>
<button type="button" aria-label="Increase quantity" [disabled]="quantity() >= max()" (click)="quantity.set(quantity() + 1)">
+
</button>
`,
})
export class QuantityInput {
readonly quantity = model(1);
readonly max = input(99);
}
model()'in sözleşmesi iki yönlü: parent'tan gelen değer component'e iniyor, component'teki değişiklik parent'a çıkıyor. Bunu setInput ile tek başına test edebilirsin ama iki yönü birlikte görmenin en net yolu, testin içinde küçük bir host component yazmak:
@Component({
imports: [QuantityInput],
template: `<app-quantity-input [(quantity)]="quantity" [max]="3" />`,
})
class QuantityHost {
readonly quantity = signal(2);
}
describe('QuantityInput', () => {
function button(label: string): HTMLButtonElement {
return TestBed.getLastFixture().nativeElement.querySelector(`button[aria-label="${label}"]`);
}
it('writes the new quantity back to the parent', async () => {
const fixture = TestBed.createComponent(QuantityHost);
await fixture.whenStable();
button('Increase quantity').click();
await fixture.whenStable();
expect(fixture.componentInstance.quantity()).toBe(3);
expect(fixture.nativeElement.querySelector('.quantity').textContent).toBe('3');
});
it('shows a quantity changed by the parent', async () => {
const fixture = TestBed.createComponent(QuantityHost);
await fixture.whenStable();
fixture.componentInstance.quantity.set(1);
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('.quantity').textContent).toBe('1');
expect(button('Decrease quantity').disabled).toBe(true);
});
});
Host'un componentInstance'ına dokunmak burada sorun değil: host testin bir parçası, parent rolünü oynuyor ve state'i signal olduğu için Angular değişiklikten haberdar oluyor. Test edilen component'in kendi componentInstance'ına state yazmak ise yazının başındaki hataya geri götürüyor.
Content Projection: CartSummary
CartSummary sepet satırlarını ve toplamı gösteriyor; "Checkout" düğmesini kendisi koymuyor, kullanan sayfa cartActions attribute'lu bir element'i içine yansıtıyor. Template'in ilgili kısmı:
@if (cart.lines().length === 0) {
<p class="empty">Your cart is empty.</p>
} @else {
<ul>
@for (line of cart.lines(); track line.book.isbn) {
<li>
<span class="title">{{ line.book.title }}</span>
<app-quantity-input
[quantity]="line.quantity"
(quantityChange)="cart.setQuantity(line.book.isbn, $event)"
[max]="line.book.stock"
/>
<span class="line-total">{{ line.book.price * line.quantity | price }}</span>
</li>
}
</ul>
<p class="total">Total: {{ cart.total() | price }}</p>
<ng-content select="[cartActions]" />
}
Content projection'ı test etmenin tek yolu yansıtılacak içeriği veren bir parent; yine host component:
@Component({
imports: [CartSummary],
template: `
<app-cart-summary>
<button cartActions type="button">Checkout</button>
</app-cart-summary>
`,
})
class CartSummaryHost {}
describe('CartSummary', () => {
function text(selector: string): string | undefined {
return TestBed.getLastFixture().nativeElement.querySelector(selector)?.textContent;
}
it('says the cart is empty and hides the projected actions', async () => {
const fixture = TestBed.createComponent(CartSummaryHost);
await fixture.whenStable();
expect(text('.empty')).toBe('Your cart is empty.');
expect(text('button')).toBeUndefined();
});
it('lists the lines with the total and shows the projected actions', async () => {
const cart = TestBed.inject(CartStore);
cart.add(dune);
cart.add(dune);
cart.add(hobbit);
const fixture = TestBed.createComponent(CartSummaryHost);
await fixture.whenStable();
const titles = [...fixture.nativeElement.querySelectorAll('.title')].map((title) => title.textContent);
expect(titles).toEqual(['Dune', 'The Hobbit']);
expect(text('.total')).toBe('Total: ₺1.280,00');
expect(text('button[cartActions]')).toBe('Checkout');
});
it('updates the total when a quantity changes', async () => {
TestBed.inject(CartStore).add(dune);
const fixture = TestBed.createComponent(CartSummaryHost);
await fixture.whenStable();
fixture.nativeElement.querySelector('button[aria-label="Increase quantity"]').click();
await fixture.whenStable();
expect(text('.line-total')).toBe('₺900,00');
expect(text('.total')).toBe('Total: ₺900,00');
});
});
İlk test projection'ın koşullu olduğunu kontrol ediyor: ng-content @else dalında durduğu için boş sepette "Checkout" düğmesi DOM'a hiç girmiyor. Son test QuantityInput'u taklit etmeden, gerçek child component'le çalışıyor: "+" düğmesine tıklamak quantityChange'i tetikliyor, CartStore güncelleniyor ve toplam değişiyor. Child'ı taklit edip etmemenin maliyetini 5. yazıda konuşacağız; burada child küçük ve bağımlılığı olmadığı için gerçeğini kullanmak hem daha basit hem daha çok şey test ediyor.
CartStore'u TestBed.inject ile alıp doldurmak, component'in bağımlılığını gerçek service'le kullanmak demek. Service'in kendi testleri ayrı bir dosyada; burada yalnız component'in onu doğru gösterdiğini kontrol ediyoruz.
@defer Bloklarını Test Etmek
BookCard'daki puan özeti @defer (on viewport) ile erteleniyor: RatingSummary kart ekrana girdiğinde yükleniyor ve kendi HTTP isteğini atıyor. jsdom'da IntersectionObserver olmadığını 1. yazıda görmüştük; bu yüzden testte kart hiçbir zaman "ekrana girmiyor". İlk denemede test kalmadı ve blok placeholder'da kaldı: kartın metni "DuneFrank Herbert₺450,00Add to cartRatings" idi. Ama suite'i --reporters verbose ile çalıştırınca, kartı render eden her testin altında Angular'ın ErrorHandler'ının konsola yazdığı bir hata çıktı:
stderr | src/app/catalog/book-card.spec.ts > BookCard > shows the title, the author and the price
ERROR ReferenceError: IntersectionObserver is not defined
at createIntersectionObserver (.../@angular/core/fesm2022/_debug_node-chunk.mjs:2180:3)
at onViewport (.../@angular/core/fesm2022/_debug_node-chunk.mjs:2193:17)
Varsayılan reporter geçen testlerin konsol çıktısını göstermediği için bu satırlar normal koşuda görünmüyor. Sebep TestBed'in @defer modu. DeferBlockBehavior.Playthrough blokları tarayıcıdaki gibi trigger'larına göre çalıştırıyor ve tip tanımına göre test ortamında varsayılan bu; on viewport trigger'ı kurulurken IntersectionObserver aranıyor ve bulunamıyor. Manual ise trigger kurmuyor, blokları sen render edene kadar bekletiyor. Bu yüzden BookCard ve CatalogPage testlerinde deferBlockBehavior: DeferBlockBehavior.Manual kullanıyoruz; hata satırları kayboldu. Bloklara fixture.getDeferBlocks() ile erişip istediğin durumu render ediyorsun:
it('keeps the ratings deferred until the card is rendered completely', async () => {
const fixture = TestBed.getLastFixture<BookCard>();
expect(element().querySelector('.rating-placeholder')?.textContent).toBe('Ratings');
const [ratings] = await fixture.getDeferBlocks();
await ratings.render(DeferBlockState.Complete);
TestBed.tick();
TestBed.inject(HttpTestingController)
.expectOne('/api/books/9780441013593/rating')
.flush({ average: 4.5, count: 120 });
await fixture.whenStable();
expect(element().querySelector('.rating')?.textContent).toBe('4.5 / 5 (120 ratings)');
});
Test hem placeholder'ı hem tam hâli kontrol ediyor. DeferBlockState'in Placeholder, Loading ve Error değerleri de var; @loading ya da @error bloğu yazdıysan onları da aynı şekilde render edip görebilirsin. "Kart ekrana girince yüklenir" davranışının kendisi, yani trigger, jsdom'da test edilemiyor. Onu 7. yazıda gerçek tarayıcıda, Vitest browser mode ile test edeceğiz.
Sayfa Seviyesinde: CatalogPage ve Hata Durumu
2. yazıda httpResource'un hata durumunda value() okununca exception fırlattığını görmüştük. Katalog sayfası bu yüzden template'te önce status()'a bakıyor:
@switch (catalog.books.status()) {
@case ('error') {
<p role="alert">Could not load the catalog.</p>
<button type="button" (click)="catalog.books.reload()">Try again</button>
}
@case ('loading') {
<p>Loading books…</p>
}
@default {
<ul aria-label="Books">
@for (book of catalog.inStock(); track book.isbn) {
<li><app-book-card [book]="book" (addToCart)="cart.add($event)" /></li>
} @empty {
<li>No books found.</li>
}
</ul>
}
}
Bu sayfanın testini ilk yazdığımda, alışkanlıkla, component'i oluşturduktan sonra await fixture.whenStable() yazdım. Üç testin üçü de şu hatayla kaldı:
× shows the books that are in stock 5015ms
Error: Test timed out in 5000ms.
whenStable() bekleyen task'lar bitene kadar bekliyor ve yüklenmekte olan bir resource da bekleyen task. İsteği cevaplayacak olan kod ise await'in arkasında, hiç çalışamıyor. Sırayı değiştirmek gerekiyor: önce TestBed.tick() ile ilk render ve istek, sonra flush, en son whenStable:
it('shows an error and loads again when "Try again" is clicked', async () => {
const fixture = TestBed.createComponent(CatalogPage);
TestBed.tick();
http.expectOne('/api/books').flush(null, { status: 503, statusText: 'Service Unavailable' });
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toBe('Could not load the catalog.');
fixture.nativeElement.querySelector('button').click();
TestBed.tick();
http.expectOne('/api/books').flush([dune]);
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('[role="alert"]')).toBeNull();
expect(fixture.nativeElement.querySelector('h3').textContent).toBe('Dune');
});
Sayfa testi BookCard'ı gerçek hâliyle render ediyor; ikinci bir testte karttaki "Add to cart" düğmesine tıklayıp CartStore.count()'un 1 olduğunu kontrol ediyoruz. Bu, 1. yazıdaki test haritasında component ile routed feature arasında duran bir test: sayfa, child component'ler, service'ler gerçek; yalnız HTTP taklit.
Üretimde Dikkat
Bu yazının sonunda suite 10 dosya ve 39 test; üç koşuda 778, 828 ve 985 ms sürdü. Component testlerinin her biri 4–40 ms arasında. En yavaşları her dosyanın ilk testi; dosyadaki ilk render'ın tek seferlik warm-up maliyeti ilk teste yükleniyor. Süre sorunu henüz yok, ama bir component testinin saniyeler sürmeye başlaması genellikle gerçek bir zamanlayıcı ya da cevaplanmamış bir istek yüzünden whenStable'ın beklemesi demek.
Selector'ları dikkatli seç. Bu yazıdaki testler .price, .total gibi class'larla DOM'u sorguluyor. Class'lar stil için değiştiğinde testler kalıyor; aynı şekilde button'u etiket adıyla aramak, karta ikinci bir düğme eklendiğinde yanlış düğmeyi buluyor. Bir sonraki yazıda Testing Library ile element'leri kullanıcının gördüğü rol ve isimle bulacağız; bu sorunların çoğu orada ortadan kalkıyor.
Eski testleri Angular 22'ye taşırken ng update'in component'lere eklediği ChangeDetectionStrategy.Eager'ı kalıcı çözüm sayma. Yukarıdaki denemede Eager, zoneless bir fixture'da componentInstance ataması yapan testi kurtarmadı. Testleri setInput, signal ve whenStable ile yeniden yazmak gerekiyor; Karma'dan geçişin ayrıntıları 9. yazıda.
Sık Düşülen Tuzaklar
componentInstance üzerinden state yazıp template'i atlamak. fixture.componentInstance.count = 2 hem zoneless'ta çalışmıyor hem de parent'ın component'i gerçekte nasıl kullandığını test etmiyor. Input için setInput, iki yönlü binding için host component kullan.
Private ya da protected alanları test etmek. CartSummary'nin cart alanı protected; TypeScript'te fixture.componentInstance['cart'] ile yine de erişebilirsin. Ama o zaman test, component'in bir CartStore alanı olduğuna bağlanıyor. Kullanıcının gördüğü toplamı DOM'dan okumak aynı davranışı implementation'a bağlanmadan test ediyor.
DOM'u whenStable olmadan okumak. Tıklama ya da setInput sonrasında DOM bir sonraki change detection'a kadar eski hâlinde. Assertion hemen ardından DOM'a bakıyorsa arada await fixture.whenStable() olmalı.
Cevaplanmamış istek varken whenStable beklemek. Sonuç beş saniyelik bir timeout; hata mesajı isteği değil süreyi söylüyor. Önce TestBed.tick() ve flush, sonra whenStable.
@defer içindeki component'i hiç görmeden "test edildi" saymak. jsdom'da on viewport bloğu testi kırmadan placeholder'da kalıyor ve konsola yazdığı hata varsayılan reporter'da görünmüyor. Manual mod kullan ve ertelenen içeriği getDeferBlocks() ile render et; yoksa o içerik hiçbir testte çalışmıyor.
Küçük Bir Deneme
★☆☆ CartBadge'i signal tabanlı yaz: count = input(0). Yazının başındaki testi setInput ve whenStable ile yeniden yaz ve hem detectChanges hem whenStable varyantının geçtiğini gör.
★★☆ BookCard'a "stokta son 2 kitap" uyarısı ekle (stock <= 2). Uyarıyı gösteren ve göstermeyen iki test yaz; kitabı setInput ile değiştirip aynı fixture'da uyarının kaybolduğunu kontrol et.
★★★ BookCard'ın defer bloğuna bir @loading bloğu ekle ve placeholder, loading ve complete durumlarını sırayla render eden bir test yaz. deferBlockBehavior'ı Playthrough'a çevirip testi --reporters verbose ile çalıştır; konsolda ne değişiyor?
Bu yazıdaki testler DOM'u class ve etiket adlarıyla sorguladı. Bir sonraki yazıda aynı component'lere Angular Testing Library ile bakıyoruz: render(), role ve label sorguları, user-event ile gerçekçi yazma ve tıklama, ve Signal Forms ile kurulmuş checkout formunun hata mesajları.