Advanced Angular Testing: Guards, Interceptors, Routing and Component Inputs

Angular Tutorial: Beginner to Advanced · 18. Quality, security and delivery · 5 min read · Updated

In the first testing lesson you tested a counter and a store. Real apps need tests for guards, interceptors, navigation and components with inputs and outputs. These tests catch the bugs that matter most: broken login redirects, missing headers and wrong events.

All the tests in this lesson run with ng test --watch=false and use the code from earlier lessons.

What you will learn

  • How to test a guard with RouterTestingHarness
  • How to test an interceptor with HttpTestingController
  • How to test inputs, outputs and mocked services

Test the auth guard

The guard from the route guards lesson redirects to /login and remembers the wanted URL. Test it with a real router and fake pages:

// src/app/auth-guard.spec.ts
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { Auth } from './auth/auth';
import { authGuard } from './auth-guard';

@Component({ template: 'secret page' })
class SecretPage {}

@Component({ template: 'login page' })
class LoginPage {}

describe('authGuard', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        provideRouter([
          { path: 'secret', component: SecretPage, canActivate: [authGuard] },
          { path: 'login', component: LoginPage },
        ]),
      ],
    });
  });

  it('redirects to login and keeps the wanted url when signed out', async () => {
    const harness = await RouterTestingHarness.create();
    await harness.navigateByUrl('/secret');

    const router = TestBed.inject(Router);
    expect(router.url).toBe('/login?returnUrl=%2Fsecret');
  });

  it('opens the page when signed in', async () => {
    TestBed.inject(Auth).login('Ada');

    const harness = await RouterTestingHarness.create();
    const page = await harness.navigateByUrl('/secret', SecretPage);

    expect(page).toBeInstanceOf(SecretPage);
    expect(TestBed.inject(Router).url).toBe('/secret');
  });
});

RouterTestingHarness creates a router outlet for you, navigates, and returns the component that was activated. No browser, no flaky waiting.

Test an interceptor

// src/app/http/interceptors.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Auth } from '../auth/auth';
import { authInterceptor, loadingInterceptor } from './interceptors';
import { Loading } from './loading';

describe('interceptors', () => {
  let http: HttpClient;
  let controller: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        provideHttpClient(withInterceptors([loadingInterceptor, authInterceptor])),
        provideHttpClientTesting(),
      ],
    });
    http = TestBed.inject(HttpClient);
    controller = TestBed.inject(HttpTestingController);
  });

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

  it('adds the auth header only to our own API', () => {
    TestBed.inject(Auth).login('Ada');

    http.get('https://api.example.com/items').subscribe();
    http.get('https://other.example.org/items').subscribe();

    const own = controller.expectOne('https://api.example.com/items');
    const other = controller.expectOne('https://other.example.org/items');

    expect(own.request.headers.get('Authorization')).toContain('Bearer');
    expect(other.request.headers.has('Authorization')).toBe(false);
    own.flush([]);
    other.flush([]);
  });

  it('turns the loading flag on during a request and off afterwards', () => {
    const loading = TestBed.inject(Loading);

    http.get('https://api.example.com/items').subscribe();
    expect(loading.active()).toBe(true);

    controller.expectOne('https://api.example.com/items').flush([]);
    expect(loading.active()).toBe(false);
  });

  it('turns the loading flag off even when the request fails', () => {
    const loading = TestBed.inject(Loading);

    http.get('https://api.example.com/items').subscribe({ error: () => {} });
    controller
      .expectOne('https://api.example.com/items')
      .flush('boom', { status: 500, statusText: 'Server Error' });

    expect(loading.active()).toBe(false);
  });
});

The second and third tests protect the finalize behaviour from the interceptors lesson.

Test inputs and outputs

Use fixture.componentRef.setInput() to set inputs, as the template would, and subscribe to outputs:

// src/app/features/products/ui/product-card.spec.ts
import { TestBed } from '@angular/core/testing';
import { ProductCard } from './product-card';

describe('ProductCard', () => {
  async function create() {
    const fixture = TestBed.createComponent(ProductCard);
    fixture.componentRef.setInput('product', { id: 7, name: 'Lamp', price: 25 });
    await fixture.whenStable();
    return { fixture, el: fixture.nativeElement as HTMLElement };
  }

  it('renders the product', async () => {
    const { el } = await create();
    expect(el.textContent).toContain('Lamp');
    expect(el.textContent).toContain('25 USD');
  });

  it('emits the product id when the button is clicked', async () => {
    const { fixture, el } = await create();
    const emitted: number[] = [];
    fixture.componentInstance.add.subscribe((id: number) => emitted.push(id));

    el.querySelector('button')!.click();

    expect(emitted).toEqual([7]);
  });
});

Mock a service in a page test

// src/app/features/products/pages/products-page.spec.ts
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ProductsFacade } from '../data/products.facade';
import { ProductsPage } from './products-page';

describe('ProductsPage', () => {
  it('shows products from the facade and forwards add events', async () => {
    const added: number[] = [];
    const fake = {
      products: signal([{ id: 1, name: 'Pen', price: 2 }]),
      cartCount: signal(0),
      addToCart: (id: number) => added.push(id),
    };
    TestBed.configureTestingModule({ providers: [{ provide: ProductsFacade, useValue: fake }] });

    const fixture = TestBed.createComponent(ProductsPage);
    await fixture.whenStable();
    const el = fixture.nativeElement as HTMLElement;

    expect(el.textContent).toContain('Pen');
    el.querySelector('button')!.click();
    expect(added).toEqual([1]);

    fake.cartCount.set(3);
    await fixture.whenStable();
    expect(el.textContent).toContain('3 in cart');
  });
});

Because the page depends on the facade abstraction, the test supplies a small fake made of signals, and then changes a signal to see the template react.

Habits of good tests

  • Test behaviour, not implementation. Click and read the text instead of calling private methods.
  • One reason to fail per test, with a name that reads as a sentence.
  • No real network, timers or randomness. Fake them.
  • Cover failure paths: errors, empty lists, double clicks.
  • Keep tests fast. Unit tests should run in seconds, so developers run them all the time.
  • Do not chase 100% coverage. Aim at logic that would hurt if it broke. Run ng test --watch=false --coverage to see gaps.
  • For complex widgets, the Angular CDK offers component harnesses, which give tests a stable API that survives markup changes.

Try it yourself

  1. Write a test for Auth that checks isLoggedIn before and after login and logout.
  2. Add a test that the error interceptor re-throws the original error to the caller.
  3. Test a canMatch guard from the advanced routing lesson with RouterTestingHarness.

Quick check

  1. Which tool lets you test navigation with a real router?
  2. How do you set a signal input in a test?
  3. Why replace a service with a fake in a component test?

Next, test the whole app like a user in end-to-end tests with Playwright. See the testing guide.

Frequently asked questions

Should I test private methods?

No. Test through public behaviour. If a private method is hard to reach, it may deserve to be its own small service with its own tests.

How many unit tests versus end-to-end tests?

Many fast unit and component tests, fewer integration tests, and a small number of end-to-end tests for the most important user journeys.