Angular NotesRohit’s interview study guide
Chapter 16

Testing Angular

Testing components, services, signals, forms, routes and HTTP with TestBed and Vitest.

7 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#The Angular testing setup

Added

My notes stop before testing, so this whole chapter is new. Every example in it — and every claim in the routing chapter — was run with the setup a fresh Angular 22 project gets from ng new.

The idea: a unit test should exercise your code the way Angular would run it, but without a browser window, a server or a real backend. Angular splits that job in two. Vitest is the test runner — it finds *.spec.ts files, runs describe/it blocks, and gives you expect, vi.fn() and fake timers. TestBed is Angular's part — it builds a small, throwaway Angular application per test (an injector with your providers, the compiler, the change-detection scheduler), so you can create components and inject services exactly as the real app would.

The mental model is a fresh mini-app for every it. TestBed.configureTestingModule({ imports, providers }) is the test's appConfig; TestBed.createComponent(X) bootstraps one component into it; TestBed.inject(Y) asks its injector for a service. After each test the whole thing is destroyed (teardown.destroyAfterEach defaults to true), so no state leaks between tests.

What CLI 22 sets up for you:

  • angular.json has "test": { "builder": "@angular/build:unit-test" }. Its runner option defaults to vitest, and with no browsers configured the tests run in Node with jsdom, a JavaScript implementation of the DOM.
  • The builder compiles your app and specs with the same esbuild-based application builder as ng build — including strict template type-checking, so a template error fails the test run before any test executes.
  • It initialises TestBed for you (the option docs say "the Angular TestBed are always initialized"); there's no test.ts or karma.conf.js.
  • tsconfig.spec.json adds "types": ["vitest/globals"], which is why describe, it, expect and vi need no import.
  • The app is zoneless and components are OnPush by default — both matter for how tests wait (below).

The generated spec for ng generate component gen-demo:

gen-demo.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GenDemo } from './gen-demo';

describe('GenDemo', () => {
  let component: GenDemo;
  let fixture: ComponentFixture<GenDemo>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [GenDemo],
    }).compileComponents();

    fixture = TestBed.createComponent(GenDemo);
    component = fixture.componentInstance;
    await fixture.whenStable();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});
What’s happening
  1. beforeEach runs before every it, so each test gets its own TestBed configuration and its own component.
  2. imports: [GenDemo] — a standalone component goes in imports, the same place it would go in another component. In the NgModule era (techStatus, Angular 16) you'd see declarations: [GenDemo] and a list of modules instead.
  3. compileComponents() compiled external templates in the old JIT setup. With the application builder the templates are already compiled ahead of time; I removed the call from a test of the generated App component (which uses templateUrl) and it still rendered. The schematic keeps it, and it's harmless.
  4. TestBed.createComponent(GenDemo) creates the component and attaches its host element to the test document. The returned fixture is your handle on it: componentInstance (the class instance), nativeElement (its DOM element), componentRef (to set inputs), debugElement (Angular's wrapper for querying).
  5. await fixture.whenStable() waits until Angular has nothing pending — in particular the first change-detection pass that renders the template. In a zoneless app rendering is scheduled, not synchronous, so this await is how a test says "let Angular do its thing" (see Testing a component).
AdvancedRunning ng test

Running it:

Shell
npx ng test --watch=false                        # the whole suite once (CI)
npx ng test                                      # watch mode, re-runs on save
npx ng test --include src/app/testing            # only specs under a folder (a directory path is expanded to its specs)
npx ng test --filter 'UserApi'                   # only tests whose suite/test name matches the regex
npx ng test --watch=false --reporters=verbose    # list every test
npx ng test --coverage                           # coverage report
AdvancedTest run output explained
Output
❯ Building...
✔ Building...
Application bundle generation complete. [1.092 seconds]

 RUN  v5.0.3 /Users/rohitvarma/AndroidStudioProjects/StudySites/.sandbox/angular/ch1316

 Test Files  14 passed (14)
      Tests  86 passed (86)
   Start at  22:25:49
   Duration  1.70s (environment 38%, setup 26%, tests 18%, transform 11%, import 7%, worker 1%)
What’s happening
  1. Building is the Angular compiler step. A template type error stops here with the same NGxxxx error ng build would print, e.g. "NG8001: 'router-outlet' is not a known element".
  2. RUN v5.0.3 is Vitest. It runs each spec file in a jsdom environment; "environment 38%" of the 1.7 s was setting jsdom up.
  3. 86 tests (this chapter's plus the routing chapter's) ran in under two seconds. That speed — compared with booting a real Chrome for Karma — is a big part of why the CLI switched.
  4. One trap I hit: --include 'src/app/testing/**' (a glob) also picked up non-spec files and reported 8 "No test suite found in file …/cart.ts" failures. Pass the directory itself, or a **/*.spec.ts glob.
AdvancedReading a failing test's output

A failing test shows the expected and received values and points at the line:

Output
 ×  ng-sandbox  src/app/testing/fail.spec.ts > Counter > forgot to wait for change detection 18ms
   → expected 'Count: 0' to be 'Clicks: 0' // Object.is equality
AssertionError: expected 'Count: 0' to be 'Clicks: 0' // Object.is equality
Expected: "Clicks: 0"
Received: "Count: 0"
 ❯ src/app/testing/fail.spec.ts:9:71
      8|     fixture.componentRef.setInput('label', 'Clicks');
      9|     expect(fixture.nativeElement.querySelector('.label').textContent).…
AdvancedKarma and Jasmine vs Vitest equivalents

That one is the most common Angular test failure there is — the next topic explains it.

From my notes' techStatus world to now:

Angular 16 (Karma + Jasmine, zone.js)Angular 22 (Vitest, zoneless)
ng test opens Chrome via Karmang test runs Vitest in Node + jsdom
jasmine.createSpy('get')vi.fn()
spyOn(api, 'get').and.returnValue(x)vi.spyOn(api, 'get').mockReturnValue(x)
jasmine.createSpyObj('Api', ['get']){ get: vi.fn() } as a useValue provider
fakeAsync(() => { …; tick(300); })vi.useFakeTimers() + await vi.advanceTimersByTimeAsync(300)
fixture.detectChanges() after every changeawait fixture.whenStable() (or fixture.detectChanges())
declarations: [MyComponent]imports: [MyComponent]

fakeAsync and waitForAsync depend on zone.js. Under Vitest without it, calling fakeAsync(() => {})() threw: "zone-testing.js is needed for the fakeAsync() test helper but could not be found. Please make sure that your environment includes zone.js/testing". The .d.ts now labels them "IMPORTANT: This API requires Zone.js and cannot be used with the Vitest test runner".

#Testing a component

Added

A component test checks the contract a user or parent sees: given these inputs, the DOM shows this; when I click that, this output fires. You don't test that Angular can render an interpolation; you test your template and logic through the same doors a parent component would use.

The one thing to internalise for Angular 22 is when the DOM updates. In a zoneless app nothing re-renders synchronously. A signal write, a setInput, or a DOM event schedules change detection, and Angular runs it shortly after (a microtask/timer later). So the pattern in every test is: change something → await fixture.whenStable() → assert on the DOM. Signals you can read directly at any time; the DOM you read after waiting.

counter.ts
@Component({
  selector: 'app-counter',
  template: `
    <p class="label">{{ label() }}: <span class="count">{{ count() }}</span></p>
    <p class="double">Double: {{ double() }}</p>
    <button (click)="increment()">+{{ step() }}</button>
  `,
})
export class Counter {
  label = input('Count');
  step = input(1);
  changed = output<number>();

  count = signal(0);
  double = computed(() => this.count() * 2);

  increment() {
    this.count.update((c) => c + this.step());
    this.changed.emit(this.count());
  }
}
AdvancedSpec: inputs, clicks and outputs
counter.spec.ts
describe('Counter', () => {
  let fixture: ComponentFixture<Counter>;
  let el: HTMLElement;

  beforeEach(async () => {
    await TestBed.configureTestingModule({ imports: [Counter] }).compileComponents();
    fixture = TestBed.createComponent(Counter);
    el = fixture.nativeElement;
    await fixture.whenStable();
  });

  it('setInput: DOM is not updated until change detection runs', async () => {
    fixture.componentRef.setInput('label', 'Clicks');
    console.log('right after setInput:', el.querySelector('.label')?.textContent); // Count: 0
    await fixture.whenStable();
    expect(el.querySelector('.label')?.textContent).toBe('Clicks: 0');
  });

  it('click -> signal update -> DOM, and the output fires', async () => {
    fixture.componentRef.setInput('step', 5);
    const emitted: number[] = [];
    fixture.componentInstance.changed.subscribe((v) => emitted.push(v));

    const button = fixture.debugElement.query(By.css('button'));
    button.triggerEventHandler('click'); // DebugElement way
    el.querySelector('button')!.click(); // real DOM event
    await fixture.whenStable();

    expect(el.querySelector('.count')?.textContent).toBe('10');
    expect(el.querySelector('.double')?.textContent).toBe('Double: 20');
    expect(emitted).toEqual([5, 10]);
  });

  it('reads signals on the instance directly', () => {
    fixture.componentInstance.count.set(21);
    expect(fixture.componentInstance.double()).toBe(42);
  });
});
What’s happening
  1. Setup. createComponent builds a Counter with no parent, so every input has its default: label is 'Count', step is 1. After whenStable() the DOM says Count: 0 and the button says +1.
  2. setInput. fixture.componentRef.setInput('label', 'Clicks') is how a test plays the parent's [label]="…". It updates the signal input immediately and schedules a render — but the console.log right after it printed Count: 0. Only after await fixture.whenStable() does the DOM say Clicks: 0. Asserting before the await is exactly the failure shown in the previous topic. (Assigning fixture.componentInstance.label = … isn't an option: a signal input is read-only from outside.)
  3. Clicks. triggerEventHandler('click') calls the template's (click) handler directly without a real event; el.querySelector('button')!.click() dispatches a real DOM click that bubbles like a user's. Both call increment(): count goes 0 → 5 → 10 with step = 5. Prefer the real click — it also catches a handler bound to the wrong element.
  4. Output. changed is an OutputEmitterRef; .subscribe() in a test is the equivalent of the parent's (changed)="…". It received [5, 10], one emission per click.
  5. Signals without the DOM. count.set(21) and reading double() → 42 works synchronously, no waiting: computed is evaluated on read. Test logic through signals and rendering through the DOM.
  6. A mistyped input name fails fast: setInput('lable', 'x') threw "NG0303: Can't set value of the 'lable' input on the '_Counter' component. Make sure that the 'lable' property is declared as an input using the input() or model() function or the @Input() decorator."
AdvancedBinding inputs at creation with inputBinding

Angular 20+ can also wire inputs and outputs at creation time, which reads like a parent template:

counter.spec.ts
it('binds inputs to a signal and listens to outputs', async () => {
  const label = signal('Score');
  const changed = vi.fn();
  const fixture = TestBed.createComponent(Counter, {
    bindings: [inputBinding('label', label), outputBinding<number>('changed', changed)],
  });
  await fixture.whenStable();
  const el: HTMLElement = fixture.nativeElement;
  expect(el.querySelector('.label')?.textContent).toBe('Score: 0');

  label.set('Points');
  el.querySelector('button')!.click();
  await fixture.whenStable();
  expect(el.querySelector('.label')?.textContent).toBe('Points: 1');
  expect(changed).toHaveBeenCalledExactlyOnceWith(1);
});
What’s happening
  1. inputBinding('label', label) connects the component's label input to a signal you own — like [label]="label()" in a parent. outputBinding('changed', changed) subscribes a listener — like (changed)="changed($event)".
  2. Because the binding is live, label.set('Points') is all it takes to change the input; there's no setInput call.
  3. changed is a vi.fn(), so the output can be asserted with Vitest's mock matchers: called exactly once with 1.
  4. Use this style when a test needs to change an input several times or when you want the setup to read like a parent template; setInput is fine for one-off values. (twoWayBinding('value', signal) exists for model() inputs.)
AdvancedPitfall: plain fields under OnPush
AdvancedChoosing stable DOM queries

#Testing services and signals

Added

A service is the easiest thing in Angular to test: no template, no DOM, no waiting for change detection. You ask TestBed's injector for it and call its methods. The only Angular-specific parts are how you get the instance (through DI, so its own inject() calls work) and when effects run (they're scheduled, like rendering).

cart.ts
@Service()
export class CartService {
  private readonly _items = signal<CartItem[]>([]);
  readonly items = this._items.asReadonly();
  readonly total = computed(() => this._items().reduce((sum, i) => sum + i.price * i.qty, 0));

  constructor() {
    // persist on every change
    effect(() => localStorage.setItem('cart', JSON.stringify(this._items())));
  }

  add(item: CartItem) {
    this._items.update((items) => [...items, item]);
  }
}
AdvancedSpec: cart service, computed and effects
cart.spec.ts
describe('CartService', () => {
  let cart: CartService;

  beforeEach(() => {
    localStorage.clear();
    cart = TestBed.inject(CartService);
  });

  it('computed total follows the items', () => {
    expect(cart.total()).toBe(0);
    cart.add({ name: 'Book', price: 20, qty: 2 });
    cart.add({ name: 'Pen', price: 3, qty: 1 });
    expect(cart.items().length).toBe(2);
    expect(cart.total()).toBe(43);
  });

  it('effects run when you flush them with TestBed.tick()', () => {
    cart.add({ name: 'Book', price: 20, qty: 1 });
    console.log('before tick:', localStorage.getItem('cart')); // null
    TestBed.tick();
    console.log('after tick:', localStorage.getItem('cart'));  // [{"name":"Book","price":20,"qty":1}]
  });

  it('each test gets a fresh instance', () => {
    expect(cart.items()).toEqual([]);
  });
});
What’s happening
  1. TestBed.inject(CartService) asks the test injector for the service. @Service() makes it available at the root automatically (see Services with @Service and inject()), so no providers entry is needed. The injector runs the constructor in an injection context, which is what lets effect() (and any inject() inside) work.
  2. Why not new CartService()? I tried: it threw "NG0203: effect() can only be used within an injection context…". A service that uses inject(HttpClient) fails the same way ("The HttpClient token injection failed"). Classes that only use constructor parameters can still be newed in a plain test, but going through TestBed.inject is the consistent habit.
  3. computed is synchronous. total() is 0, then after two adds it recomputes on read: 20×2 + 3×1 = 43. No flushing needed — computed signals are pulled, not pushed.
  4. Effects are scheduled. After add, localStorage was still null: the effect was marked dirty but hadn't run. TestBed.tick() runs the pending work Angular would do in the app (effects plus change detection), and then the item was saved. TestBed.flushEffects() still exists but is deprecated in favour of tick(). In component tests await fixture.whenStable() flushes effects too.
  5. Isolation. The third test sees an empty cart even though the earlier tests added items: TestBed destroyed the injector after each test, so inject created a new CartService. The localStorage.clear() is there because jsdom's storage is not reset by TestBed — it's a global, like any other browser API.
AdvancedConfirming the zoneless test environment

The test also confirmed the environment: TestBed.inject(NgZone).constructor.name was NoopNgZone and typeof Zone was undefined — no zone.js anywhere. And cart.items.set([]) threw, because asReadonly() returns a signal without set/update (TypeScript flags it too). That's the "don't expose writable signals" rule from Don't expose writable signals holding up in a test.

AdvancedAsserting on public signals

#Testing HTTP with HttpTestingController

Added

A service that calls an API shouldn't hit a real server in a unit test: it would be slow, flaky and need a backend running. Angular's answer is a fake backend you control from the test. provideHttpClientTesting() swaps the part of HttpClient that actually sends requests for one that just records them. Your service runs its real code — builds the URL, sets headers, runs interceptors — and the request lands in a queue. The test pulls it out with HttpTestingController, checks it, and decides the response with flush().

The mental model is a mock server behind a window: the code under test hands a request through the window, you inspect it, and you hand back whatever response you want — success, 404, 500 — at the moment you choose.

user-api.ts
@Service()
export class UserApi {
  private http = inject(HttpClient);

  getUser(id: number): Observable<User | null> {
    return this.http.get<User>(`/api/users/${id}`).pipe(
      catchError((err: HttpErrorResponse) => (err.status === 404 ? of(null) : Promise.reject(err))),
    );
  }

  search(q: string, page = 1): Observable<User[]> {
    const params = new HttpParams().set('q', q).set('page', page);
    return this.http.get<User[]>('/api/users', { params });
  }

  create(name: string): Observable<User> {
    return this.http.post<User>('/api/users', { name });
  }
}

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(TokenStore).token;
  return next(token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req);
};
AdvancedSpec: interceptor-backed UserApi request
user-api.spec.ts
describe('UserApi', () => {
  let api: UserApi;
  let httpTesting: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideHttpClient(withInterceptors([authInterceptor])), provideHttpClientTesting()],
    });
    api = TestBed.inject(UserApi);
    httpTesting = TestBed.inject(HttpTestingController);
  });

  afterEach(() => httpTesting.verify());

  it('GET /api/users/:id', async () => {
    const result = firstValueFrom(api.getUser(1));

    const req = httpTesting.expectOne('/api/users/1');
    expect(req.request.method).toBe('GET');
    req.flush({ id: 1, name: 'Ada' });

    expect(await result).toEqual({ id: 1, name: 'Ada' });
  });

  it('maps a 404 to null', async () => {
    const result = firstValueFrom(api.getUser(99));
    httpTesting.expectOne('/api/users/99').flush('Not found', { status: 404, statusText: 'Not Found' });
    expect(await result).toBeNull();
  });

  it('rethrows a 500', async () => {
    const result = firstValueFrom(api.getUser(1));
    httpTesting.expectOne('/api/users/1').flush(null, { status: 500, statusText: 'Server Error' });
    await expect(result).rejects.toMatchObject({ status: 500 });
    // message: "Http failure response for /api/users/1: 500 Server Error"
  });

  it('the interceptor adds the token', () => {
    TestBed.inject(TokenStore).token = 'abc';
    api.getUser(1).subscribe();
    const req = httpTesting.expectOne('/api/users/1');
    expect(req.request.headers.get('Authorization')).toBe('Bearer abc');
    req.flush({ id: 1, name: 'Ada' });
  });
});
What’s happening
  1. Providers. provideHttpClient(withInterceptors([authInterceptor])) is the same line as the app config, so the interceptor chain is real. provideHttpClientTesting() comes after it and replaces the backend. (In 22 HttpClient itself is providedIn: 'root' — a test with only provideHttpClientTesting() worked — but you need provideHttpClient(...) to configure interceptors or withFetch().)
  2. Subscribe first. firstValueFrom(api.getUser(1)) subscribes, which is what sends the request — HttpClient observables are cold. A separate test proved it: calling api.getUser(1) without subscribing and then expectNone('/api/users/1') passed. The promise is pending: nothing has answered yet.
  3. Catch it. expectOne('/api/users/1') finds exactly one pending request with that URL, or throws. req.request is the real HttpRequest the service built — method, headers, body, params — so you can assert on it.
  4. Answer it. req.flush({ id: 1, name: 'Ada' }) delivers a 200 with that body. The observable emits, firstValueFrom resolves, and the await gets the user. Passing { status: 404, statusText: 'Not Found' } turns the flush into an HttpErrorResponse, which the service's catchError maps to null; a 500 is re-thrown, and its message was "Http failure response for /api/users/1: 500 Server Error".
  5. The interceptor ran. With a token in TokenStore, the request that reached the fake backend had Authorization: Bearer abc — the test sees the request after the interceptor chain, exactly what a server would.
  6. verify() in afterEach. It fails the test if any request is still unanswered. Leaving one open printed "Expected no open requests, found 1: GET /api/users/7". That catches a service making an extra call you didn't expect.
AdvancedPitfall: query strings in expectOne
AdvancedFlushing httpResource effects with TestBed.tick

httpResource in a component

httpResource sends its request from an effect, so in a test the request doesn't exist until Angular gets a chance to run effects:

user-card.ts
@Component({
  selector: 'app-user-card',
  template: `
    @if (user.isLoading()) {
      <p class="loading">Loading…</p>
    } @else if (user.error()) {
      <p class="error">Could not load user</p>
    } @else if (user.hasValue()) {
      <h2>{{ user.value().name }}</h2>
    }
  `,
})
export class UserCard {
  id = input.required<number>();
  user = httpResource<User>(() => `/api/users/${this.id()}`);
}
user-api.spec.ts
TestBed.configureTestingModule({ providers: [provideHttpClientTesting()] });
const httpTesting = TestBed.inject(HttpTestingController);
const fixture = TestBed.createComponent(UserCard);
fixture.componentRef.setInput('id', 1);
TestBed.tick();
// DOM: "Loading…"

httpTesting.expectOne('/api/users/1').flush({ id: 1, name: 'Ada' } satisfies User);
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('h2').textContent).toBe('Ada');

fixture.componentRef.setInput('id', 2);
TestBed.tick();
httpTesting.expectOne('/api/users/2').flush('boom', { status: 500, statusText: 'Server Error' });
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('.error').textContent).toBe('Could not load user');
httpTesting.verify();
What’s happening
  1. setInput('id', 1) makes the resource's URL function return /api/users/1, but the resource only reacts when its effect runs.
  2. TestBed.tick() runs pending effects and change detection synchronously: the request is sent and the template renders the loading branch (Loading…). Without the tick() I got "Expected one matching request for criteria "Match URL: /api/users/1", found none." — and the DOM was still empty.
  3. Don't use await fixture.whenStable() at that point: a pending resource counts as pending work, so "stable" would wait for a response that the test hasn't flushed yet.
  4. After flush, whenStable() lets the resource settle and re-render: hasValue() is true and the heading is Ada.
  5. Changing id to 2 and ticking sends a new request; flushing a 500 puts the resource in its error state and the template shows "Could not load user". The same pattern works for resource() and rxResource(). More on the HTTP side in Testing HTTP calls.

#Testing routes and guards

Added

Routing code is awkward to test by hand: a guard needs an ActivatedRouteSnapshot and a RouterStateSnapshot, a component reading params needs an ActivatedRoute, and the interesting behaviour is the sequence — match, guard, resolve, render. RouterTestingHarness lets you skip the fakes and use the real router with your real route config. It creates a tiny root component that contains a <router-outlet>, and gives you navigateByUrl() that waits for the navigation to finish.

Every claim in Routing was checked this way; that chapter is a long list of worked examples. Here's the toolkit.

guard-unit.spec.ts
describe('authGuard through the router', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        provideRouter([
          { path: 'login', component: Login },
          { path: 'admin', component: AdminDashboard, canActivate: [authGuard] },
        ]),
      ],
    });
  });

  it('redirects', async () => {
    const harness = await RouterTestingHarness.create();
    await harness.navigateByUrl('/admin');
    expect(TestBed.inject(Router).url).toBe('/login?returnUrl=%2Fadmin');
    expect(harness.routeNativeElement?.textContent).toBe('Please log in');
  });

  it('typed navigateByUrl throws if a different component activates', async () => {
    const harness = await RouterTestingHarness.create();
    await harness.navigateByUrl('/admin', AdminDashboard);
    // throws: "Unexpected routed component type. Expected _AdminDashboard but got _Login"
  });
});
What’s happening
  1. provideRouter([...]) in the test is the real router with the real guard — the test only chooses which routes to register. You can pass features too: provideRouter(routes, withComponentInputBinding()).
  2. RouterTestingHarness.create() builds the harness root (one per test: a second create() threw "Only one harness should be created per test."). create('/admin') would also perform an initial navigation.
  3. await harness.navigateByUrl('/admin') runs the whole pipeline — guard included — and resolves when the navigation is done, re-rendering the outlet. Because the guard redirected, the router ended on /login?returnUrl=%2Fadmin and the outlet shows Login.
  4. harness.routeNativeElement is the element of the component currently in the outlet; routeDebugElement is its DebugElement, so routeDebugElement.componentInstance and routeDebugElement.injector.get(ActivatedRoute) are available.
  5. navigateByUrl(url, ComponentType) returns the routed component instance, typed — handy for calling methods or reading signals. If a different component ends up in the outlet, it throws with both names (the leading _ is how esbuild names classes in the test build).
AdvancedUnit-testing a functional guard

A functional guard is also just a function, so you can unit-test it without any routing at all — as long as you give it an injection context. The CLI's ng generate guard spec does exactly this:

guard-unit.spec.ts
describe('authGuard as a plain function', () => {
  const route = {} as ActivatedRouteSnapshot;
  const state = { url: '/admin' } as RouterStateSnapshot;

  it('returns a UrlTree to /login when logged out', () => {
    const result = TestBed.runInInjectionContext(() => authGuard(route, state));
    expect(result).toBeInstanceOf(UrlTree);
    expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/login?returnUrl=%2Fadmin');
  });

  it('returns true when logged in', () => {
    TestBed.inject(AuthService).loggedIn.set(true);
    expect(TestBed.runInInjectionContext(() => authGuard(route, state))).toBe(true);
  });
});
What’s happening
  1. The guard only reads state.url, so a minimal object cast to RouterStateSnapshot is enough. Build only what the function under test actually touches.
  2. Calling authGuard(route, state) directly threw "NG0203: The _AuthService token injection failed. inject() function must be called from an injection context…" — its inject() calls need an injector. TestBed.runInInjectionContext(fn) runs fn with TestBed's injector as the context, like the router does.
  3. Logged out, the result is a UrlTree; router.serializeUrl turns it back into the string /login?returnUrl=%2Fadmin for a readable assertion.
  4. Flip the real AuthService signal and the same call returns true.
  5. Which style? Plain-function tests are fast and precise for guard logic; harness tests prove the guard is wired to the right route and that the redirect lands where you think. A couple of each is a good balance.
AdvancedTesting route params with the harness

#Testing forms

Added

A form test should drive the form the way a user does: type into the input, leave the field, click submit. That exercises the binding between the DOM and the form model, which is where real bugs live (wrong formControlName, a missing directive import, an error message bound to the wrong control). Reading form.valid alone only tests Angular's validators.

Typing in a test means two lines: set the element's value, then dispatch an input event — that event is what the form directives listen to. "Leaving the field" is a blur event, which marks the control touched.

login-form.ts
@Component({
  selector: 'app-login-form',
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="submit()">
      <input id="email" formControlName="email" />
      @if (form.controls.email.touched && form.controls.email.hasError('email')) {
        <p class="error">Enter a valid email</p>
      }
      <input id="password" type="password" formControlName="password" />
      <button type="submit" [disabled]="form.invalid">Log in</button>
    </form>
  `,
})
export class LoginForm {
  loggedIn = output<{ email: string; password: string }>();

  form = new FormGroup({
    email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }),
    password: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(8)] }),
  });

  submit() {
    if (this.form.valid) this.loggedIn.emit(this.form.getRawValue());
  }
}
AdvancedSpec: reactive form validation and submit
forms.spec.ts
function type(input: HTMLInputElement, value: string) {
  input.value = value;
  input.dispatchEvent(new Event('input'));
}

it('typing updates the FormControl; the button follows validity', async () => {
  const fixture = TestBed.createComponent(LoginForm);
  await fixture.whenStable();
  const el: HTMLElement = fixture.nativeElement;
  const email = el.querySelector<HTMLInputElement>('#email')!;
  const password = el.querySelector<HTMLInputElement>('#password')!;
  const button = el.querySelector('button')!;

  expect(button.disabled).toBe(true);

  type(email, 'not-an-email');
  expect(fixture.componentInstance.form.controls.email.value).toBe('not-an-email'); // synchronous
  expect(el.querySelector('.error')).toBeNull(); // not touched yet

  email.dispatchEvent(new Event('blur')); // touched
  await fixture.whenStable();
  expect(el.querySelector('.error')?.textContent).toBe('Enter a valid email');

  type(email, 'rohit@example.com');
  type(password, 'longenough');
  await fixture.whenStable();
  expect(el.querySelector('.error')).toBeNull();
  expect(button.disabled).toBe(false);
});

it('submitting emits the value', async () => {
  const fixture = TestBed.createComponent(LoginForm);
  const spy = vi.fn();
  fixture.componentInstance.loggedIn.subscribe(spy);
  await fixture.whenStable();
  const el: HTMLElement = fixture.nativeElement;

  fixture.componentInstance.form.setValue({ email: 'a@b.co', password: '12345678' }); // model -> view
  await fixture.whenStable();
  expect(el.querySelector<HTMLInputElement>('#email')!.value).toBe('a@b.co');
  expect(el.querySelector('button')!.disabled).toBe(false);

  el.querySelector('button')!.click(); // submit button inside the form -> ngSubmit
  expect(spy).toHaveBeenCalledWith({ email: 'a@b.co', password: '12345678' });
});
What’s happening
  1. Initial state. Both controls are empty and required, so the group is invalid and [disabled]="form.invalid" disables the button.
  2. Typing is synchronous for the model. type(email, 'not-an-email') sets value and fires input; formControlName's value accessor hears it and calls setValue on the control immediately. The test read 'not-an-email' from the control without awaiting — only the DOM re-render is scheduled.
  3. Errors wait for touched. The template shows the error only when the control is touched and has the email error. Before blur, .error is null. Dispatching blur marks the control touched; after whenStable() the message appears. This is exactly the "touched vs dirty" UX rule from Field state: dirty, touched, valid, checked through the DOM.
  4. Valid input enables the button. After valid values in both fields and a render, the error is gone and button.disabled is false. Even though form.invalid is a plain getter (not a signal) and the component is OnPush, the view updated: the ng-valid/ng-invalid class bindings that ReactiveFormsModule puts on each control read an internal status signal (I checked NgControlStatus in forms.mjs), so a status change marks the view for check.
  5. Model → view. form.setValue({...}) writes into the controls; the value accessors write into the inputs, and the button enables. Clicking the submit button inside the <form> fires the browser's submit, which FormGroupDirective turns into (ngSubmit); submit() emits getRawValue() and the spy got { email: 'a@b.co', password: '12345678' }.
AdvancedSignal Forms component under test

Signal Forms test the same way. The difference is where the value lives: in your model signal, which the [formField] directive writes on every keystroke.

signup-form.ts
@Component({
  selector: 'app-signup-form',
  imports: [FormField],
  template: `
    <input id="email" [formField]="signup.email" />
    @if (signup.email().touched() && signup.email().invalid()) {
      @for (e of signup.email().errors(); track e.kind) {
        <p class="error">{{ e.message }}</p>
      }
    }
  `,
})
export class SignupForm {
  model = signal({ email: '' });
  signup = form(this.model, (p) => {
    required(p.email, { message: 'Email is required' });
    email(p.email, { message: 'Not an email' });
  });
}
AdvancedSpec: Signal Forms errors and model sync
forms.spec.ts
const fixture = TestBed.createComponent(SignupForm);
await fixture.whenStable();
const input = fixture.nativeElement.querySelector('#email') as HTMLInputElement;
const cmp = fixture.componentInstance;

expect(cmp.signup.email().errors().map((e) => e.kind)).toEqual(['required']);

type(input, 'nope');
expect(cmp.model()).toEqual({ email: 'nope' });
expect(cmp.signup.email().errors().map((e) => e.kind)).toEqual(['email']);

input.dispatchEvent(new Event('blur'));
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('.error')?.textContent).toBe('Not an email');

cmp.model.set({ email: 'rohit@example.com' }); // model -> view
await fixture.whenStable();
expect(input.value).toBe('rohit@example.com');
expect(cmp.signup().valid()).toBe(true);
What’s happening
  1. Empty field: only required fails — the email validator doesn't report on an empty value, so errors() kinds are ['required'], not both.
  2. Typing nope updates the model signal itself: cmp.model() is { email: 'nope' }, synchronously. Errors are computed from it, so kind is now ['email'].
  3. Field state is signals too: touched() flips on blur, and after a render the custom message Not an email is on the page.
  4. Setting the model from code (model.set(...)) flows the other way: the directive writes the input's value, and signup().valid() (form-level state) is true. Testing Signal Forms needs no special API — it's signals in, DOM out. See Signal Forms: creating a form from a model.

#Mocks, spies and fakes

Added

A unit test should test one unit. When a component depends on UserApi, you don't want the test to depend on HTTP, a server, or UserApi's own bugs. So you replace the dependency with a stand-in you control. That's exactly what dependency injection makes easy: the component asks for "a UserApi", and the test decides what that is (the testing side of Dependency inversion for testing).

The vocabulary, loosely:

  • A fake is a working, simplified implementation (an in-memory store instead of HTTP).
  • A stub returns canned answers (getUser always returns Ada).
  • A mock / spy also records how it was called, so you can assert "called once with 7". In Vitest, vi.fn() creates one; vi.spyOn(obj, 'method') wraps a real method.

You plug them in through TestBed providers, the same provider strategies the app uses — usually useValue.

profile-page.ts
@Component({
  selector: 'app-profile-page',
  imports: [ActivityChart],
  template: `
    @if (user(); as u) {
      <h1>{{ u.name }}</h1>
      <app-activity-chart [userId]="u.id" />
    } @else {
      <p>No such user</p>
    }
  `,
})
export class ProfilePage {
  private api = inject(UserApi);
  id = input.required<number>();
  user = toSignal(toObservable(this.id).pipe(switchMap((id) => this.api.getUser(id))));
}
AdvancedSpec: mock API and overridden child
mocks.spec.ts
@Component({ selector: 'app-activity-chart', template: `fake chart for {{ userId() }}` })
class FakeActivityChart {
  userId = input.required<number>();
}

describe('ProfilePage with a mocked UserApi', () => {
  const getUser = vi.fn<(id: number) => ReturnType<UserApi['getUser']>>();

  beforeEach(() => {
    getUser.mockReset();
    TestBed.configureTestingModule({
      providers: [{ provide: UserApi, useValue: { getUser } satisfies Partial<UserApi> }],
    });
    TestBed.overrideComponent(ProfilePage, {
      remove: { imports: [ActivityChart] },
      add: { imports: [FakeActivityChart] },
    });
  });

  it('renders the user from the mock', async () => {
    getUser.mockReturnValue(of({ id: 7, name: 'Grace' }));
    const fixture = TestBed.createComponent(ProfilePage);
    fixture.componentRef.setInput('id', 7);
    await fixture.whenStable();

    expect(getUser).toHaveBeenCalledExactlyOnceWith(7);
    expect(fixture.nativeElement.querySelector('h1').textContent).toBe('Grace');
    expect(fixture.nativeElement.querySelector('app-activity-chart').textContent).toBe('fake chart for 7');
  });

  it('a different return value per call', async () => {
    getUser.mockReturnValueOnce(of({ id: 1, name: 'Ada' })).mockReturnValueOnce(of(null));
    const fixture = TestBed.createComponent(ProfilePage);
    fixture.componentRef.setInput('id', 1);
    await fixture.whenStable();
    expect(fixture.nativeElement.textContent).toContain('Ada');

    fixture.componentRef.setInput('id', 2);
    await fixture.whenStable();
    expect(fixture.nativeElement.textContent).toContain('No such user');
    expect(getUser.mock.calls).toEqual([[1], [2]]);
  });
});
What’s happening
  1. The mock function. vi.fn<(id: number) => …>() creates a typed spy. It does nothing until told; mockReturnValue(of({...})) makes every call return that observable, mockReturnValueOnce queues one answer per call. mockReset() in beforeEach clears both the recorded calls and the configured answers, since the const lives across tests.
  2. The provider. { provide: UserApi, useValue: { getUser } } tells the injector "when anyone asks for UserApi, give them this object". ProfilePage's inject(UserApi) gets the mock and never knows. satisfies Partial<UserApi> keeps the object honest: misspell getUser or return the wrong type and TypeScript complains, while not forcing you to stub search and create.
  3. The child component. ActivityChart stands in for something heavy (a charting library). overrideComponent(ProfilePage, { remove: { imports: [ActivityChart] }, add: { imports: [FakeActivityChart] } }) swaps it in ProfilePage's own imports for a fake with the same selector and inputs. The page rendered "fake chart for 7", which also proves the parent passed [userId]="u.id" correctly.
  4. Assert on the interaction. toHaveBeenCalledExactlyOnceWith(7) checks the component asked for the right user, once. In the second test, getUser.mock.calls is [[1], [2]] — the input change re-ran the switchMap with the new id, and the second answer (null) rendered "No such user".
  5. Spy on a real service instead. When most of a service is fine to keep, vi.spyOn(TestBed.inject(UserApi), 'getUser').mockReturnValue(of(...)) replaces one method on the real root instance and leaves search real. Call vi.restoreAllMocks() in afterEach (or TestBed's teardown will at least drop the instance).
AdvancedPitfall: overriding after injection
AdvancedTesting a debounced typeahead with fake timers

Fake timers instead of fakeAsync

Code with debounceTime, setTimeout or polling shouldn't make tests wait in real time. In the zone.js world you wrapped the test in fakeAsync and called tick(300). Under Vitest you use Vitest's fake timers: vi.useFakeTimers() replaces setTimeout and friends with a virtual clock that only moves when you say so.

typeahead.ts
export class Typeahead {
  private api = inject(UserApi);
  terms = new Subject<string>();
  results = signal<User[]>([]);

  constructor() {
    this.terms
      .pipe(
        debounceTime(300),
        distinctUntilChanged(),
        switchMap((q) => this.api.search(q)),
        takeUntilDestroyed(),
      )
      .subscribe((users) => this.results.set(users));
  }
}
// template: <input (input)="terms.next($any($event.target).value)" /> and an @for over results()
mocks.spec.ts
const search = vi.fn((q: string) => of<User[]>([{ id: 1, name: `result for ${q}` }]));

beforeEach(() => {
  vi.useFakeTimers();
  search.mockClear();
  TestBed.configureTestingModule({ providers: [{ provide: UserApi, useValue: { search } }] });
});
afterEach(() => vi.useRealTimers());

it('debounces 300 ms and drops repeats', async () => {
  const fixture = TestBed.createComponent(Typeahead);
  await vi.runAllTimersAsync();
  const input: HTMLInputElement = fixture.nativeElement.querySelector('input');
  const typeIt = (v: string) => {
    input.value = v;
    input.dispatchEvent(new Event('input'));
  };

  typeIt('a');
  await vi.advanceTimersByTimeAsync(100);
  typeIt('an');
  await vi.advanceTimersByTimeAsync(100);
  typeIt('ang');
  await vi.advanceTimersByTimeAsync(299);
  expect(search).not.toHaveBeenCalled();

  await vi.advanceTimersByTimeAsync(1);
  expect(search).toHaveBeenCalledExactlyOnceWith('ang');

  typeIt('ang'); // same term again
  await vi.advanceTimersByTimeAsync(300);
  expect(search).toHaveBeenCalledTimes(1); // distinctUntilChanged

  await vi.runAllTimersAsync(); // let the scheduled change detection run
  expect(fixture.nativeElement.querySelector('li').textContent).toBe('result for ang');
});
What’s happening
  1. vi.useFakeTimers() in beforeEach swaps the timer functions before the component is created, so RxJS's debounceTime (which uses setTimeout underneath) schedules on the virtual clock. afterEach puts real timers back so other tests aren't affected.
  2. After createComponent, await vi.runAllTimersAsync() plays the initial render: Angular's zoneless scheduler also uses timers, so with a frozen clock even the first render waits for you.
  3. Type a, advance 100 ms, type an, advance 100 ms, type ang: each keystroke restarts the 300 ms debounce. Advancing 299 ms after the last one — virtual time 499 ms in total — still hasn't called search.
  4. One more millisecond completes the 300 ms of silence: debounceTime emits 'ang' and search is called exactly once with it. The first two terms were never searched.
  5. Typing ang again and waiting 300 ms emits 'ang' from the debounce, but distinctUntilChanged drops it because it equals the previous value: still 1 call. Finally runAllTimersAsync() lets the scheduled change detection run and the <li> shows result for ang. The whole test took 14 ms of real time.
What does this test report?
TypeScript
it('shows the new label', () => {
  const fixture = TestBed.createComponent(Counter);
  fixture.componentRef.setInput('label', 'Clicks');
  expect(fixture.nativeElement.querySelector('.label')?.textContent).toBe('Clicks: 0');
});
Show answer

It fails with "expected '' to be 'Clicks: 0'" — the paragraph exists but is empty.

What’s happening
  1. createComponent runs the template's creation pass synchronously: elements and empty text nodes are made. I logged innerHTML at this point: <p class="label"><span class="count"></span></p><p class="double"></p><button></button> — the structure, with no text.
  2. The update pass, which evaluates {{ label() }}: and fills the text nodes, is change detection. In a zoneless app it's scheduled, not run inline.
  3. setInput('label', 'Clicks') updates the input signal and schedules a render — still nothing written to the DOM.
  4. The test never awaits, so it reads textContent of the empty paragraph: ''.
  5. Fix: make the test async and await fixture.whenStable() after setInput (or call fixture.detectChanges() to render synchronously). Await only after createComponent and not after setInput, and you get the other classic failure from the setup topic: "expected 'Count: 0' to be 'Clicks: 0'".
What does this HTTP test report?
TypeScript
it('loads page 2', () => {
  api.search('ad', 2).subscribe();
  httpTesting.expectOne('/api/users').flush([]);
});
// afterEach(() => httpTesting.verify());
Show answer

It fails at expectOne: "Expected one matching request for criteria "Match URL: /api/users", found none. Requests received are: GET /api/users?q=ad&page=2."

What’s happening
  1. subscribe() sends the request; the testing backend records it with the URL /api/users and HttpParams q=ad, page=2.
  2. expectOne with a string compares against urlWithParams, which is /api/users?q=ad&page=2. That's not equal to /api/users, so no match, and expectOne throws listing what it did receive — which is the helpful part of the message.
  3. The test stops there; verify() in afterEach would also complain about the open request.
  4. Fix: httpTesting.expectOne('/api/users?q=ad&page=2'), or expectOne(r => r.url === '/api/users') followed by assertions on req.request.params.

Built from Rohit’s “Angular interview prep” doc. Examples target Angular 22 and are compiled and tested with the Angular CLI and Vitest.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.