Search code examples
angularunit-testingjestjs

Fixture doesn't get updated after ngAfterViewInit


I'm testing my Angular app with Jest. Here is simplified template of my component:

<div *ngIf="viewInitialized">
  <button
    class="button"
    (click)="onLogoutClicked()"
  >
    {{ changeTokenText }}
  </button>
</div>

Here is simplified code of the component:

@Component({
  selector: 'logout-qr-page',
  templateUrl: './logout.component.html',
  styleUrls: ['./logout.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LogoutComponent implements OnInit, AfterViewInit {
  viewInitialized = false;

  constructor(
    ...
  ) {}

  ngOnInit(): void {
    ...
  }

  ngAfterViewInit(): void {
    ...
    this.viewInitialized = true;
  }

  onLogoutClicked(): void {
    ...
  }
}

And my tests are:

describe('TsdLogoutComponent', () => {
  let component: LogoutComponent;
  let fixture: ComponentFixture<LogoutComponent>;
  let debugElement: DebugElement;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [LogoutComponent],
      imports: [
       ...
      ],
      providers: [
       ...
      ],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(LogoutComponent);
    component = fixture.componentInstance;
    debugElement = fixture.debugElement;
    fixture.detectChanges();
  });

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

  it('should correctly render default state', () => {
    expect(component.viewInitialized).toBe(true); // PASS
    expect(debugElement.query(By.css('.button'))).not.toBeNull(); // FAIL
  });
});

So as you can see viewInitialized is true, but there is no element with class 'button'. AfterViewInit was called, it changed the value but did not update the fixture.

I tried:

  • calling fixture.detectChanges() multiple times, calling it only inside test (not in before each), calling it in before each and in test;
  • using await fixture.whenStable(), using it with fixture.detectChanges();
  • using fixture.autoDetectChanges().

What worked: The test starts passing if I do component.viewInitialized = true manualy (in test), if I use signal in the component (viewInitialized = signal(false) => viewInitialized.set(true)) or if i call cdRef.detectChanges() in ngAfterViewInit. But I don't really want to use any of these - in my project everything works perfectly without signals or cdRef, I want it to work the same in tests or at least I want to understand the reason of this behavior.


Solution

  • I ran into the same issue and the issue most likely is because of:

    changeDetection: ChangeDetectionStrategy.OnPush,
    

    Apparently, with OnPush change detection, in unit tests you can only call fixture.detectChanges() once. After the first call, no matter how many times you call fixture.detectChanges(), the view won't get updated.

    This article explains it: https://betterprogramming.pub/how-to-write-tests-for-components-with-onpush-change-detection-in-angular-24f2637a0f40.

    You may need an account to read the article but the gist of it is to create a new function:

    /**
     * Changes in components using OnPush strategy are only applied once when calling .detectChanges(),
     * This function solves this issue.
     */
    export async function runOnPushChangeDetection(fixture: ComponentFixture<any>): Promise<void> {
      const changeDetectorRef = fixture.debugElement.injector.get<ChangeDetectorRef>(ChangeDetectorRef);
      changeDetectorRef.detectChanges();
      return fixture.whenStable();
    }
    

    In your test:

    it('should correctly render default state', async () => {
        expect(component.viewInitialized).toBe(true); // PASS
        await runOnPushChangeDetection(fixture);
        expect(debugElement.query(By.css('.button'))).not.toBeNull(); // FAIL
      });
    

    Can also use it in fakeAsync/tick way:

    it('should correctly render default state', fakeAsync(() => {
        expect(component.viewInitialized).toBe(true); // PASS
        runOnPushChangeDetection(fixture);
        tick();
        expect(debugElement.query(By.css('.button'))).not.toBeNull(); // FAIL
      }));