Angular projects ship with a unit test runner and, usually, an end-to-end one alongside. The pizza app uses Vitest for units and Playwright for flows through the UI. What follows is the useful half: what to test where, and the mechanics that are not obvious.
The cheapest correct test
describe('MoneyPipe', () => {
const pipe = new MoneyPipe();
it('formats a number as US currency', () => {
expect(pipe.transform(14.5)).toBe('$14.50');
});A pipe is a class with one method. Nothing about it needs Angular running, so
new MoneyPipe() and a call is the whole test — milliseconds rather than tens of them.
Reach for TestBed when you need Angular's injector or its rendering, and
not before. Most services and every pure pipe need neither.
TestBed
TestBed.configureTestingModule({
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
{ provide: MenuService, useValue: menuServiceStub([PEPPERONI, PEPSI]) },
],
});TestBed builds a miniature application: an injector you configure, and a place to
create components. Because dependencies arrive through DI, swapping a real service for a stub is one
provider — no mocking library, no module interception.
⚠️ Order matters. provideHttpClientTesting() must come
after provideHttpClient(), because it replaces the backend the first installed.
Reversed, the real backend wins and your tests hit the network.
Testing HTTP
it('attaches the bearer token when one is stored', () => {
tokenStore.set('a-test-token');
http.get('/api/me/orders').subscribe();
const req = httpMock.expectOne(`${environment.apiBaseUrl}/api/me/orders`);
expect(req.request.headers.get('Authorization')).toBe('Bearer a-test-token');
});HttpTestingController hands you every request as an object to assert on, which is
what makes an interceptor — otherwise invisible plumbing — directly testable.
afterEach(() => {
httpMock.verify();verify() is the part people leave out and then regret. It fails
the test if a request was made that no expectation consumed, which is how you discover the component
firing a second request you never knew about.
Testing a directive
@Component({
imports: [Autofocus],
template: `
<input id="plain" />
<input id="focused" appAutofocus />
`,
})
class EnabledHost {}A directive has no template of its own, so there is nothing to instantiate directly. A throwaway host component in the spec file uses it the way a real template would — which also exercises the selector. A directive whose selector is misspelled still passes a test that news up the class by hand; it does not pass this one.
const fixture = TestBed.createComponent(EnabledHost);
fixture.detectChanges();
await fixture.whenStable();await fixture.whenStable() is load-bearing here: the directive focuses inside
afterNextRender, so asserting immediately after createComponent checks
before it has happened and fails for the wrong reason.
Testing a functional guard
return TestBed.runInInjectionContext(() =>A functional guard is just a function, but it may call inject() — so it has to run
inside an injection context. That is the whole trick, and it is why a guard cannot simply be called
like an ordinary function in a test.
Testing time
The search pipeline debounces for 300 ms. Waiting it out in every assertion would make the suite slow enough that people stop running it:
vi.useFakeTimers();expect(first.cancelled).toBe(true);
expect(second.cancelled).toBe(false);And req.cancelled is the only direct evidence that switchMap
unsubscribed rather than letting both requests run. Asserting on the final rendered results cannot
distinguish "cancelled the stale request" from "got lucky with the ordering" — the test would pass
either way, which makes it worthless for the thing it exists to check.
Two gotchas paid for already
A stub must cover the whole component tree, not just the component. The menu
page's spec stubs MenuService, but its template renders the pizza builder, which injects
CartService, which reads menu.crusts() in an effect. A four-member stub
failed inside change detection with this.menu.crusts is not a function — a stack trace
pointing at CartService for a spec about the menu page.
Fixtures need their real shape. ProductCard maps over
product.sizes; a trimmed object literal fails deep inside a child template rather than in
the spec that wrote it.
Where end-to-end takes over
Unit tests answer "does this piece behave". They cannot answer "can somebody actually order a pizza". The Playwright suite drives every flow through the real UI against the real backend — browse, cart, auth, checkout, orders, profile, admin — plus the API guards that are not observable through the UI at all.
It runs serially, because it is integration testing against one backend and one database, and tests must clean up what they create or one failure poisons every later run.
What is next
Why an Angular app gets slow, and what change detection is actually doing.