Angular'da State'i Test Etmek: Signal Service'leri, Signal Store ve NgRx Store Testleri
State kodu public API'si üzerinden test edilir: Signal Store bir service gibi TestBed ile alınır, effect'ler TestBed.tick() ile çalıştırılır, HTTP HttpTestingController ile, zaman Vitest fake timer'larıyla kontrol edilir; klasik Store'un reducer, selector ve functional effect'leri ise saf fonksiyon olarak test edilir. Bookstore'un sepet, arama, checkout ve back office state'i için 19 Vitest testi yazıyor; TestBed.tick() unutulunca effect'in hiç çalışmadığını gerçek çıktıyla gösteriyoruz.
Sürüm notu: Bu yazıdaki testler Angular 22.2 (@angular/build:unit-test), Vitest 5.1, jsdom 30, NgRx 22.0.1 (@ngrx/signals, @ngrx/store, @ngrx/effects), TypeScript 6.0 ve Node.js 24 ile çalıştırılmıştır (Angular 22.2 projeleri TypeScript 6.0 kullanıyor).
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) Angular istemcisi bookstore-web'de sepet store'u, altıncı yazıdan beri her değişiklikte sepetin sürümlü bir kopyasını localStorage'a yazıyor; bunu store'un onInit'indeki bir effect yapıyor. Bunun için yazılmış ilk test:
it('saves a versioned copy after the effect runs', () => {
const cart = TestBed.inject(CartStore);
cart.add(dune);
expect(JSON.parse(localStorage.getItem('bookstore.cart')!)).toEqual({
version: 2,
items: [{ isbn: dune.isbn, title: 'Dune', price: 450, quantity: 1 }],
coupon: null,
});
});
Uygulamada aynı akış çalışıyor ve Playwright ile gözledik. Bu test geçer mi? Geçmezse localStorage.getItem ne döndürür: boş bir sepet mi, null mı? Tahminini yaz; cevap "Signal Store: Global Store, Effect'ler ve unprotected" bölümünde.
Bu yazıda serinin şimdiye kadar yazdığı state kodunu test ediyoruz: bir signal service, global ve local Signal Store'lar, rxMethod, bir custom feature, store kullanan bir component ve klasik Store'un reducer, selector ve effect'leri. Angular 22'nin varsayılan test kurulumu Vitest ve jsdom ile çalışan @angular/build:unit-test builder'ı; projede zaten vardı, yalnız *.spec.ts dosyalarını ekledik. Kurulumun kendisi ve "neyi hangi seviyede test etmeli" sorusu Frontend Testleri serisinin "Angular 22'de Test Altyapısı: Vitest, unit-test Builder'ı, jsdom ve Neyi Hangi Seviyede Test Etmeli?" yazısında. Testler tek komutla çalışıyor:
npm test -- --watch=false
Signal Service ve resource
Üçüncü yazıdaki Categories, bir httpResource'u paylaşan root bir @Service(). Testi:
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ApplicationRef } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Categories } from './categories';
describe('Categories (signal service with httpResource)', () => {
it('loads the list once and exposes it as a signal', async () => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
const categories = TestBed.inject(Categories);
const http = TestBed.inject(HttpTestingController);
expect(categories.list.value()).toEqual([]);
// resource isteğini params'ı hesaplanınca gönderir; bu bir effect içinde olur
TestBed.tick();
http.expectOne('http://localhost:5280/api/categories').flush([{ slug: 'classics', name: 'Classics' }]);
await TestBed.inject(ApplicationRef).whenStable();
expect(categories.list.status()).toBe('resolved');
expect(categories.list.value()).toEqual([{ slug: 'classics', name: 'Classics' }]);
});
});
Bir signal service, her service gibi TestBed.inject ile alınıyor ve signal'ları doğrudan okunuyor. Resource'un iki noktası var. Birincisi, istek service oluşturulur oluşturulmaz gitmiyor: resource, params'ını reactive olarak hesaplayıp isteği gönderiyor ve bu iş Angular'ın effect zamanlamasına bağlı. Testte TestBed.tick() bunu hemen çalıştırıyor; Angular 22'de eski TestBed.flushEffects() deprecated ve yerini bu aldı. İkincisi, flush'tan sonra yanıtın resource'a işlenmesi async; ApplicationRef.whenStable()'ı beklemek bunu garanti ediyor. HTTP tarafında provideHttpClientTesting() gerçek isteği HttpTestingController'a yönlendiriyor: expectOne isteği buluyor, flush yanıtı veriyor. Resource testlerinin ayrıntısı Frontend Testleri serisinin "Angular'da Service, Pipe, Signal ve Resource Testleri: TestBed.inject, HttpTestingController ve Fake Timer'lar" yazısında.
Signal Store: Global Store, Effect'ler ve unprotected
NgRx dokümantasyonu Signal Store'u "bir Angular service gibi test et" diye özetliyor: TestBed ile al, public API'sini kullan, sonucu oku. rxMethod, signalMethod ve inject() bir injection context istediği için store'u new ile oluşturmak işe yaramıyor. Sepetin testleri:
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { patchState } from '@ngrx/signals';
import { Dispatcher } from '@ngrx/signals/events';
import { unprotected } from '@ngrx/signals/testing';
import { ordersApiEvents } from '../checkout/checkout-events';
import type { Book } from '../models';
import { CartStore } from './cart-store';
const dune: Book = { isbn: '9780441172719', title: 'Dune', author: 'Frank Herbert', price: 450, stock: 12, category: 'science-fiction' };
const emma: Book = { isbn: '9780141439587', title: 'Emma', author: 'Jane Austen', price: 320, stock: 8, category: 'classics' };
describe('CartStore', () => {
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
});
it('adds one more copy when the same book is added again', () => {
const cart = TestBed.inject(CartStore);
cart.add(dune);
cart.add(dune);
cart.add(emma);
expect(cart.items().map((i) => `${i.title} x ${i.quantity}`)).toEqual(['Dune x 2', 'Emma x 1']);
expect(cart.count()).toBe(3);
expect(cart.totalPrice()).toBe(1220);
});
it('saves a versioned copy after the effect runs', () => {
const cart = TestBed.inject(CartStore);
cart.add(dune);
// effect'ler change detection sırasında çalışır; testte onları TestBed.tick() çalıştırır
TestBed.tick();
expect(JSON.parse(localStorage.getItem('bookstore.cart')!)).toEqual({
version: 2,
items: [{ isbn: dune.isbn, title: 'Dune', price: 450, quantity: 1 }],
coupon: null,
});
});
it('reads a cart saved in the old array format', () => {
localStorage.setItem('bookstore.cart', JSON.stringify([{ isbn: dune.isbn, title: 'Dune', price: 450, quantity: 2 }]));
const cart = TestBed.inject(CartStore);
expect(cart.count()).toBe(2);
});
it('computes the discount from a coupon put into state with unprotected()', () => {
const cart = TestBed.inject(CartStore);
cart.add(dune);
cart.add(emma);
// kupon için public setter yok (yalnız HTTP yapan applyCoupon): state'i doğrudan hazırla
patchState(unprotected(cart), { coupon: { code: 'BOOKWEEK', percent: 10 } });
expect(cart.discount()).toBe(77);
expect(cart.payable()).toBe(693);
});
it('applies a coupon from the API and reports an unknown one', async () => {
const cart = TestBed.inject(CartStore);
const http = TestBed.inject(HttpTestingController);
const ok = cart.applyCoupon('BOOKWEEK');
http.expectOne('http://localhost:5280/api/coupons/BOOKWEEK').flush({ code: 'BOOKWEEK', percent: 10 });
await ok;
expect(cart.coupon()).toEqual({ code: 'BOOKWEEK', percent: 10 });
const unknown = cart.applyCoupon('FREE50');
expect(cart.isPending()).toBe(true);
http.expectOne('http://localhost:5280/api/coupons/FREE50').flush(null, { status: 404, statusText: 'Not Found' });
await unknown;
expect(cart.coupon()).toBeNull();
expect(cart.requestError()).toBe('HTTP 404');
http.verify();
});
it('empties itself when an order is placed', () => {
const cart = TestBed.inject(CartStore);
cart.add(dune);
TestBed.inject(Dispatcher).dispatch(
ordersApiEvents.orderPlaced({ id: 1043, items: cart.items(), shipping: 'standard', total: 450 }),
);
expect(cart.count()).toBe(0);
});
});
"Önce Tahmin Et"teki testin, yani TestBed.tick() satırı olmayan hâlinin sonucu:
RUN v5.1.0 /home/user/bookstore/bookstore-web
❯ |bookstore-web| src/app/cart/cart-store.spec.ts (6 tests | 1 failed) 16ms
❯ CartStore (6)
× saves a versioned copy after the effect runs 3ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL |bookstore-web| src/app/cart/cart-store.spec.ts > CartStore > saves a versioned copy after the effect runs
AssertionError: expected null to deeply equal { version: 2, …(2) }
- Expected:
{
"coupon": null,
"items": [
{
"isbn": "9780441172719",
"price": 450,
"quantity": 1,
"title": "Dune",
},
],
"version": 2,
}
+ Received:
null
❯ src/app/cart/cart-store.spec.ts:33:65
31| const cart = TestBed.inject(CartStore);
32| cart.add(dune);
33| expect(JSON.parse(localStorage.getItem('bookstore.cart')!)).toEqua…
| ^
34| version: 2,
35| items: [{ isbn: dune.isbn, title: 'Dune', price: 450, quantity: …
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Test Files 1 failed (1)
Tests 1 failed | 5 passed (6)
Start at 22:19:18
Duration 395ms (environment 63%, setup 23%, transform 7%, tests 5%, import 2%, worker 1%)
Boş bir sepet bile değil, null: effect hiç çalışmadı. Uygulamada effect'ler change detection sırasında çalışıyor; testte change detection yok, test senkron bitiyor ve effect sıradaki işini hiç yapamıyor. Asıl testteki TestBed.tick() bekleyen effect'leri hemen çalıştırıyor ve test geçiyor. İkinci yazıda effect'leri testte tetiklemenin bu yazının konusu olduğunu söylemiştik; cevap bu satır.
Diğer testlerden üçü üzerinde durmaya değer. unprotected(), @ngrx/signals/testing'den geliyor ve korumalı bir store'un yazılabilir bir görünümünü döndürüyor; kuponun public bir setter'ı yok (yalnız HTTP yapan applyCoupon var), indirim hesabını test etmek için state'i doğrudan hazırlamak bu yüzden makul. NgRx dokümantasyonu onu tam bu durum için, yani gerekli public setter'lar olmadığında state hazırlamak için öneriyor. Async method testinde Promise önce başlatılıyor, sonra HttpTestingController ile yanıt veriliyor ve en son Promise bekleniyor; arada isPending()'in true olduğu da görülüyor. Son test, yedinci yazıdaki event akışının sepet tarafı: Dispatcher ile orderPlaced dispatch edildiğinde sepet boşalıyor. Reducer'ı ayrıca çağırmaya gerek yok; root store'un reducer'ı gerçek dispatcher'a bağlı.
Local Store ve Sahte Bağımlılık
Checkout store'u providedIn olmadan yazılmıştı; testte onu testin kendi providers'ına koyuyoruz. Bağımlılığı olan CartStore'un yerine yalnız okunan alanı içeren bir sahte object veriyoruz:
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { CartStore } from '../cart/cart-store';
import { CheckoutStore } from './checkout-store';
describe('CheckoutStore', () => {
// bağımlılık, yalnız CheckoutStore'un okuduğu alanı içeren bir sahteyle değiştirilir
const payable = signal(450);
beforeEach(() => {
TestBed.configureTestingModule({
providers: [CheckoutStore, { provide: CartStore, useValue: { payable } }],
});
});
it('charges standard shipping below 500 TRY and nothing above', () => {
const checkout = TestBed.inject(CheckoutStore);
expect(checkout.shippingPrice()).toBe(40);
payable.set(620);
expect(checkout.shippingPrice()).toBe(0);
});
it('charges 90 TRY for express shipping', () => {
const checkout = TestBed.inject(CheckoutStore);
checkout.chooseShipping('express');
expect(checkout.payable()).toBe(620 + 90);
});
});
Sahte payable gerçek bir signal olduğu için, değeri değiştirince checkout store'unun computed'ları da değişiyor; test böylece kargo kuralını sepetin tamamını kurmadan sınıyor. Sahte object'te yalnız store'un gerçekten okuduğu alanlar var; CartStore'un API'si değişirse TypeScript bunu burada yakalamıyor (useValue tipsiz), bu yüzden sahteyi küçük tutmak ve gerçek store'un kendi testlerine güvenmek gerekiyor. Mock'un ne zaman zarar verdiği Unit Test serisinin "Mock'u Ne Zaman Kullanmamalı: Over-mocking, Kırılgan Testler ve "Sahibi Olmadığını Mock'lama"" yazısında.
Test log'unda checkout store'unun withHooks satırları da görünüyor: her test kendi store instance'ını oluşturuyor ve test bitince TestBed onu destroy ediyor. Root bir store da her test için yeniden oluşuyor; TestBed her testten sonra kendi injector'ını sıfırlıyor.
rxMethod ve Fake Timer'lar
Altıncı yazıdaki arama store'u, debounceTime(300) ve switchMap ile bir rxMethod kullanıyor. 300 ms'yi gerçekten beklemek yerine Vitest'in fake timer'larıyla zamanı ilerletiyoruz:
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import type { Book } from '../models';
import { SearchStore } from './search-store';
const dune = { isbn: '9780441172719', title: 'Dune' } as Book;
const page = (items: Book[]) => ({ items, total: items.length, page: 1, pageSize: 2 });
describe('SearchStore', () => {
let store: InstanceType<typeof SearchStore>;
let http: HttpTestingController;
beforeEach(() => {
vi.useFakeTimers();
TestBed.configureTestingModule({ providers: [SearchStore, provideHttpClient(), provideHttpClientTesting()] });
store = TestBed.inject(SearchStore);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => vi.useRealTimers());
// sorgu signal'ı rxMethod'a bir effect üzerinden ulaşır (TestBed.tick), sonra debounceTime 300 ms bekler
function type(query: string): void {
store.setQuery(query);
TestBed.tick();
vi.advanceTimersByTime(300);
}
it('waits for a pause and cancels the request that is no longer needed', () => {
type('du');
const first = http.expectOne((r) => r.params.get('q') === 'du');
type('dun');
const second = http.expectOne((r) => r.params.get('q') === 'dun');
expect(first.cancelled).toBe(true);
second.flush(page([dune]));
expect(store.results()).toEqual([dune]);
expect(store.highlightedItem()).toEqual(dune);
});
it('keeps searching after a failed request', () => {
type('em');
http.expectOne((r) => r.params.get('q') === 'em').flush(null, { status: 503, statusText: 'Service Unavailable' });
expect(store.requestError()).toBe('HTTP 503');
type('dune');
http.expectOne((r) => r.params.get('q') === 'dune').flush(page([dune]));
expect(store.requestStatus()).toBe('fulfilled');
});
});
type yardımcısı üç adımı açıkça yapıyor: state'i değiştir, TestBed.tick() ile rxMethod'un içindeki effect'i çalıştırıp sorguyu stream'e sok, sonra vi.advanceTimersByTime(300) ile debounce'u geçir. İlk test switchMap'in iptal davranışını doğruluyor: HttpTestingController'ın cancelled alanı, "du" isteğinin "dun" gelince iptal edildiğini gösteriyor. İkinci test, altıncı yazıdaki tapResponse tuzağının regresyon testi: 503'ten sonra bir sonraki sorgu hâlâ istek gönderiyor. NgRx dokümantasyonu rxMethod'u bir Observable ile çağırarak test etmeyi de gösteriyor; biz store'u uygulamadaki gibi, signal üzerinden kullandık.
Custom Feature'ı Test Etmek
import { HttpErrorResponse } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore, withMethods } from '@ngrx/signals';
import { setError, setFulfilled, setPending, withRequestStatus } from './with-request-status';
// Bir feature, onu kullanan en küçük store üzerinden test edilir
const TestStore = signalStore(
{ providedIn: 'root' },
withRequestStatus(),
withMethods((store) => ({
start: () => patchState(store, setPending()),
succeed: () => patchState(store, setFulfilled()),
fail: (error: unknown) => patchState(store, setError(error)),
})),
);
describe('withRequestStatus', () => {
it('starts idle and tracks a request', () => {
const store = TestBed.inject(TestStore);
expect(store.requestStatus()).toBe('idle');
store.start();
expect(store.isPending()).toBe(true);
store.succeed();
expect(store.requestStatus()).toBe('fulfilled');
});
it('turns an HTTP error into a short message', () => {
expect(setError(new HttpErrorResponse({ status: 503 }))).toEqual({ requestStatus: 'error', requestError: 'HTTP 503' });
});
});
Bir feature tek başına inject edilemiyor; onu kullanan en küçük store'u testte tanımlamak yeterli. Updater'lar düz fonksiyon olduğu için (setError) TestBed olmadan, girdi ve çıktısıyla test ediliyor; altıncı yazıda updater'ları method değil fonksiyon yazmanın gerekçelerinden biri buydu.
Component Testinde Sahte Store
Arama kutusu store'unu kendi providers'ında sağlıyor. Böyle bir store'u testte değiştirmek için testin providers'ı yetmiyor, çünkü component kendi provider'ını kullanıyor; TestBed.overrideComponent ile component'in provider listesini değiştiriyoruz:
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import type { Book } from '../models';
import { SearchBox } from './search-box';
import { SearchStore } from './search-store';
describe('SearchBox', () => {
it('renders the store state and sends typing to the store', async () => {
// sahte bir store: component testi store'u değil component'i sınar
const fake = {
query: signal('em'),
results: signal([{ isbn: '1', title: 'Emma' }, { isbn: '2', title: 'Middlemarch' }] as Book[]),
highlighted: signal(1),
highlightedItem: signal(null),
isPending: signal(false),
requestError: signal<string | null>(null),
setQuery: vi.fn(),
move: vi.fn(),
};
TestBed.configureTestingModule({ imports: [SearchBox], providers: [provideRouter([])] });
// store component'in kendi providers'ından gelir; bu yüzden orada değiştirilir
TestBed.overrideComponent(SearchBox, { set: { providers: [{ provide: SearchStore, useValue: fake }] } });
const fixture = TestBed.createComponent(SearchBox);
await fixture.whenStable();
const element: HTMLElement = fixture.nativeElement;
expect(element.querySelector('li.highlighted')?.textContent?.trim()).toBe('Middlemarch');
const input = element.querySelector('input')!;
input.value = 'emm';
input.dispatchEvent(new Event('input'));
expect(fake.setQuery).toHaveBeenCalledWith('emm');
});
});
Sahte store, template'in okuduğu signal'lar ve component'in çağırdığı method'lar (vi.fn()) ile kuruluyor. Test iki şey soruyor: store'un state'i doğru render ediliyor mu (seçili öneri highlighted class'ını alıyor mu) ve kullanıcının yazdığı store'a gidiyor mu. Store'un kendi mantığı burada test edilmiyor; o önceki bölümde. Zoneless bir uygulamada fixture.whenStable(), change detection'ın çalışıp template'in güncellenmesini bekliyor.
Klasik Store: Reducer, Selector, Effect ve MockStore
Klasik Store'un en kolay test edilen yanı, reducer ve selector'ların saf fonksiyon olması:
import type { AdminOrder } from '../models';
import { adminOrdersApiActions, adminOrdersPageActions } from './orders.actions';
import { adminOrdersFeature } from './orders.feature';
const orders: AdminOrder[] = [
{ id: 1041, customer: 'Alan', items: [], total: 640, status: 'new' },
{ id: 1040, customer: 'Ada', items: [], total: 490, status: 'shipped' },
];
describe('adminOrdersFeature', () => {
// reducer'lar ve selector'lar saf fonksiyon: TestBed gerekmez
const { reducer, selectVisibleOrders, selectNewCount } = adminOrdersFeature;
it('replaces a shipped order and clears its pending flag', () => {
let state = reducer(undefined, adminOrdersApiActions.ordersLoaded({ orders }));
state = reducer(state, adminOrdersPageActions.shipClicked({ id: 1041 }));
expect(state.shippingIds).toEqual([1041]);
state = reducer(state, adminOrdersApiActions.orderShipped({ order: { ...orders[0], status: 'shipped' } }));
expect(state.shippingIds).toEqual([]);
expect(state.orders.map((o) => o.status)).toEqual(['shipped', 'shipped']);
});
it('filters by status through the projector', () => {
expect(selectVisibleOrders.projector(orders, 'new').map((o) => o.id)).toEqual([1041]);
expect(selectNewCount.projector(orders)).toBe(1);
});
});
Reducer, bir state ve bir action ile çağrılıyor (undefined başlangıç state'ini veriyor). Selector'ların projector'ı, input selector'ları atlayıp yalnız hesabı test etmeye izin veriyor. Functional effect'ler de birer fonksiyon; bağımlılıklarını varsayılan parametre olarak aldıkları için testte sahte argümanlarla çağrılıyorlar:
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { firstValueFrom, of, throwError } from 'rxjs';
import type { AdminOrder } from '../models';
import { adminOrdersApiActions, adminOrdersPageActions } from './orders.actions';
import { loadOrders } from './orders.effects';
describe('loadOrders effect', () => {
// functional effect bir fonksiyon: bağımlılıkları argüman olarak verilir
it('maps the response to Orders Loaded', async () => {
const orders = [{ id: 1041 } as AdminOrder];
const http = { get: () => of(orders) } as unknown as HttpClient;
const action = await firstValueFrom(loadOrders(of(adminOrdersPageActions.opened()), http));
expect(action).toEqual(adminOrdersApiActions.ordersLoaded({ orders }));
});
it('maps an HTTP error to Orders Load Failed', async () => {
const http = { get: () => throwError(() => new HttpErrorResponse({ status: 500 })) } as unknown as HttpClient;
const action = await firstValueFrom(loadOrders(of(adminOrdersPageActions.opened()), http));
expect(action).toEqual(adminOrdersApiActions.ordersLoadFailed({ error: 'HTTP 500' }));
});
});
Ne TestBed ne provideMockActions gerekiyor: of(opened()) bir action stream'i, { get: () => of(orders) } bir HttpClient yerine geçiyor. Store'u okuyan component için ise provideMockStore:
import { TestBed } from '@angular/core/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import type { AdminOrder } from '../models';
import { AdminOrdersPage } from './admin-orders-page';
import { adminOrdersPageActions } from './orders.actions';
import { adminOrdersFeature } from './orders.feature';
describe('AdminOrdersPage', () => {
it('shows the selected orders and dispatches Ship Clicked', async () => {
TestBed.configureTestingModule({
imports: [AdminOrdersPage],
// MockStore: selector'lar sabit değer döndürür, dispatch edilen action'lar yalnız spy'a ulaşır
providers: [
provideMockStore({
selectors: [
{ selector: adminOrdersFeature.selectVisibleOrders, value: [{ id: 1042, customer: 'Grace', total: 430, status: 'new' } as AdminOrder] },
{ selector: adminOrdersFeature.selectNewCount, value: 1 },
{ selector: adminOrdersFeature.selectStatusFilter, value: 'new' },
{ selector: adminOrdersFeature.selectShippingIds, value: [] },
{ selector: adminOrdersFeature.selectError, value: null },
],
}),
],
});
const store = TestBed.inject(MockStore);
const dispatch = vi.spyOn(store, 'dispatch');
const fixture = TestBed.createComponent(AdminOrdersPage);
await fixture.whenStable();
const element: HTMLElement = fixture.nativeElement;
expect(element.querySelector('h1')?.textContent).toContain('(1 new)');
element.querySelector<HTMLButtonElement>('button.ship')!.click();
expect(dispatch).toHaveBeenCalledWith(adminOrdersPageActions.shipClicked({ id: 1042 }));
});
});
MockStore'da selector'lar verilen sabit değerleri döndürüyor ve dispatch edilen action'lar hiçbir reducer'a gitmiyor; test yalnız component'in doğru action'ı doğru payload'la dispatch ettiğini kontrol ediyor. selectSignal da MockStore ile çalışıyor; component kodunda hiçbir şey değişmedi.
Üretimde Dikkat
Bütün testler birlikte (store'ların console satırları çıkarıldı):
RUN v5.1.0 /home/user/bookstore/bookstore-web
✓ |bookstore-web| src/app/shared/with-request-status.spec.ts > withRequestStatus > starts idle and tracks a request 8ms
✓ |bookstore-web| src/app/shared/with-request-status.spec.ts > withRequestStatus > turns an HTTP error into a short message 1ms
✓ |bookstore-web| src/app/admin/orders.feature.spec.ts > adminOrdersFeature > replaces a shipped order and clears its pending flag 2ms
✓ |bookstore-web| src/app/admin/orders.feature.spec.ts > adminOrdersFeature > filters by status through the projector 1ms
✓ |bookstore-web| src/app/admin/orders.effects.spec.ts > loadOrders effect > maps the response to Orders Loaded 2ms
✓ |bookstore-web| src/app/admin/orders.effects.spec.ts > loadOrders effect > maps an HTTP error to Orders Load Failed 0ms
✓ |bookstore-web| src/app/checkout/checkout-store.spec.ts > CheckoutStore > charges standard shipping below 500 TRY and nothing above 16ms
✓ |bookstore-web| src/app/checkout/checkout-store.spec.ts > CheckoutStore > charges 90 TRY for express shipping 4ms
✓ |bookstore-web| src/app/search/search-store.spec.ts > SearchStore > waits for a pause and cancels the request that is no longer needed 17ms
✓ |bookstore-web| src/app/search/search-store.spec.ts > SearchStore > keeps searching after a failed request 4ms
✓ |bookstore-web| src/app/catalog/categories.spec.ts > Categories (signal service with httpResource) > loads the list once and exposes it as a signal 29ms
✓ |bookstore-web| src/app/cart/cart-store.spec.ts > CartStore > adds one more copy when the same book is added again 22ms
✓ |bookstore-web| src/app/cart/cart-store.spec.ts > CartStore > saves a versioned copy after the effect runs 6ms
✓ |bookstore-web| src/app/cart/cart-store.spec.ts > CartStore > reads a cart saved in the old array format 2ms
✓ |bookstore-web| src/app/cart/cart-store.spec.ts > CartStore > computes the discount from a coupon put into state with unprotected() 1ms
✓ |bookstore-web| src/app/cart/cart-store.spec.ts > CartStore > applies a coupon from the API and reports an unknown one 3ms
✓ |bookstore-web| src/app/cart/cart-store.spec.ts > CartStore > empties itself when an order is placed 1ms
✓ |bookstore-web| src/app/admin/admin-orders-page.spec.ts > AdminOrdersPage > shows the selected orders and dispatches Ship Clicked 37ms
✓ |bookstore-web| src/app/search/search-box.spec.ts > SearchBox > renders the store state and sends typing to the store 32ms
Test Files 9 passed (9)
Tests 19 passed (19)
Start at 22:19:08
Duration 1.12s (environment 61%, setup 28%, transform 7%, tests 2%, import 2%, worker 1%)
19 test 1,1 saniyede çalışıyor; CI'da her commit'te çalıştırmamak için bir neden yok. Hız, testlerin büyük kısmının TestBed'i yalnız injector olarak kullanmasından ve gerçek bir tarayıcı ya da sunucu gerektirmemesinden geliyor. Buna karşılık bu testler event akışının uçtan uca doğru bağlandığını, route seviyesindeki store'un gerçekten destroy edildiğini ya da localStorage'daki bir kaydın tarayıcıda okunduğunu göstermiyor; serinin her yazısında kullandığımız Playwright senaryoları bunun için. İkisi birbirinin yerini tutmuyor.
Testleri store'un public API'si üzerinden yazmak, refactoring'de kazandırıyor: altıncı yazıda sepeti entity'lere taşıdığımızda cart.items(), cart.count() ve cart.add() aynı kaldı; bu testler o değişikliğe dokunmadan geçerdi. _lineEntityMap gibi private bir alanı okuyan bir test ise ilk refactoring'de kırılırdı.
Sık Düşülen Tuzaklar
TestBed.tick()'i unutmak
"Önce Tahmin Et"teki localStorage testinin yanında, arama testinde de type yardımcısından TestBed.tick()'i çıkarınca:
RUN v5.1.0 /home/user/bookstore/bookstore-web
❯ |bookstore-web| src/app/search/search-store.spec.ts (2 tests | 2 failed) 15ms
❯ SearchStore (2)
× waits for a pause and cancels the request that is no longer needed 13ms
× keeps searching after a failed request 2ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯
FAIL |bookstore-web| src/app/search/search-store.spec.ts > SearchStore > waits for a pause and cancels the request that is no longer needed
Error: Expected one matching request for criteria "Match by function: ", found none.
❯ HttpClientTestingBackend.expectOne ../k8-fastbuild-ST-fdfa778d11ba/bin/packages/common/http/testing/src/backend.ts:112:13
❯ src/app/search/search-store.spec.ts:33:25
31| const first = http.expectOne((r) => r.params.get('q') === 'du');
32| type('dun');
33| const second = http.expectOne((r) => r.params.get('q') === 'dun');
| ^
34| expect(first.cancelled).toBe(true);
35| second.flush(page([dune]));
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯
İlk sorgunun isteği (du) bulundu, ikincisininki (dun) bulunamadı. İlk sorgu geçti, ikincisi geçmedi: effect'in ne zaman çalışacağı Angular'ın zamanlamasına kalmış durumda ve fake timer'lar bu zamanlamayı da etkiliyor. Test bu hâliyle zamanlamaya bağlı; hangi adımın geçeceği belirsiz. Signal'dan beslenen bir rxMethod, signalMethod ya da store effect'i test ediyorsan, state'i değiştirdikten sonra TestBed.tick() çağır.
Private state'e ulaşmak için store'u kırmak
(store as any)._lineEntityMap() ya da her teste unprotected ile state yazmak, testi store'un iç yapısına bağlıyor. NgRx dokümantasyonunun ilkesi açık: yalnız public API. unprotected'ı, kupon örneğindeki gibi public bir setter'ı olmayan bir başlangıç state'i hazırlamak için kullan; sonucu yine public signal'lardan oku.
Store'un method'larını spy'lamak
Store'u tek başına test ederken vi.spyOn(store, 'add') ile method'un çağrıldığını doğrulamak, davranış yerine implementation'ı test ediyor. NgRx dokümantasyonu bunun yerine, büyüyen bir method'un mantığını bir service'e taşıyıp testte o service'i sahtelemeyi öneriyor. Component testindeki vi.fn() farklı: orada sahte olan store'un kendisi ve soru "component doğru method'u çağırdı mı?".
MockStore ile reducer'ı test ettiğini sanmak
provideMockStore ile yazılmış bir component testi geçtiğinde, dispatch edilen action'ın state'i doğru değiştirdiği kanıtlanmış olmuyor; MockStore'da reducer çalışmıyor. Reducer'ın kendi testi ayrı olmalı (bu yazıdaki gibi) ya da gerçek provideStore ile bir entegrasyon testi yazılmalı.
Küçük Bir Deneme
★☆☆ RecentlyViewedStore için bir test yaz: track'i dört farklı kitapla çağır ve viewedEntities()'in en son üç kitabı en yenisi başta olacak şekilde tuttuğunu doğrula. signalMethod'u bir değerle çağırdığında TestBed.tick() gerekiyor mu, bir signal ile çağırdığında?
★★☆ OrdersStore'un withEventHandlers'ını test et: checkoutEvents.orderSubmitted'i dispatch et, HttpTestingController ile POST /api/orders'a 201 ver ve store'da siparişin göründüğünü, 409 verdiğinde ise requestError'ın HTTP 409 olduğunu doğrula.
★★★ shipOrder effect'i için bir test yaz: iki farklı sipariş için art arda shipClicked gelsin ve ilk yanıt ikincisinden sonra dönsün (Subject ile kontrol edilen sahte bir http.post). mergeMap'i switchMap ile değiştirince hangi test kırılıyor ve bu kırılma neden doğru bir kırılma?
Serinin son yazısında bütün bu araçları yan yana koyuyoruz: local signal'dan klasik Store'a kadar hangi state için hangi araç, Bookstore'un feature'ları için yaptığımız seçimler ve Angular'da state yönetiminin BehaviorSubject'ten Signal Store'a nasıl evrildiği.