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');
});
});
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.”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.