Search code examples
angularjasmineangular-routingangular-router

Issues testing data in an Angular route definition


I'm trying to test a component that dynamically sets a title based on data in a route, but I'm having trouble with the testing bit.

I'm trying to mock out the route data, but in my method for grabbing the data findRouteData it just ends up undefined when debugging the tests.

I can visually verify that the component itself does what I want it to do, but I'm having trouble with mocking the route data bit.

Like I said, everything is being called, but my mock of the routerState isn't working properly. Data is undefined.

How do I properly mock the route data?

@Injectable()
class FakeRouter {
    url: string;
    subject = new Subject();
    events = this.subject.asObservable();
    routerState = {
        snapshot: {
            root: {
                data: {title: 'Title'}
            }
        }
    };
    navigate(url: Array<string>): void {
        this.url = url.join('');
        this.triggerNavEvents(this.url);
    }

    triggerNavEvents(url: string): void {
        this.subject.next(new NavigationEnd(0, url, null));
    }
}

The component itself:

import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router';
import { Subject } from 'rxjs/Subject';

@Component({
    selector: 'hq-page-title',
    templateUrl: './page-title.component.html',
    styleUrls: ['./page-title.component.scss'],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class PageTitleComponent implements OnInit, OnDestroy {
    public title;
    private unsubscribe = new Subject<boolean>();

    constructor(private router: Router, private cdRef: ChangeDetectorRef) {
    }

    ngOnInit(): void {
        this.router.events
            .filter((event: any) => event instanceof NavigationEnd)
            .takeUntil(this.unsubscribe)
            .subscribe(() => {
                const test = this.router;
                const test1 = this.router.routerState;
                const routeData = this.findRouteData(this.router.routerState.snapshot.root);
                if (routeData.hasOwnProperty('title')) {
                    this.title = routeData.title;
                    this.cdRef.detectChanges();
                }
            });
    }

    ngOnDestroy(): void {
        this.unsubscribe.next();
        this.unsubscribe.complete();
    }

    /**
     * Returns the route data
     *
     * Check out the following links to see why it was implemented this way:
     * @link https://github.com/angular/angular/issues/19420
     * @link https://github.com/angular/angular/issues/11812#issuecomment-346637722
     *
     * @param {ActivatedRouteSnapshot} root
     * @returns {any}
     */
    private findRouteData(root: ActivatedRouteSnapshot) {
        let data = <any>{};
        while (root) {
            if (root.children && root.children.length) {
                root = root.children[0];
            } else if (root.data) {
                data = {...data, ...root.data};
                return data;
            } else {
                return data;
            }
        }
    }

}

An example route:

{
    path: 'settings',
    data: {title: 'Settings'},
} 

The tests:

import { async, ComponentFixture, TestBed } from '@angular/core/testing';

import { PageTitleComponent } from './page-title.component';
import {NavigationEnd, Router} from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import {Subject} from 'rxjs/Subject';
import {Injectable} from '@angular/core';

@Injectable()
class FakeRouter {
    url: string;
    subject = new Subject();
    events = this.subject.asObservable();
    routerState = {
        snapshot: {
            root: {
                data: {title: 'Title'}
            }
        }
    };
    navigate(url: Array<string>): void {
        this.url = url.join('');
        this.triggerNavEvents(this.url);
    }

    triggerNavEvents(url: string): void {
        this.subject.next(new NavigationEnd(0, url, null));
    }
}

describe('PageTitleComponent', () => {
    let component: PageTitleComponent;
    let fixture: ComponentFixture<PageTitleComponent>;
    let router: Router;

    beforeEach(async(() => {
        TestBed.configureTestingModule({
            imports: [RouterTestingModule],
            declarations: [
                PageTitleComponent
            ],
            providers: [
                { provide: router, useClass: FakeRouter }
            ]
        })
            .compileComponents()
            .then(() => {
                fixture = TestBed.createComponent(PageTitleComponent);
                component = fixture.componentInstance;

                router = TestBed.get(Router);
                fixture.detectChanges();
            });
    }));
    it('should create', () => {
        expect(component).toBeTruthy();
    });

    describe('the title should be title', () => {
        beforeEach(async(() => {
            router.navigate(['']);
        }));
        it('the title should be title', () => {
            expect(component.title).toBe('Title');
        });
    });
});

Solution

  • I solved my own problem, as these things often go.

    Rather than trying to go into the router.routerState to get the route data, I switched to using the ActivatedRoute, and used the snapshot from that. I had shied away from that implementation earlier because I found that subscribing to the ActivatedRoute's data property didn't yield the route data. It just ended up being a blank object.

    However, I found that if I looked into the ActivatedRoute once the route events had reached NavigationEnd all the data I was looking for was there.

    ActivatedRoute was much easier to mock. Inside TestBed.configureTestingModule() I added this piece to the providers:

    {
        provide: ActivatedRoute,
        useValue: {
            snapshot: {
                data: {title: 'Title'}
            }
        },
    },
    

    The changes in my main TS file are these:

    constructor(private router: Router, private activatedRoute: ActivatedRoute, private cdRef: ChangeDetectorRef) {
    }
    
    ngOnInit(): void {
        this.router.events
            .filter((event: any) => event instanceof NavigationEnd)
            .takeUntil(this.unsubscribe)
            .subscribe(() => {
                const routeData = this.findRouteData(this.activatedRoute.snapshot);
                if (routeData.hasOwnProperty('title')) {
                    this.title = routeData.title;
                    this.cdRef.detectChanges();
                }
            });
    }
    

    And I removed this bit from the mock router:

    routerState = {
        snapshot: {
            root: {
                data: {title: 'Title'}
            }
        }
    };
    

    Really not too many changes.

    Note: I could still be missing something here. Please let me know if you think I am.