In my app I have a Component that simply gets some value async via some service and displays it to the user.
The code of the Component looks like the following:
@Component({
changeDetection: ChangeDetectionStrategy.Default,
selector: 'test-component',
template: `<div id="my-special-property">{{ myProperty }}</div>`,
})
export class DashboardComponent implements OnInit {
private readonly someService = inject(SomeService);
myProperty: string;
ngOnInit(): void {
this.someService.getSomeDataAsync().subscribe((value) => {
this.myProperty = value;
});
}
}
and it is tested with a simple test that looks like this:
it('should correctly display myProperty in the HTML template of the Component', () => {
const myMockedValue = 'Some mock value;'
const { fixture, component, page } = setup();
const someService = TestBed.inject(SomeService);
spyOn(someService, 'getSomeDataAsync').and.returnValue(myMockedValue);
fixture.detectChanges();
expect(page.getMySpecialPropertyLocator().innerHTML).toContain(myMockedValue);
});
Everything works fine so far. Now, suppose that I want to change the ChangeDetectionStrategy to OnPush. The Component will be broken because the change detection is not triggered after the value of myProperty has been updated:
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'test-component',
template: `<div id="my-special-property">{{ myProperty }}</div>`,
})
export class DashboardComponent implements OnInit {
private readonly someService = inject(SomeService);
myProperty: string;
ngOnInit(): void {
this.someService.getSomeDataAsync().subscribe((value) => {
this.myProperty = value;
});
}
}
Now, what is interesting is that the test is still passing. Because the change detection is triggered by that fixture.detectChanges().
This is inconsistent: now my test is telling my that everything works fine, but in reality, it does not.
I know that to fix this bug, I simply have to manually call the change detection in my Component:
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'test-component',
template: `<div id="my-special-property">{{ myProperty }}</div>`,
})
export class DashboardComponent implements OnInit {
private readonly someService = inject(SomeService);
private readonly changeDetectorRef = inject(ChangeDetectorRef);
myProperty: string;
ngOnInit(): void {
this.someService.getSomeDataAsync().subscribe((value) => {
this.myProperty = value;
this.changeDetectorRef.detectChanges(); // this fixes the problem
});
}
}
But with this fix, if I remove the fixture.detectChanges() from the test in the attempt to make it more realistic, it will fail:
it('should correctly display myProperty in the HTML template of the Component', () => {
const myMockedValue = 'Some mock value;'
const { fixture, component, page } = setup();
const someService = TestBed.inject(SomeService);
spyOn(someService, 'getSomeDataAsync').and.returnValue(myMockedValue);
// this will fail
expect(page.getMySpecialPropertyLocator().innerHTML).toContain(myMockedValue);
});
I found a way to make it realistic, meaning that the test would pass ONLY when the fix of this.changeDetectorRef.detectChanges() and it would fail when it is not there.
This way is calling component.ngOnInit() in the test manually, instead of fixture.detectChanges(), but I find it a bit ugly:
it('should correctly display myProperty in the HTML template of the Component', () => {
const myMockedValue = 'Some mock value;'
const { fixture, component, page } = setup();
const someService = TestBed.inject(SomeService);
spyOn(someService, 'getSomeDataAsync').and.returnValue(myMockedValue);
component.ngOnInit();
expect(page.getMySpecialPropertyLocator().innerHTML).toContain(myMockedValue);
});
what would be the proper way to achieve the same, without manually calling component.ngOnInit() from the unit test?
In other words, how to make sure that the unit test is realistic and it truly reflects the way the component behaves in the real application after OnPush change detection is used?
Note: this example is a simplified version of the real Component, which can be found here:
https://github.com/azerothcore/Keira3/tree/master/src/app/features/dashboard
Usually when dealing with
OnPushyou should use a more reactive approach.The key is to use the
AsyncPipe.Simply, there are two possible way
When you subscribe to the HTTP observable call, simply update the state. In your template, you will subscribe trough the
AsyncPipeto the state property observable.You can assign (without the
.subscribe) the method call (SomeService#getSomeDataAsync) to a class propertymyProperty$. Note that, if you do not cache, each time Change Detection is triggered, an http call will be triggered. Just callpipeovermyProperty$and ensure you are caching the observable withshareorshareReplayoperators.In general, the
OnPush+AsyncPipeis a valid friend. Give it a try!