angularunit-testingjasmineangular-router-params

Unit test asynchronous code in a component in Angular 9


Update 2: Turns out it seems most likely that my new test is responding to the url params of the previous test when it is first loaded. This is obviously not what I want and likely means I need to change the routing. Is the only solution to reset the route params in the afterEach? Is there a better way to handle this?

Update:

After some more digging, I no longer think it's an async issue but I'm not sure what the issue is. I think it has to do with the routing or maybe the subscription or something. Here is a sample of a failed test scenario from some console logs I had thrown into the component.

//This is an emulated copy of what I see in the console

new request: 
> {pageId: 3, testCount: 1}
retrievePage: making the request with:
> {pageId: 3, testCount: 1}
new request:
> {id: 2, testCount: 2}
retrieveItem: making the request with:
> {id: 2, testCount: 2}
retrieveItem: made the request with: 
> {id: 2, testCount: 2}
FAILED MyComponent when the url matches item/:id should load the appropriate item
retrievePage: made the request with:
> {pageId: 3, testCount: 1}
> error properties: Object({ originalStack: 'Error: Expected spy service.retrievePage not to have been called.....

I have updated the code snippets below to reflect the console logs I added in.

As you can see from the logs, for some reason the params subscribe gets run a second time for the first test (the first test ran and completed fine, logging the "new request" and "making..."/"made..." messages just once only). I don't know if there's a way to determine which test component this is being run with (if for some reason the new component is generated and responds to the params from the previous test) or what. I'm unsure really what the issue is or how to debug further.

Original Question:

I'm having an issue with testing my component that runs some asynchronous code. I can't post the actual code due to NDA but I have provided a simple representation that is as close to my real code as I can get.

I have a component that runs some asynchronous code like so (with only the relevant bits included, default imports/setup are implied):

...
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
import { MyService } from 'services/my-service';

@Component({
    ...
})
export class MyComponent implements OnInit, OnDestroy {
    private paramsSubscription: Subscription;

    constructor(private route: ActivatedRoute, private service: MyService) {}

    ngOnInit(): void {
        this.paramsSubscription = this.route.params.subscribe(async params => {
            console.log('new request: ', params);
            let result;
            let itemId = params.id;
            
            if (params.pageId) {
                
                console.log('retrievePage: making the request with: ', params);
                let page = await this.service.retrievePage(params.pageId).toPromise();
                console.log('retrievePage: made the request with: ', params);
                itemId = page.items[0].id;
            }

            if (itemId) {
                console.log('retrieveItem: making the request with: ', params);
                result = await this.service.retrieveItem(itemId).toPromise();
                console.log('retrieveItem: made the request with: ', params);
            }
            ...
        });
    }
    ngOnDestroy() {
        if (this.paramsSubscription) this.paramsSubscription.unsubscribe();
    }
}

I then have a unit test like this (also sample code):

...
import { fakeAsync, tick } from '@angular/core/testing';
import { of, ReplaySubject } from 'rxjs';
import { ActivatedRoute, Params } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { MyService } from 'services/my-service';
import { MyComponent } from './my.component';

describe('MyComponent', () => {
    let component: MyComponent;
    let fixture: ComponentFixture<MyComponent>;

    let service = jasmine.createSpyObj('MyService', {
        retrieveItem: of([]), 
        retrievePage: of([])
    });
    let route: ActivatedRoute;
    let routeParams = new ReplaySubject<Params>(1);
    
    let testCount = 0;

    let item = {
        id: 2,
        ...
    };

    let page = {
        id: 3,
        items: [item]
        ...
    };

    beforeEach(async(() => {
        TestBed.configureTestingModule({
            declarations: [MyComponent],
            imports: [RouterTestingModule],
            providers: [
                { provide: MyService, useValue: service },
                { provide: ActivatedRoute, useValue: 
                    jasmine.createSpyObj('ActivatedRoute',[], {
                        params: routeParams.asObservable()
                    }) 
                },
            ]
        }).compileComponents();
    }));

    beforeEach(() => {
        fixture = TestBed.createComponent(MyComponent);
        component = fixture.componentInstance;

        service = TestBed.inject(MyService);
        route = TestBed.inject(ActivatedRoute);
        
        testCount++;

        fixture.detectChanges();
    });
    
    afterEach(() => {
        service.retrieveItem.calls.reset();
        service.retrievePage.calls.reset();
    });

    it('should create', () => {
        expect(component).toBeTruthy();
    });
    

    describe('when the url matches: item/:id', () => {
        beforeEach(fakeAsync(() => {
            routeParams.next({id: item.id, testCount: testCount});
            tick();
            fixture.detectChanges();
        }));

        it('should load the appropriate item', () => {
            expect(service.retrieveItem).toHaveBeenCalledWith(item.id);
            expect(service.retrievePage).not.toHaveBeenCalled();
        });
        ...
    });

    describe('when the url matches: item', () => {
        beforeEach(fakeAsync(() => {
            routeParams.next({testCount: testCount});
            tick();
            fixture.detectChanges();
        }));

        it('should load the first item', () => {
            expect(service.retrievePage).not.toHaveBeenCalled();
            expect(service.retrieveItem).toHaveBeenCalledWith(1);
        });
        ...
    });

    describe('when the url matches: item/:pageId', () => {
        beforeEach(fakeAsync(() => {
            routeParams.next({pageId: page.id, testCount: testCount});
            tick();
            fixture.detectChanges();
        }));

        it('should load the item from the page', () => {
            expect(service.retrievePage).toHaveBeenCalledWith(page.id);
            expect(service.retrieveItem).toHaveBeenCalledWith(page.items[0].id);
        });
        ...
    });
});

The problem I'm having is in my unit tests, the await calls from my component haven't completed before it moves on to the next unit test so my expect calls aren't functioning properly. When my tests run in perfect order everything is fine. But sometimes my tests run in a different order say 1, 3, 2. When they do this, test # 2 fails because it expects retrieveItem to have been called with id 2 but the previous call triggered it with id 1. (This is just a contrived example, my actual code is more complicated than this)

All I'm wondering is, how do I tell jasmine to wait for the calls that are being triggered within my component to return. I've tried looking at the tutorials on aynchronous code for jasmine https://jasmine.github.io/tutorials/async but those all seem to reference making an asynchronous call manually from within my test which isn't what I'm trying to do.

The fakeAsync with the tick() was my attempt at getting it to wait for the routing and everything to finish but it didn't seem to solve my problem. Does anyone know what I'm doing wrong or how to solve my issue?

I have also tried:

Also, if I add routeParams.next({}); to my afterEach() it fixes the issue but I feel like that's defeating the purpose of the tests being random order because that implies that my code will only function if you are coming from no params, which in my real implementation you can get here with any variety of params in your url (e.g. I can go from pageId: 1 to id: 2 with no inbetween step).

Further information: in my real code it happens like this:

//This is pseudo code
params.subscribe
    if(params.first) param1 = params.first;
    if(params.second) param2 = params.second;
    if (param1) {
        item = function1();
        param2 = item.param2;
    }
    if(param2) {
        otherItem = await function2();
    } else if (item) {
        result = await function3();
    }

My tests are checking if function1, function2, and function3 have been called when the activatedRoute had various params being passed. I have added console logs in my component to confirm that test2 will start and function2 from test1 is being called after test2 has started. So my check to make sure that function2 hasn't been called, is failing because it's delayed from test1.

Any advice or tips are greatly appreciated.


Solution

  • So after a bunch more digging and googling and I managed to find a solution to my problem myself, so I'm sharing it here incase anyone else is ever as confused as I was.

    https://angular.io/guide/testing-components-scenarios#testing-with-activatedroutestub

    On angular documentation when they are working with activatedRoutes, I noticed that they trigger the route first, then create the component. From observing how the app normally works (not during testing) that is more inline with how angular actually functions - routing happens before the component is created. Basing my solution off of that fact, I ended up reworking my test to be like follows:

    ...
    import { async } from '@angular/core/testing';
    import { of, ReplaySubject } from 'rxjs';
    import { ActivatedRoute, Params } from '@angular/router';
    import { RouterTestingModule } from '@angular/router/testing';
    import { MyService } from 'services/my-service';
    import { MyComponent } from './my.component';
    
    describe('MyComponent', () => {
        let component: MyComponent;
        let fixture: ComponentFixture<MyComponent>;
    
        let service = jasmine.createSpyObj('MyService', {
            retrieveItem: of([]), 
            retrievePage: of([])
        });
        let route: ActivatedRoute;
        let routeParams = new ReplaySubject<Params>(1);
        
        let testCount = 0;
    
        let item = {
            id: 2,
            ...
        };
    
        let page = {
            id: 3,
            items: [item]
            ...
        };
    
        beforeEach(async(() => {
            TestBed.configureTestingModule({
                declarations: [MyComponent],
                imports: [RouterTestingModule],
                providers: [
                    { provide: MyService, useValue: service },
                    { provide: ActivatedRoute, useValue: 
                        jasmine.createSpyObj('ActivatedRoute',[], {
                            params: routeParams.asObservable()
                        }) 
                    },
                ]
            }).compileComponents();
        }));
    
        beforeEach(() => {
            service = TestBed.inject(MyService);
            route = TestBed.inject(ActivatedRoute);
        });
        
        function createComponent() {
            fixture = TestBed.createComponent(MyComponent);
            component = fixture.componentInstance;
    
            fixture.detectChanges();
        }
        
        afterEach(() => {
            service.retrieveItem.calls.reset();
            service.retrievePage.calls.reset();
        });
    
        it('should create', () => {
            createComponent();
            expect(component).toBeTruthy();
        });
        
    
        describe('when the url matches: item/:id', () => {
            beforeEach(async () => {
                routeParams.next({id: item.id, testCount: testCount});
                createComponent();
            });
    
            it('should load the appropriate item', () => {
                expect(service.retrieveItem).toHaveBeenCalledWith(item.id);
                expect(service.retrievePage).not.toHaveBeenCalled();
            });
            ...
        });
    
        describe('when the url matches: item', () => {
            beforeEach(async () => {
                routeParams.next({testCount: testCount});
                createComponent();
            });
    
            it('should load the first item', () => {
                expect(service.retrievePage).not.toHaveBeenCalled();
                expect(service.retrieveItem).toHaveBeenCalledWith(1);
            });
            ...
        });
    
        describe('when the url matches: item/:pageId', () => {
            beforeEach(async () => {
                routeParams.next({pageId: page.id, testCount: testCount});
                createComponent();
            ));
    
            it('should load the item from the page', () => {
                expect(service.retrievePage).toHaveBeenCalledWith(page.id);
                expect(service.retrieveItem).toHaveBeenCalledWith(page.items[0].id);
            });
            ...
        });
    });