Getting Started with Angular Route Parameters: A Beginner's Guide to Accessing and Testing Route Parameters
In this article, I'll show how to extract parameters and data from Angular routes using the native ActivatedRoute
.
Setup
If unsure how to set up Angular with Jest please refer to the article: barcioch.pro/angular-with-jest-setup.
helper pipe
I created a very basic pipe to format the Params
. It's just for the testing purposes.
import { Pipe, PipeTransform } from '@angular/core';
import { Params } from '@angular/router';
@Pipe({
name: 'formatParams',
})
export class FormatParamsPipe implements PipeTransform {
transform(input: Params): string {
return Object.keys(input).map(key => `${key}:${input[key]}`).join(',');
}
}
ActivatedRoute
The first way of accessing route parameters is through the ActivatedRoute
. It can be injected into a component and contains route information associated with a component. Since this class contains a lot of data, we'll be interested only in the following:
path params
query params
data - custom data assigned to the routes.
These properties are never nullish and are a type of interface Params {[key: string]: any;}
. This means that the property names are always of string
type but the values can be of any
type.
ActivatedRouteSnapshot
The ActivatedRoute.snapshot
contains the data from the given moment. This data is read-only and does not change. All the properties are exposed as plain values. That means, if you keep the reference to the snapshot, and then navigate to the same URL with different parameters without re-rendering the component, the values won't change. You'll have to get the newest snapshot from the ActivatedRoute
.
import { Component, inject, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-snapshot-data',
template: `
<div>{{ activatedRoute.snapshot.params | formatParams }}</div>
<div>{{ activatedRoute.snapshot.queryParams | formatParams }}</div>
<div>{{ activatedRoute.snapshot.data | formatParams }}</div>
`
})
export class SnapshotDataComponent implements OnInit {
readonly activatedRoute = inject(ActivatedRoute);
ngOnInit(): void {
console.log(this.activatedRoute.snapshot.params);
console.log(this.activatedRoute.snapshot.queryParams);
console.log(this.activatedRoute.snapshot.data);
}
}
Observable properties
The ActivatedRoute
has also 3 observable properties: params
, queryParams
and data
. These properties work as BehaviourSubject
so the actual values are immediately emitted upon subscription. The most important fact is that they emit values whenever associated route parameters change.
import { Component, inject, OnInit } from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';
@Component({
selector: 'app-observable-data',
template: `
<div>{{ activatedRoute.params | async | formatParams }}</div>
<div>{{ activatedRoute.queryParams | async | formatParams }}</div>
<div>{{ activatedRoute.data | async | formatParams }}</div>
`
})
export class ObservableDataComponent implements OnInit {
readonly activatedRoute = inject(ActivatedRoute);
ngOnInit(): void {
this.activatedRoute.params.subscribe((params: Params) => console.log(params));
this.activatedRoute.queryParams.subscribe((queryParams: Params) => console.log(queryParams));
this.activatedRoute.data.subscribe((data: Params) => console.log(data));
}
}
To access the data in the view I'm using the async
pipe. In the component it's a basic subscription. Note, that I'm not unsubscribing from the ActivatedRoute
observables. This is one of the exceptions, where it's not needed because Angular takes care of it.
Testing the ActivatedRoute
Before testing let's take a look at the TestComponent
. It's displaying:
observable params, query params, data
current snapshot params, query params, data
initial snapshot params, query params, data
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute } from '@angular/router';
import { FormatParamsPipeModule } from '../format-params/format-params-pipe.module';
@Component({
selector: 'app-test',
template: `
<div data-test='observable-params'>{{ activatedRoute.params | async | formatParams }}</div>
<div data-test='observable-query-params'>{{ activatedRoute.queryParams | async | formatParams }}</div>
<div data-test='observable-data'>{{ activatedRoute.data | async | formatParams }}</div>
<div data-test='snapshot-params'>{{ activatedRoute.snapshot?.params | formatParams }}</div>
<div data-test='snapshot-query-params'>{{ activatedRoute.snapshot?.queryParams | formatParams }}</div>
<div data-test='snapshot-data'>{{ activatedRoute.snapshot?.data | formatParams }}</div>
<div data-test='initial-snapshot-params'>{{ initialReference?.params | formatParams }}</div>
<div data-test='initial-snapshot-query-params'>{{ initialReference?.queryParams | formatParams }}</div>
<div data-test='initial-snapshot-data'>{{ initialReference?.data | formatParams }}</div>
`,
standalone: true,
imports: [CommonModule, FormatParamsPipeModule],
})
export class TestComponent {
readonly activatedRoute = inject(ActivatedRoute);
readonly initialReference = this.activatedRoute.snapshot;
}
When declaring the routes I'm using paramsInheritanceStrategy: 'always'
to make sure that the child components can access all the parents' parameters.
import { Router } from '@angular/router';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TestComponent } from './modules/test/test.component';
import { RouterTestingModule } from '@angular/router/testing';
import { By } from '@angular/platform-browser';
import { Component } from '@angular/core';
describe('ActivatedRouteTest', () => {
let router: Router;
let fixture: ComponentFixture<RouterOutletComponent>;
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [RouterOutletComponent],
imports: [
TestComponent,
RouterTestingModule.withRoutes([
{
path: '',
component: RouterOutletComponent,
children: [
{
path: `products`,
children: [
{
path: ':productId',
component: TestComponent,
data: {
secret: 'product-details',
},
},
{
path: '',
data: {
secret: 'product-list',
},
component: TestComponent,
},
]
},
]
}
], {paramsInheritanceStrategy: 'always'}),
],
});
fixture = TestBed.createComponent(RouterOutletComponent);
router = TestBed.inject(Router);
router.initialNavigation();
});
describe('when user enters /products url', () => {
beforeEach(async () => {
await router.navigateByUrl('/products');
fixture.detectChanges();
await fixture.whenStable();
});
it('should display route properties', () => {
const expected: SnapshotDump = {
params: '',
queryParams: '',
data: 'secret:product-list',
snapshotParams: '',
snapshotQueryParams: '',
snapshotData: 'secret:product-list',
initialSnapshotParams: '',
initialSnapshotQueryParams: '',
initialSnapshotData: 'secret:product-list',
};
expect(getSnapshotDump()).toEqual(expected);
});
describe('and user navigates to /products/123 url', () => {
beforeEach(async () => {
await router.navigateByUrl('/products/123');
fixture.detectChanges();
});
it('should display route properties', () => {
const expected: SnapshotDump = {
params: 'productId:123',
queryParams: '',
data: 'secret:product-details',
snapshotParams: 'productId:123',
snapshotQueryParams: '',
snapshotData: 'secret:product-details',
initialSnapshotParams: 'productId:123',
initialSnapshotQueryParams: '',
initialSnapshotData: 'secret:product-details',
};
expect(getSnapshotDump()).toEqual(expected);
});
describe('and user navigates to /products/998 url', () => {
beforeEach(async () => {
await router.navigateByUrl('/products/998');
fixture.detectChanges();
});
it('should display route properties', () => {
const expected: SnapshotDump = {
params: 'productId:998',
queryParams: '',
data: 'secret:product-details',
snapshotParams: 'productId:998',
snapshotQueryParams: '',
snapshotData: 'secret:product-details',
initialSnapshotParams: 'productId:123',
initialSnapshotQueryParams: '',
initialSnapshotData: 'secret:product-details',
};
expect(getSnapshotDump()).toEqual(expected);
});
describe('and user navigates to /products/998?param1=val1 url', () => {
beforeEach(async () => {
await router.navigateByUrl('/products/998?param1=val1');
fixture.detectChanges();
});
it('should display route properties', () => {
const expected: SnapshotDump = {
params: 'productId:998',
queryParams: 'param1:val1',
data: 'secret:product-details',
snapshotParams: 'productId:998',
snapshotQueryParams: 'param1:val1',
snapshotData: 'secret:product-details',
initialSnapshotParams: 'productId:123',
initialSnapshotQueryParams: '',
initialSnapshotData: 'secret:product-details',
};
expect(getSnapshotDump()).toEqual(expected);
});
describe('and user navigates to /products/998?param2=val2 url', () => {
beforeEach(async () => {
await router.navigateByUrl('/products/998?param2=val2');
fixture.detectChanges();
});
it('should display route properties', () => {
const expected: SnapshotDump = {
params: 'productId:998',
queryParams: 'param2:val2',
data: 'secret:product-details',
snapshotParams: 'productId:998',
snapshotQueryParams: 'param2:val2',
snapshotData: 'secret:product-details',
initialSnapshotParams: 'productId:123',
initialSnapshotQueryParams: '',
initialSnapshotData: 'secret:product-details',
};
expect(getSnapshotDump()).toEqual(expected);
});
});
});
});
});
});
const getSnapshotDump = (): SnapshotDump => {
return {
params: fixture.debugElement.query(By.css('[data-test="observable-params"]')).nativeElement.textContent,
queryParams: fixture.debugElement.query(By.css('[data-test="observable-query-params"]')).nativeElement.textContent,
data: fixture.debugElement.query(By.css('[data-test="observable-data"]')).nativeElement.textContent,
snapshotParams: fixture.debugElement.query(By.css('[data-test="snapshot-params"]')).nativeElement.textContent,
snapshotQueryParams: fixture.debugElement.query(By.css('[data-test="snapshot-query-params"]')).nativeElement.textContent,
snapshotData: fixture.debugElement.query(By.css('[data-test="snapshot-data"]')).nativeElement.textContent,
initialSnapshotParams: fixture.debugElement.query(By.css('[data-test="initial-snapshot-params"]')).nativeElement.textContent,
initialSnapshotQueryParams: fixture.debugElement.query(By.css('[data-test="initial-snapshot-query-params"]')).nativeElement.textContent,
initialSnapshotData: fixture.debugElement.query(By.css('[data-test="initial-snapshot-data"]')).nativeElement.textContent,
}
}
});
@Component({
selector: 'router-outlet-dummy',
template: '<router-outlet></router-outlet>',
})
export class RouterOutletComponent {
}
interface SnapshotDump {
params: string;
queryParams: string;
data: string;
snapshotParams: string;
snapshotQueryParams: string;
snapshotData: string;
initialSnapshotParams: string;
initialSnapshotQueryParams: string;
initialSnapshotData: string;
}
In this test, I'm navigating between routes and validating the parameters. The test confirms that the params and snapshot params are up-to-date when accessing the activated route. Also confirmed, the initial snapshot parameters don't change upon navigation.