Getting Started with Angular Route Parameters: A Beginner's Guide to Accessing and Testing Route Parameters

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.

Source code

gitlab.com/barcioch-blog-examples/013-angul..