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

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

Testing the hard parts

  1. Guards, interceptors and routes can be tested without a real server.
  2. Run a guard function with runInInjectionContext and check what it returns.
  3. For interceptors, use HttpTestingController to see the request and send a fake response.
  4. The router testing harness navigates and lets you check which component showed.
  5. Use setInput on the component fixture to test component inputs.

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.