Angular testing with OnPush Component and fixture.detectChanges() - how to make the unit test reliable?

69 Views Asked by At

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

1

There are 1 best solutions below

2
Francesco Moro On

Usually when dealing with OnPush you should use a more reactive approach.

The key is to use the AsyncPipe.

Simply, there are two possible way

  1. You are using state management
    When you subscribe to the HTTP observable call, simply update the state. In your template, you will subscribe trough the AsyncPipe to the state property observable.
  2. You are not using state management
    You can assign (without the .subscribe) the method call (SomeService#getSomeDataAsync) to a class property myProperty$. Note that, if you do not cache, each time Change Detection is triggered, an http call will be triggered. Just call pipe over myProperty$ and ensure you are caching the observable with share or shareReplay operators.

In general, the OnPush + AsyncPipe is a valid friend. Give it a try!