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:
fixture.detectChanges()
multiple times, calling it only inside test (not in before each), calling it in before each and in test;await fixture.whenStable()
, using it with fixture.detectChanges()
;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.
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
}));