Angular'da Service, Pipe, Signal ve Resource Testleri: TestBed.inject, HttpTestingController ve Fake Timer'lar
httpResource durumunu hemen loading gösterir ama isteği bir effect içinde gönderir; testte isteği görmek için önce TestBed.tick(), cevaptan sonra değeri okumak için whenStable() gerekir. Bookstore'un CatalogService'i, PricePipe'ı ve RecentlyViewed'ı üzerinden runInInjectionContext'i, effect testini, HttpTestingController ile iptal ve hata yolunu, debounced() için fake timer'ları ve Angular 22'deki resource zamanlama değişikliğini gerçek koşularla gösteriyoruz.
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
Bookstore'un (sitedeki serilerin ortak örnek uygulaması olan online kitapçı) Angular istemcisinde katalog bir service'ten geliyor. CatalogService, kitap listesini httpResource ile /api/books'tan çekiyor ve kategori ile arama terimini query parametresine çeviriyor:
@Injectable({ providedIn: 'root' })
export class CatalogService {
readonly category = signal<string | undefined>(undefined);
readonly search = signal('');
private readonly term = debounced(() => this.search().trim(), 300);
readonly books = httpResource<Book[]>(
() => {
const category = this.category();
const search = this.term.value();
return {
url: '/api/books',
params: { ...(category ? { category } : {}), ...(search ? { search } : {}) },
};
},
{ defaultValue: [] },
);
readonly inStock = computed(() => this.books.value().filter((book) => book.stock > 0));
}
İlk test, service oluşur oluşmaz isteğin gittiğini kontrol ediyor:
it('loads the whole catalog', () => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
const catalog = TestBed.inject(CatalogService);
const http = TestBed.inject(HttpTestingController);
expect(catalog.books.status()).toBe('loading');
http.expectOne('/api/books');
});
Resource'un durumu loading ise istek de yolda olmalı, değil mi? İki assertion'dan hangisi geçer, hangisi kalır?
Cevap: Resource Yükleniyor Görünür, İstek Henüz Gitmemiştir
Birinci assertion geçti, ikincisi kaldı:
FAIL bookstore-web src/app/catalog/catalog-service.spec.ts > CatalogService > loads the whole catalog
Error: Expected one matching request for criteria "Match URL: /api/books", found none.
❯ src/app/catalog/catalog-service.spec.ts:13:10
status() service oluştuğu anda loading dönüyor; ama HTTP isteğini gönderen kod henüz çalışmadı. Angular'ın resource implementation'ına bakınca sebep görünüyor: her resource, yükleme işini loadEffect adında bir effect'in içinde yapıyor. Effect'ler oluştukları anda değil, Angular bir sonraki senkronizasyonu yaptığında çalışıyor. Uygulamada bunu change detection'ı planlayan scheduler yapıyor; testte ise bizim tetiklememiz gerekiyor. Bunun API'si TestBed.tick(). Tip tanımındaki açıklamasına göre model ile UI'ı senkronlamak için bekleyen işi çalıştırıyor; Angular 20'den beri public API ve eski TestBed.flushEffects() onun lehine deprecated.
Doğru test üç adımlı: önce TestBed.tick() ile isteği gönder, sonra flush ile cevabı ver, en son Angular'ın işini bitirmesini bekle:
it('loads the whole catalog when no filter is set', async () => {
TestBed.tick();
const request = http.expectOne('/api/books');
expect(request.request.method).toBe('GET');
expect(catalog.books.status()).toBe('loading');
request.flush([dune, emma]);
await TestBed.inject(ApplicationRef).whenStable();
expect(catalog.books.status()).toBe('resolved');
expect(catalog.books.value().map((book) => book.title)).toEqual(['Dune', 'Emma']);
});
Son bekleme de gerekli. flush çağrısından hemen sonra durumu yazdırdığımda resource hâlâ loading idi ve value() boş array dönüyordu; await appRef.whenStable() sonrasında resolved ve bir kitap. Resource cevabı bir promise üzerinden işliyor, bu yüzden assertion'dan önce Angular'a bir tur vermen gerekiyor. whenStable(), uygulamada bekleyen iş kalmayana kadar bekliyor; yükleme süren bir resource da bekleyen iş sayılıyor.
Service'i TestBed ile ve TestBed'siz Test Etmek
1. yazıdaki RecentlyViewed ilk hâliyle düz bir class'tı; new RecentlyViewed() ile oluşturup TestBed olmadan test edebilirdin. Bu yazıda listeyi localStorage'a yazan bir effect ekledik:
const storageKey = 'bookstore.recentlyViewed';
@Injectable({ providedIn: 'root' })
export class RecentlyViewed {
private readonly isbns = signal<readonly string[]>(JSON.parse(localStorage.getItem(storageKey) ?? '[]'));
readonly list = this.isbns.asReadonly();
constructor() {
effect(() => localStorage.setItem(storageKey, JSON.stringify(this.isbns())));
}
mark(isbn: string): void {
this.isbns.update((list) => [isbn, ...list.filter((item) => item !== isbn)].slice(0, 3));
}
}
effect() ve inject() bir injection context istiyor. Bu tür bir class'ı new ile oluşturduğunda Angular şu hatayı veriyor (aynı denemeyi inject(LOCALE_ID) kullanan PricePipe ile yaptım):
Error: NG0203: The `InjectionToken LocaleId` token injection failed. `inject()` function must be called from an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`.
Kural basit: class'ın constructor'ında ya da field'larında inject(), effect(), httpResource() gibi bir şey varsa TestBed'e ihtiyacın var. providedIn: 'root' service'leri TestBed.inject() ile alırsın. Injector'a kayıtlı olmayan bir class için, örneğin bir pipe için, TestBed.runInInjectionContext() var:
describe('PricePipe', () => {
let pipe: PricePipe;
beforeEach(() => {
pipe = TestBed.runInInjectionContext(() => new PricePipe());
});
it('formats Turkish lira with the Turkish locale', () => {
expect(pipe.transform(1234.5)).toBe('₺1.234,50');
});
it('formats other currencies with their own symbol', () => {
expect(pipe.transform(1234.5, 'EUR')).toBe('€1.234,50');
});
it('shows free books as "Free"', () => {
expect(pipe.transform(0)).toBe('Free');
});
it('rounds to two decimals', () => {
expect(pipe.transform(0.1 + 0.2)).toBe('₺0,30');
});
});
PricePipe locale'i inject(LOCALE_ID) ile alıyor ve formatCurrency'yi para biriminin dar sembolüyle çağırıyor. Pipe bir kez oluşunca gerisi saf fonksiyon testi: girdi ver, string'i karşılaştır. Pipe'ı bir component'in içinde render edip DOM'dan (tarayıcının sayfayı tuttuğu element ağacı) okumak da mümkün ama bu, hata çıktığında sebebi template ile pipe arasında aratır. Pipe'ın kendi davranışını burada, template'teki kullanımını component testinde kontrol etmek daha net.
'tr' locale'inin testte neden çalıştığını 1. yazıda kurduk: providersFile her teste LOCALE_ID olarak 'tr' veriyor, setupFiles ise registerLocaleData(localeTr) çağırıyor. Bu kayıt olmasa ne olacağını, LOCALE_ID'yi kayıtlı olmayan 'de' ile değiştirerek denedim: Error: NG0701: Missing locale data for the locale "de". Locale ile çalışan pipe testleri kaldığında önce bu iki dosyaya bak.
Signal'ları ve Effect'leri Test Etmek
computed signal'lar senkron; okuduğun anda hesaplanıyor. CatalogService.inStock'u test etmek için ekstra bir şey gerekmiyor, kaynağını doldurup okumak yeterli:
it('lists only the books that are in stock', async () => {
TestBed.tick();
http.expectOne('/api/books').flush([dune, emma]);
await TestBed.inject(ApplicationRef).whenStable();
expect(catalog.inStock().map((book) => book.title)).toEqual(['Dune']);
});
Effect'ler ise planlanıyor. RecentlyViewed testinde bunu açıkça görmek için iki assertion yazdım: mark()'tan hemen sonra localStorage boş, TestBed.tick()'ten sonra dolu.
describe('RecentlyViewed', () => {
beforeEach(() => localStorage.clear());
it('saves the list to localStorage', () => {
const recent = TestBed.inject(RecentlyViewed);
recent.mark('9780441013593');
expect(localStorage.getItem('bookstore.recentlyViewed')).toBeNull();
TestBed.tick();
expect(localStorage.getItem('bookstore.recentlyViewed')).toBe('["9780441013593"]');
});
it('restores the list saved by an earlier visit', () => {
localStorage.setItem('bookstore.recentlyViewed', '["9780141439587"]');
const recent = TestBed.inject(RecentlyViewed);
expect(recent.list()).toEqual(['9780141439587']);
});
});
Birinci assertion bir implementation detayına bakıyor gibi görünebilir. Burada bilerek duruyor: effect'in planlandığını belgeliyor ve birisi kaydı senkron yapmaya kalkarsa ya da effect'i kaldırırsa test haber veriyor. beforeEach içindeki localStorage.clear() ise şart. TestBed injector'ı her testten sonra sıfırlıyor ama jsdom'un localStorage'ı aynı dosyadaki testler arasında yaşıyor; bir testte setItem yapıp sonraki testte getItem ile okuduğumda değer oradaydı. 1. yazıdaki module state'iyle aynı sorun, bu kez tarayıcı API'sinde.
httpResource Testleri: HttpTestingController ile İstekleri Yakalamak
provideHttpClientTesting(), HttpClient'ın backend'ini isteği gerçekten göndermeyen bir test backend'iyle değiştiriyor. İstekler HttpTestingController'da birikiyor; testte onları bulup cevaplıyorsun. Angular dokümanı provideHttpClient()'ın önce, provideHttpClientTesting()'in sonra yazılmasını söylüyor, çünkü ikincisi birincinin bazı parçalarını eziyor. Angular 21'den beri HttpClient root injector'da varsayılan olarak var; provideHttpClientTesting()'i tek başına yazdığımda da test çalıştı. provideHttpClient()'ı interceptor gibi bir özelleştirme test edeceğin zaman eklemek yeterli, Bookstore'da ikisini dokümandaki sırayla yazıyoruz. Test dosyasının iskeleti:
describe('CatalogService', () => {
let catalog: CatalogService;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
catalog = TestBed.inject(CatalogService);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
});
Service'in sözleşmesi istek tarafında: hangi URL'e, hangi parametrelerle gidiyor. expectOne bir URL string'i ya da bir fonksiyon alıyor. String verdiğinde query string dahil tam URL'le eşleşiyor; parametreyi ayrı kontrol etmek istediğinde fonksiyon daha okunaklı:
it('sends the selected category as a query parameter', () => {
catalog.category.set('classics');
TestBed.tick();
const request = http.expectOne((req) => req.url === '/api/books');
expect(request.request.params.get('category')).toBe('classics');
});
it('cancels the running request when the category changes', () => {
TestBed.tick();
const first = http.expectOne('/api/books');
catalog.category.set('classics');
TestBed.tick();
expect(first.cancelled).toBe(true);
http.expectOne('/api/books?category=classics');
});
İkinci test httpResource'un önemli bir davranışını kontrol ediyor: parametre değişince yolda olan isteği iptal ediyor. Kategoriyi hızlı değiştiren bir kullanıcıda eski cevabın yeni listeyi ezmemesi buna bağlı. TestRequest.cancelled bu davranışı doğrudan gösteriyor.
Hata yolu da test edilmeli. flush'a bir status kodu verince HttpClient bunu HttpErrorResponse olarak raporluyor:
it('moves to the error state when the API fails', async () => {
TestBed.tick();
http.expectOne('/api/books').flush({ title: 'Database unavailable' }, { status: 503, statusText: 'Service Unavailable' });
await TestBed.inject(ApplicationRef).whenStable();
expect(catalog.books.status()).toBe('error');
expect(catalog.books.error()).toBeInstanceOf(HttpErrorResponse);
expect((catalog.books.error() as HttpErrorResponse).status).toBe(503);
expect(() => catalog.books.value()).toThrow(/error state/);
});
Son satır gözden kaçması kolay bir davranışı sabitliyor: hata durumunda value() defaultValue'yu dönmüyor, exception fırlatıyor. Resource tip tanımı da bunu söylüyor ve hasValue() kullanmayı öneriyor. Mesaj "Resource is currently in an error state (see Error.cause for details)" ile başlıyor. inStock gibi value()'yu okuyan her computed de aynı hatayı fırlattı. Katalog sayfasının template'i inStock()'u @if (catalog.books.hasValue()) ya da status() kontrolü olmadan okursa API hatası ekrana boş bir liste olarak değil, bir runtime hatası olarak yansır. Bu da 3. yazıdaki component testinin kontrol etmesi gereken bir şey.
Network hatası için flush yerine request.error(new ProgressEvent('error')) var; dokümana göre HttpClient bunu status 0 olan bir hata olarak raporluyor. Bir resource'u elle yeniden yüklemek için reload() var: hata durumundayken reload() ve TestBed.tick() sonrasında durum reloading oldu.
Zaman: debounced() ve Fake Timer'lar
Arama kutusunda her tuşta istek atmamak için CatalogService arama terimini Angular 22 ile gelen debounced() ile geciktiriyor. debounced() 22.0'da experimental; bir signal değil, Resource dönüyor: value() gecikmeli değeri, isLoading() ise kullanıcının yazmaya devam ettiğini söylüyor. 300 ms'yi gerçekten beklemek testi yavaşlatır ve süreye bağlı hale getirir; Vitest'in fake timer'ları zamanı elle ilerletmeyi sağlıyor:
describe('CatalogService search', () => {
let catalog: CatalogService;
let http: HttpTestingController;
beforeEach(async () => {
vi.useFakeTimers();
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
catalog = TestBed.inject(CatalogService);
http = TestBed.inject(HttpTestingController);
TestBed.tick();
http.expectOne('/api/books').flush([dune, emma]);
await vi.advanceTimersByTimeAsync(0);
});
afterEach(() => {
http.verify();
vi.useRealTimers();
});
it('waits 300 ms after the last keystroke before searching', async () => {
catalog.search.set('du');
await vi.advanceTimersByTimeAsync(200);
catalog.search.set('dune ');
await vi.advanceTimersByTimeAsync(299);
TestBed.tick();
http.expectNone((req) => req.url === '/api/books');
await vi.advanceTimersByTimeAsync(1);
TestBed.tick();
http.expectOne('/api/books?search=dune').flush([dune]);
await vi.advanceTimersByTimeAsync(0);
expect(catalog.books.value()).toEqual([dune]);
});
});
Test dört şeyi birlikte kontrol ediyor: 200 ms sonra gelen ikinci tuş sayacı sıfırlıyor, son tuştan 299 ms sonra hâlâ istek yok, 300. milisaniyede tek bir istek gidiyor ve sondaki boşluk trim() ile atılmış. advanceTimersByTimeAsync senkron versiyonundan farklı olarak timer'lar arasında bekleyen promise'leri de çalıştırıyor; debounced() değeri bir promise üzerinden yayınladığı için async versiyon şart.
Dikkat edilecek nokta, fake timer açıkken whenStable()'ı kullanmamak. Aynı yükleme testini fake timer'la ve await appRef.whenStable() ile yazdığımda test 5013 ms sonra "Test timed out in 5000ms." hatasıyla kaldı. Sebebi Angular'ın zoneless scheduler'ı: scheduleCallbackWithRafRace adlı fonksiyonu senkronizasyonu bir setTimeout ile bir requestAnimationFrame arasındaki yarışa bağlıyor ve fake timer ikisini de durduruyor. Zaman sen ilerletmedikçe akmıyor, whenStable() da hiç dönmüyor. Fake timer'lı testlerde onun yerine await vi.advanceTimersByTimeAsync(0) kullanıyoruz.
Angular 22'de Resource Zamanlaması Değişti
Angular 22, stream kullanan resource'ların, dolayısıyla rxResource'un, değerini senkron olarak verebilmesini sağladı (PR #67382). Değişiklik SSR (sunucu tarafında render) senaryoları için yapıldı; ama PR merge edildikten sonra bazı unit testleri kırdığı için breaking change olarak işaretlendi. Farkı görmek için aynı testi Angular 21.2 ve 22.2 projelerinde çalıştırdım:
it('rxResource with a synchronous observable', async () => {
const books = TestBed.runInInjectionContext(() =>
rxResource({ stream: () => of(['Dune']), defaultValue: [] as string[] }),
);
const log = [`created: ${books.status()} ${JSON.stringify(books.value())}`];
TestBed.tick();
log.push(`after tick: ${books.status()} ${JSON.stringify(books.value())}`);
await Promise.resolve();
log.push(`after microtask: ${books.status()} ${JSON.stringify(books.value())}`);
throw new Error(log.join(' | '));
});
Angular 21.2: created: loading [] | after tick: loading [] | after microtask: resolved ["Dune"]
Angular 22.2: created: loading [] | after tick: resolved ["Dune"] | after microtask: resolved ["Dune"]
Angular 21'de TestBed.tick() sonrasında resource hâlâ loading idi, değer bir microtask sonra geliyordu. Angular 22'de senkron bir observable'ın değeri tick'in içinde geliyor. Servisi of(...) dönen bir stub ile test edip "yükleniyor göstergesi tick'ten sonra görünür" diye assertion yazan bir Angular 21 testi, 22'ye yükseltince kalıyor. Davranış kodda değil, testteki zamanlama varsayımında değişti. Bu yüzden assertion'ı ara durumlara değil, whenStable() sonrasındaki son duruma bağlamak testleri sürüm değişikliklerine karşı daha dayanıklı yapıyor. Ara durumu (loading göstergesi gibi) gerçekten test etmek gerekiyorsa, stub'ı senkron of() yerine testin kontrol ettiği bir Subject ile yazmak zamanlamayı açık hale getiriyor.
Üretimde Dikkat
Test edilen davranış service'in sözleşmesi olsun. CatalogService için bu, isteğin URL'i, parametreleri, iptal davranışı ve hata durumunda dışarıya ne verdiği. httpResource'un iç durumlarını tek tek doğrulamak Angular'ın kendi testlerinin işi; senin testlerin uygulamanın ondan beklediğini kontrol etmeli.
HttpTestingController gerçek bir backend'in davranışını bilmiyor. API sözleşmesi değişirse, örneğin category parametresinin adı genre olursa, bu testler yeşil kalmaya devam eder. Sözleşmenin iki tarafını birlikte, uçtan uca (E2E) kontrol etmek Integration ve E2E Test serisinin konusu; frontend tarafında 5. yazıdaki MSW (Mock Service Worker; HTTP isteklerini network katmanında yakalayıp sahte cevap döndüren library) handler'ları bu riski biraz azaltıyor, çünkü aynı handler'lar geliştirme sunucusunda da kullanılabiliyor.
Test süresine dikkat et. Bu yazının sonunda suite 5 dosya ve 22 testle 452 ms'de bitiyor; sürenin %65'i hâlâ ortam kurulumu. Gerçek zamanla bekleyen tek bir setTimeout(…, 300) bile bu süreyi ikiye katlardı. Zamana bağlı her testte fake timer kullan.
Sık Düşülen Tuzaklar
verify()'ı unutmak. afterEach içinde http.verify() yoksa, service'in beklenmeyen bir istek daha atması hiçbir testi kırmıyor. Bir denemede kategori değişikliğinden sonra yalnız ikinci isteği match ile karşıladım; verify() testi şu mesajla kırdı: Expected no open requests, found 1: GET /api/books. İptal edilen ilk istek de karşılanmadığı sürece açık sayılıyor.
TestBed.tick()'i atlamak. Yazının başındaki hata bu: resource loading görünüyor ama istek henüz yok. Signal'ı değiştirdikten sonra da aynısı geçerli; kategori değişince yeni istek bir sonraki tick'te gidiyor.
flush'tan hemen sonra değeri okumak. Cevap verildiği anda resource hâlâ loading. Arada await appRef.whenStable() ya da fake timer varsa await vi.advanceTimersByTimeAsync(0) olmalı.
Fake timer ile whenStable()'ı birlikte kullanmak. Test hata vermeden beş saniye bekleyip timeout'la kalıyor ve hata mesajı sebebi söylemiyor.
Hata durumunda value()'nun defaultValue döneceğini varsaymak. value() exception fırlatıyor; hem testte hem template'te status() ya da hasValue() ile kontrol et.
Küçük Bir Deneme
★☆☆ PricePipe'a negatif fiyatlar için bir test ekle. -5 için ne çıkıyor? Bookstore'da negatif fiyat bir hata mı, iade satırı mı? Karar verdikten sonra pipe'ı ve testi buna göre düzenle.
★★☆ Ağ hatasını test et: request.error(new ProgressEvent('error')) ile HttpErrorResponse'un status değerinin 0 olduğunu ve resource'un error durumuna geçtiğini doğrula. Ardından reload() çağırıp ikinci isteği başarılı cevapla ve durumun resolved'a döndüğünü kontrol et.
★★★ CatalogService'e stok bilgisini her 60 saniyede bir yenileyen bir polling ekle (setInterval + books.reload(), DestroyRef.onDestroy ile temizlik). Fake timer'la 59 ve 60. saniyede istek sayısını kontrol eden testi yaz; sonra TestBed.resetTestingModule() sonrasında interval'in durduğunu doğrula.
Service katmanı hazır. Bir sonraki yazıda bu service'leri kullanan component'lere geçiyoruz: BookCard'a setInput ile kitap vermek, output()'u dinlemek, zoneless ve varsayılan OnPush ile whenStable, @defer blokları ve content projection için host component.