Testing Routing — Navigation, Guards and Route Parameters

Routing integration in Angular components is tested by providing either a stub ActivatedRoute (for components that read route parameters) or a RouterTestingModule setup (for components that navigate). Functional guards are the simplest to test — they are plain functions that take route and state parameters, so they can be called directly without any TestBed setup. Only the guard’s logic is tested, not the router integration.

Testing Routing and Guards

// ── Testing a functional route guard directly ─────────────────────────────
describe('authGuard', () => {
  let authService: jasmine.SpyObj<AuthService>;

  beforeEach(() => {
    authService = jasmine.createSpyObj('AuthService', ['isLoggedIn']);

    TestBed.configureTestingModule({
      providers: [
        provideRouter([]),
        { provide: AuthService, useValue: authService },
      ],
    });
  });

  it('returns true when user is logged in', () => {
    authService.isLoggedIn.and.returnValue(true);

    TestBed.runInInjectionContext(() => {
      const result = authGuard(
        {} as ActivatedRouteSnapshot,
        { url: '/admin' } as RouterStateSnapshot
      );
      expect(result).toBeTrue();
    });
  });

  it('redirects to login with returnUrl when not authenticated', () => {
    authService.isLoggedIn.and.returnValue(false);

    let result: boolean | UrlTree | undefined;
    TestBed.runInInjectionContext(() => {
      result = authGuard(
        {} as ActivatedRouteSnapshot,
        { url: '/admin/posts' } as RouterStateSnapshot
      ) as boolean | UrlTree;
    });

    expect(result).toBeInstanceOf(UrlTree);
    const urlTree = result as UrlTree;
    expect(urlTree.queryParams['returnUrl']).toBe('/admin/posts');
  });
});

// ── Testing component that reads route params ─────────────────────────────
describe('PostDetailComponent', () => {
  let postsApi: jasmine.SpyObj<PostsApiService>;

  const mockPost: PostDto = {
    id: 1, title: 'Test Post', slug: 'test-post',
    body: '<p>Content</p>', isPublished: true,
  };

  beforeEach(async () => {
    postsApi = jasmine.createSpyObj('PostsApiService', ['getBySlug']);
    postsApi.getBySlug.and.returnValue(of(mockPost));

    await TestBed.configureTestingModule({
      imports:   [PostDetailComponent, NoopAnimationsModule],
      providers: [
        { provide: PostsApiService, useValue: postsApi },
        provideRouter([{
          path: 'posts/:slug',
          component: PostDetailComponent,
        }]),
      ],
    }).compileComponents();
  });

  it('loads post by slug from route input', async () => {
    const fixture   = TestBed.createComponent(PostDetailComponent);
    const component = fixture.componentInstance;

    // Simulate route input binding (withComponentInputBinding)
    fixture.componentRef.setInput('slug', 'test-post');
    fixture.detectChanges();
    await fixture.whenStable();
    fixture.detectChanges();

    expect(postsApi.getBySlug).toHaveBeenCalledWith('test-post');
    expect(fixture.nativeElement.querySelector('h1').textContent)
      .toContain('Test Post');
  });

  it('shows not-found when post is missing', async () => {
    postsApi.getBySlug.and.returnValue(
      throwError(() => ({ status: 404 } as ApiError))
    );

    const fixture   = TestBed.createComponent(PostDetailComponent);
    fixture.componentRef.setInput('slug', 'missing-post');
    fixture.detectChanges();
    await fixture.whenStable();
    fixture.detectChanges();

    const notFound = fixture.nativeElement.querySelector('[data-cy="not-found"]');
    expect(notFound).not.toBeNull();
  });
});

// ── Testing programmatic navigation ──────────────────────────────────────
describe('LoginComponent navigation', () => {
  it('navigates to returnUrl after login', async () => {
    const router = TestBed.inject(Router);
    spyOn(router, 'navigateByUrl');

    const authSvc = TestBed.inject(AuthService);
    spyOn(authSvc, 'login').and.returnValue(of(undefined));

    // Simulate returnUrl query param
    const route = TestBed.inject(ActivatedRoute);
    spyOn(route.snapshot.queryParamMap, 'get').and.returnValue('/admin/posts');

    component.form.patchValue({ email: 'a@test.com', password: 'Pass!123' });
    component.onSubmit();
    await fixture.whenStable();

    expect(router.navigateByUrl).toHaveBeenCalledWith('/admin/posts');
  });
});
Note: TestBed.runInInjectionContext() is required to call functional guards in tests because guards use inject() internally. The injection context is not available outside Angular’s DI framework. Wrapping the guard call in runInInjectionContext provides the correct Angular context, allowing inject(AuthService) inside the guard to resolve from the TestBed’s DI container. Without this wrapper, calling a guard that uses inject() throws “inject() must be called from an injection context.”
Tip: Test functional guards by calling them directly — not by routing to a protected URL and checking if the redirect happened. The direct approach is faster, more isolated, and produces clearer failure messages. The routing integration (that the guard is actually applied to the route) is tested once in an integration test, not in every guard unit test. Unit tests for guards focus purely on the guard logic: when should it return true, when a UrlTree, and with what parameters.
Warning: When testing components with withComponentInputBinding() (Angular 18’s route-to-input binding), provide the router with the actual component route definition: provideRouter([{ path: 'posts/:slug', component: PostDetailComponent }]). This ensures Angular’s input binding wires the route params to the component’s @Input() properties correctly. Without the route definition, the router doesn’t know that :slug should be bound to the slug input, and the component receives undefined.

Common Mistakes

Mistake 1 — Calling inject() in guard tests without runInInjectionContext (error)

❌ Wrong — authGuard(route, state) directly; guard uses inject(); throws “inject() must be called from an injection context.”

✅ Correct — wrap in TestBed.runInInjectionContext(() => { result = authGuard(route, state); }).

Mistake 2 — Testing guard by navigating to protected URL (tests router + guard together)

❌ Wrong — testing guard logic via router navigation; if router setup is wrong, guard tests fail for the wrong reason.

✅ Correct — call guard function directly; test only the guard logic; keep router integration in separate integration tests.

🧠 Test Yourself

A component uses @Input() slug = input<string>('') (signal-based input). In TestBed, fixture.componentRef.setInput('slug', 'test-post') is called. Does the signal update?