Testing State in Angular: Signal Services, Signal Store and NgRx Store Tests
State code is tested through its public API: a Signal Store is obtained from TestBed like a service, effects are run with TestBed.tick(), HTTP is controlled with HttpTestingController and time with Vitest fake timers, while the classic Store's reducers, selectors and functional effects are tested as pure functions. We write 19 Vitest tests for Bookstore's cart, search, checkout and back office state and show with real output that an effect never runs when TestBed.tick() is forgotten.
Version note: The tests in this post were run with 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 and Node.js 24 (Angular 22.2 projects use TypeScript 6.0).
Predict First
In bookstore-web, the Angular client of Bookstore, the online bookstore used as the shared example across this site's series, the cart store has been writing a versioned copy of the cart to localStorage on every change since the sixth post; an effect in the store's onInit does that. The first test written for it:
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,
});
});
The same flow works in the app, and we observed it with Playwright. Does this test pass? If not, what does localStorage.getItem return: an empty cart or null? Write down your prediction; the answer is in the "Signal Store: a Global Store, Effects and unprotected" section.
In this post we test the state code the series has written so far: a signal service, global and local Signal Stores, rxMethod, a custom feature, a component that uses a store, and the reducers, selectors and effects of the classic Store. Angular 22's default test setup is the @angular/build:unit-test builder running on Vitest and jsdom; it was already in the project, we only added the *.spec.ts files. The setup itself and the question of what to test at which level are covered in the Frontend Testing series post "Testing Infrastructure in Angular 22: Vitest, the unit-test Builder, jsdom and What to Test at Which Level". The tests run with a single command:
npm test -- --watch=false
A Signal Service and a resource
Categories from the third post is a root @Service() that shares an httpResource. Its test:
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([]);
// the resource sends its request when its params are computed, which happens in an effect
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' }]);
});
});
A signal service is obtained with TestBed.inject like any service, and its signals are read directly. The resource has two points to watch. First, the request is not sent the moment the service is created: the resource computes its params reactively and then sends the request, and that is tied to Angular's effect scheduling. In a test, TestBed.tick() runs it right away; in Angular 22 the old TestBed.flushEffects() is deprecated and replaced by this. Second, applying the response to the resource after flush is async; awaiting ApplicationRef.whenStable() guarantees it. On the HTTP side, provideHttpClientTesting() routes the real request to HttpTestingController: expectOne finds the request and flush answers it. Resource tests are covered in detail in the Frontend Testing series post "Testing Services, Pipes, Signals and Resources in Angular: TestBed.inject, HttpTestingController and Fake Timers".
Signal Store: a Global Store, Effects and unprotected
The NgRx documentation sums up Signal Store testing as "test it like an Angular service": get it from TestBed, use its public API, read the result. Since rxMethod, signalMethod and inject() need an injection context, creating the store with new does not work. The cart's tests:
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);
// effects run during change detection; in a test, TestBed.tick() runs them
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);
// no public setter for coupon (only applyCoupon over HTTP): prepare the state directly
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);
});
});
The result of the test from "Predict First", the version without the TestBed.tick() line:
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%)
Not even an empty cart, but null: the effect never ran. In the app, effects run during change detection; in the test there is no change detection, the test finishes synchronously and the effect never gets its turn. The TestBed.tick() in the real test runs pending effects right away, and the test passes. In the second post we said that triggering effects in tests was the subject of this post; this line is the answer.
Three of the other tests are worth a closer look. unprotected() comes from @ngrx/signals/testing and returns a writable view of a protected store; the coupon has no public setter (there is only applyCoupon, which makes an HTTP call), so preparing the state directly to test the discount calculation is reasonable. The NgRx documentation recommends it exactly for this case, preparing state when the necessary public setters are not there. In the async method test the Promise is started first, then the response is given through HttpTestingController, and the Promise is awaited last; in between you can also see isPending() being true. The last test is the cart side of the event flow from the seventh post: when orderPlaced is dispatched with the Dispatcher, the cart empties. There is no need to call the reducer separately; the root store's reducer is connected to the real dispatcher.
A Local Store and a Fake Dependency
The checkout store was written without providedIn; in the test we put it into the test's own providers. In place of its dependency CartStore we give a fake object containing only the field that is read:
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { CartStore } from '../cart/cart-store';
import { CheckoutStore } from './checkout-store';
describe('CheckoutStore', () => {
// the dependency is replaced by a fake with only what CheckoutStore reads
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);
});
});
Because the fake payable is a real signal, changing its value changes the checkout store's computed values too; the test checks the shipping rule without building the whole cart. The fake object only has the fields the store really reads; if the CartStore API changes, TypeScript does not catch it here (useValue is untyped), so keep the fake small and rely on the real store's own tests. When mocks do harm is covered in the Unit Test series post "When Not to Mock: Over-Mocking, Brittle Tests and "Don't Mock What You Don't Own"".
The checkout store's withHooks lines appear in the test log too: each test creates its own store instance, and TestBed destroys it when the test ends. A root store is also created again for every test; TestBed resets its injector after each test.
rxMethod and Fake Timers
The search store from the sixth post uses an rxMethod with debounceTime(300) and switchMap. Instead of really waiting 300 ms, we move time forward with Vitest's fake timers:
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());
// the query signal reaches rxMethod through an effect (TestBed.tick), then debounceTime waits 300 ms
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');
});
});
The type helper does three steps explicitly: change the state, run the effect inside rxMethod with TestBed.tick() so the query enters the stream, then pass the debounce with vi.advanceTimersByTime(300). The first test checks switchMap's cancellation: the cancelled field of HttpTestingController shows that the "du" request was cancelled when "dun" arrived. The second test is a regression test for the tapResponse pitfall from the sixth post: after a 503, the next query still sends a request. The NgRx documentation also shows testing rxMethod by calling it with an Observable; we used the store as the app does, through a signal.
Testing a Custom Feature
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';
// A feature is tested through a minimal store that uses it
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' });
});
});
A feature cannot be injected on its own; defining the smallest store that uses it in the test is enough. Because updaters are plain functions (setError), they are tested by input and output without TestBed; that was one of the reasons in the sixth post for writing updaters as functions rather than methods.
A Fake Store in a Component Test
The search box provides its store in its own providers. To replace such a store in a test, the test's providers are not enough, because the component uses its own provider; we change the component's provider list with TestBed.overrideComponent:
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 () => {
// a fake store: the component test checks the component, not the store
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([])] });
// the store comes from the component's own providers, so it is replaced there
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');
});
});
The fake store is built from the signals the template reads and the methods the component calls (vi.fn()). The test asks two things: is the store's state rendered correctly (does the selected suggestion get the highlighted class), and does what the user types reach the store? The store's own logic is not tested here; that was the previous section. In a zoneless app, fixture.whenStable() waits for change detection to run and the template to update.
Classic Store: Reducers, Selectors, Effects and MockStore
The easiest part of the classic Store to test is that reducers and selectors are pure functions:
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', () => {
// reducers and selectors are pure functions: no TestBed needed
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);
});
});
The reducer is called with a state and an action (undefined gives the initial state). A selector's projector lets you skip the input selectors and test only the calculation. Functional effects are functions too; because they take their dependencies as default parameters, the test calls them with fake arguments:
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', () => {
// a functional effect is a function: its dependencies are passed as arguments
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' }));
});
});
Neither TestBed nor provideMockActions is needed: of(opened()) is an action stream, and { get: () => of(orders) } stands in for an HttpClient. For the component that reads the store, 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: selectors return fixed values, dispatched actions only reach the spy
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 }));
});
});
In MockStore, selectors return the given fixed values and dispatched actions reach no reducer; the test only checks that the component dispatches the right action with the right payload. selectSignal works with MockStore as well; nothing changed in the component code.
In Production
All the tests together (the stores' console lines removed):
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 tests run in 1.1 seconds; there is no reason not to run them on every commit in CI. The speed comes from most tests using TestBed only as an injector and needing no real browser or server. On the other hand, these tests do not show that the event flow is wired correctly end to end, that the route-level store is really destroyed, or that a record in localStorage is read in the browser; that is what the Playwright scenarios used in every post of this series are for. Neither replaces the other.
Writing tests against the store's public API pays off when you refactor: when we moved the cart to entities in the sixth post, cart.items(), cart.count() and cart.add() stayed the same; these tests would have passed through that change untouched. A test that reads a private field such as _lineEntityMap would have broken at the first refactoring.
Common Pitfalls
Forgetting TestBed.tick()
Besides the localStorage test from "Predict First", removing TestBed.tick() from the type helper in the search test gives:
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]⎯
The request for the first query (du) was found, the one for the second (dun) was not. The first query passed and the second did not: when the effect runs is left to Angular's scheduling, and fake timers affect that scheduling as well. In this form the test depends on timing; which step passes is unpredictable. If you test an rxMethod, signalMethod or store effect fed by a signal, call TestBed.tick() after changing the state.
Breaking into the store to reach private state
(store as any)._lineEntityMap(), or writing state with unprotected in every test, ties the test to the store's internals. The principle in the NgRx documentation is clear: public API only. Use unprotected to prepare an initial state that has no public setter, as in the coupon example, and still read the result from public signals.
Spying on the store's methods
When testing a store on its own, verifying that a method was called with vi.spyOn(store, 'add') tests the implementation instead of the behaviour. The NgRx documentation recommends instead moving the logic of a growing method into a service and faking that service in the test. The vi.fn() in the component test is different: there the store itself is the fake, and the question is "did the component call the right method?".
Thinking a MockStore test covers the reducer
When a component test written with provideMockStore passes, it does not prove that the dispatched action changes the state correctly; reducers do not run in MockStore. The reducer needs its own test (as in this post), or an integration test with the real provideStore.
A Small Experiment
★☆☆ Write a test for RecentlyViewedStore: call track with four different books and verify that viewedEntities() keeps the last three, newest first. Do you need TestBed.tick() when you call signalMethod with a value, and with a signal?
★★☆ Test the withEventHandlers of OrdersStore: dispatch checkoutEvents.orderSubmitted, answer POST /api/orders with 201 through HttpTestingController and verify that the order appears in the store, and that requestError is HTTP 409 when you answer with 409.
★★★ Write a test for the shipOrder effect: let two shipClicked actions for different orders arrive back to back, with the first response returning after the second (a fake http.post controlled with a Subject). Which test breaks when you replace mergeMap with switchMap, and why is that a correct failure?
In the last post of the series we put all these tools side by side: which tool for which state, from a local signal to the classic Store, the choices we made for Bookstore's features, and how state management in Angular evolved from BehaviorSubject to Signal Store.