The duplication pattern
In some projects that I recently worked on, there's a block of code that is used in many places. The code looks like this
1@Component({
2 selector: 'app-my-component'
3})
4export class MyComponent implements OnInit {
5 id$: Observable<string> = this.route.paramMap.pipe(
6 takeUntil(this.destroy$),
7 map(params => params.get('id'))
8 );
9
10 constructor(private route: ActivatedRoute) {}
11
12 ngOnInit(): void {
13 // do something with this.id$
14 }
15}
The above code gets an observable of id
from route param map via ActivatedRoute
.
In other places, it would be getting the customerId
, or getting the currentTabId
or getting data from ActivatedRoute
or ActivatedRouteSnapshot
and do something with it.
The pattern is that using the ActivatedRoute
service to get value from the paramMap
, queryParamMap
, or the data
- either as an observable or as a snapshot.
In fact, there's nothing wrong with the above code block. However, when it comes to writing unit test, you need to mock the implementation of ActivatedRoute
to test the component.
The mock version of ActivatedRoute
would somehow look like the following:
1export class ActivatedRouteStub {
2 // Use a ReplaySubject to share previous values with subscribers
3 // and pump new values into the `paramMap` observable
4 private subject = new ReplaySubject<ParamMap>();
5
6 constructor(initialParams?: Params) {
7 this.setParamMap(initialParams);
8 }
9
10 /** The mock paramMap observable */
11 readonly paramMap = this.subject.asObservable();
12
13 /** Set the paramMap observable's next value */
14 setParamMap(params: Params = {}) {
15 this.subject.next(convertToParamMap(params));
16 }
17}
Then, the test of MyComponent
would be like this
1const activatedRouteStub = new ActivatedRouteStub();
2
3describe('MyComponent', () => {
4 let fixture: ComponentFixture<MyComponent>;
5 let component: MyComponent;
6
7 beforeEach(async () => {
8 // mock the value of paramMap
9 activatedRoute.setParamMap({id: 1234});
10
11 await TestBed.configureTestingModule({
12 declarations: [MyComponent],
13 providers: [
14 {
15 provide: ActivatedRoute,
16 useValue: activatedRouteStub
17 }
18 ]
19 }).compileComponents();
20
21 fixture = TestBed.createComponent(MyComponent);
22 component = fixture.componentInstance;
23 });
24
25 it('should get :id from route param', (done) => {
26 fixture.detectChanges();
27
28 component.id$.subscribe(id => {
29 expect(id).toBe('1234');
30 done();
31 });
32 });
33});
If your component gets data from queryParamMap
in ActivatedRoute
, you should also mock the implementation of queryParamMap
just like we did with paramMap
.
In fact, we can reduce this duplicated logic by using dependency injection. Here're 3 steps to do that.
Declare factory functions to get value from ActivatedRoute
First, we create a file named activated-route.factories.ts
, and write factory functions to get value from ActivatedRoute
. We write this code only once, then reuse it in other places.
activated-route.factories.ts
1import {ActivatedRoute} from '@angular/router';
2import {Observable} from 'rxjs';
3import {map} from 'rxjs/operators';
4
5// this factory function will get value as an observable from route paramMap
6// based on the param key you passed in
7// if your current route is '/customers/:customerId' then you would call
8// routeParamFactory('customerId')
9export function routeParamFactory(
10 paramKey: string
11): (route: ActivatedRoute) => Observable<string | null> {
12 return (route: ActivatedRoute): Observable<string | null> => {
13 return route.paramMap.pipe(map(param => param.get(paramKey)));
14 };
15}
16
17// this factory function will get value as a snapshot
18// from route paramMap
19// based on the param key you passed in
20export function routeParamSnapshotFactory(
21 paramKey: string
22): (route: ActivatedRoute) => string | null {
23 return (route: ActivatedRoute): string | null => {
24 return route.snapshot.paramMap.get(paramKey);
25 };
26}
27
28// same as above factory, but get value from query param
29// if your current route is 'customers?from=USA
30// then you would call queryParamFactory('from')
31export function queryParamFactory(
32 paramKey: string
33): (route: ActivatedRoute) => Observable<string | null> {
34 return (route: ActivatedRoute): Observable<string | null> => {
35 return route.queryParamMap.pipe(map(param => param.get(paramKey)));
36 };
37}
38
39// same as queryParamFactory, but get snapshot, instead of observable
40export function queryParamSnapshotFactory(
41 paramKey: string
42): (route: ActivatedRoute) => string | null {
43 return (route: ActivatedRoute): string | null => {
44 return route.snapshot.queryParamMap.get(paramKey);
45 };
46}
In order to get data from ActivatedRoute
service, the logic of factory functions would be the same.
Declare injection token and provider for this token in your component
Next, you need to define a dependency injection token in your component, and provide value for it. Here's how to do that.
1export const APP_SOME_ID = new InjectionToken<Observable<string>>(
2 'stream of id from route param',
3);
4
5@Component({
6 selector: 'app-my-component',
7 templateUrl: './my-component.template.html',
8 changeDetection: ChangeDetectionStrategy.OnPush,
9 providers: [
10 {
11 provide: APP_SOME_ID,
12 useFactory: routeParamFactory('id'),
13 deps: [ActivatedRoute]
14 }
15 ]
16})
17export class MyComponent {}
In the providers list of your component, you provide value for APP_SOME_ID
by calling the factory function routeParamFactory('id')
. The static param key string 'id'
is actually matched with the one that you declare in your routes configuration. For example
1const routes: Routes = [
2 {
3 path: ':id',
4 component: MyComponent
5 }
6];
Inject the token in component's constructor and use it
The next step is to inject the token you declared in previous step in the component's constructor and use it.
1export const APP_SOME_ID = new InjectionToken<Observable<string>>(
2 'stream of id from route param',
3);
4
5@Component({
6 selector: 'app-my-component',
7 templateUrl: './my-component.template.html',
8 changeDetection: ChangeDetectionStrategy.OnPush,
9 providers: [
10 {
11 provide: APP_SOME_ID,
12 useFactory: routeParamFactory('id'),
13 deps: [ActivatedRoute],
14 },
15 ],
16})
17export class MyComponent {
18 constructor(
19 @Inject(APP_SOME_ID)
20 private readonly id$: Observable<string>) {}
21
22 // then do something with this.id$
23}
So now, when you write unit test for MyComponent
, it would be simple like this
1describe('MyComponent', () => {
2 let fixture: ComponentFixture<MyComponent>;
3 let component: MyComponent;
4
5 beforeEach(async () => {
6 TestBed.overrideComponent(MyComponent, {
7 set: {
8 providers: [{
9 provide: APP_SOME_ID,
10 useValue: scheduled(of('1234'), asyncScheduler)
11 }]
12 }
13 });
14
15 await TestBed.configureTestingModule({
16 declarations: [MyComponent]
17 }).compileComponents();
18
19 fixture = TestBed.createComponent(MyComponent);
20 component = fixture.componentInstance;
21 });
22
23 it('should get :id from route param', (done) => {
24 fixture.detectChanges();
25
26 component.id$.subscribe(id => {
27 expect(id).toBe('1234');
28 done();
29 });
30 });
31});
You don't need to mock the whole ActivatedRoute
service. Instead, you just provide mock value of id
observable and that's it.
There are some benefits of this approach
- It helps reducing duplicated logic in your code so your code would look cleaner, easier to understand and to maintain.
- It's easier for you to test your component. The actual thing you need is the banana, not the whole jungle and a gorilla holding a banana.
Conclusion
Dependency Injection in Angular is a powerful tool that in my opinion, you should leverage it as much as possible. In this article, I walked you through the pattern of duplication when getting route parameters from ActivatedRoute
service. Then I showed you how to reduce duplicated code by using dependency injection in just 3 simple steps.
The full code can be found on Github in case you'd like to explore it further.
Thank you for reading and have a great day!