Spectator#setInput does not to update UI Elements for Components with changeDetectionStrategy OnPush
Blafasel3 opened this issue · comments
Is this a regression?
Yes
Description
I upgraded our Angular 16 App to Angular 17 (including Angular Material) and everything worked smoothly. In unit testing, we rely heavily on Spectator. After the update from spectator 16.0.0 to 17.0.0 (as a granular update) tests started to fail at random.
What I found out so far ist that spectator.setInput(..)
actually updates the values on the component but does not seem to update the UI correctly.
When creating the component, the state is set correctly, but e.g. button texts which should change via the logic triggered by setInput
never get updated after initilization.
E.g. we are doing this in our test:
spectator.setInput({KeyValuePairsThatEnableTheButtonAndChangeTheButtonText})
const element: HTMLButtonElement = spectator.query('button');
element.click();
The button in the example is a mat-menu-item
.
In Spectator v16 this worked fine and the button is enabled and has the correct text as expected after calling setInput.
In Spectator v17 the component attributes derived from setInput
are still correct, but the button in the above example is still disabled and has the incorrect text (which is derived from the initial state set in createComponent
.
Also, in v17,Component#ngOnChanges
is not called anymore but the attributes are set "hard" in https://github.com/ngneat/spectator/pull/638/files#diff-4b8aa7924b03115ef01b9f128e7c8886004c8e5bdd2768445cdf3a9be00c81fa
and https://github.com/ngneat/spectator/pull/638/files#diff-582dd3e11b71bddafaec5400161349f59f51a76da92412359207423fa782c216R29-R42
If I'd have to guess, the changes to the setProps
seem to miss out on something that happend in the previous code rather implicitly.
Please provide a link to a minimal reproduction of the bug
Couldnt get this to work in Stackblitz, it errored out on install so I did it locally:
app.component.html:
<button mat-menu-item [disabled]="btnDisabled" click="onClick()">{{btnText}}</button>
app.component.ts:
import { Component, EventEmitter, Input, SimpleChanges } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { MatMenuModule } from '@angular/material/menu';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, MatMenuModule],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent {
@Input()
disableButton: boolean = false;
btnDisabled: boolean = true;
btnText = 'Button disabled';
emitter = new EventEmitter<void>;
ngOnChanges(changes: SimpleChanges) {
console.log(this.disableButton);
this.btnDisabled = this.disableButton;
this.btnText = this.disableButton ? 'Button disabled' : 'Button enabled';
}
onClick() {
this.emitter.emit();
}
}
app.component.spec.ts:
import { AppComponent } from './app.component';
import { createComponentFactory, Spectator } from '@ngneat/spectator';
import {expect, jest,} from '@jest/globals';
describe('AppComponent', () => {
let spectator: Spectator<AppComponent>;
const createComponent = createComponentFactory(AppComponent);
it('should make button behave correctly', () => {
spectator = createComponent({props: {disableButton: true}});
const component = spectator.component;
expect(component.btnDisabled).toBeTruthy();
expect(component.btnText).toEqual('Button disabled');
let button: HTMLButtonElement | null = getButton();
expect(button?.disabled).toBeTruthy();
expect(button?.textContent).toEqual('Button disabled');
spectator.setInput({ disableButton: false })
expect(component.btnDisabled).toBeFalsy();
expect(component.btnText).toEqual('Button enabled');
button = getButton();
expect(button?.disabled).toBeFalsy();
expect(button?.textContent).toEqual('Button enabled');
const emitterSpy = jest.spyOn(component.emitter, 'emit');
button?.click();
expect(emitterSpy).toHaveBeenCalled();
});
function getButton(): HTMLButtonElement | null {
return spectator.query('button');
}
});
Please provide the exception or error you saw
Oddly enough, this fails one step later in the chain but somehow it's failing on the assert on the spy. Not sure its related but thats the closest I could get so far.
● AppComponent › should make button behave correctly
expect(jest.fn()).toHaveBeenCalled()
Expected number of calls: >= 1
Received number of calls: 0
Please provide the environment you discovered this bug in
Angular 17 & Material 17 & Spectator 17, Jest as a test runner
package.json:
{
"name": "spectator-issue",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "jest"
},
"private": true,
"dependencies": {
"@angular/animations": "^17.2.0",
"@angular/cdk": "^17.2.2",
"@angular/common": "^17.2.0",
"@angular/compiler": "^17.2.0",
"@angular/core": "^17.2.0",
"@angular/forms": "^17.2.0",
"@angular/material": "^17.2.2",
"@angular/platform-browser": "^17.2.0",
"@angular/platform-browser-dynamic": "^17.2.0",
"@angular/router": "^17.2.0",
"rxjs": "~7.8.0",
"ts-node": "^10.9.2",
"tslib": "^2.3.0",
"zone.js": "~0.14.3"
},
"devDependencies": {
"@angular-devkit/build-angular": "^17.2.3",
"@angular/cli": "^17.2.3",
"@angular/compiler-cli": "^17.2.0",
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.24.0",
"@ngneat/spectator": "^17.1.0",
"@types/jasmine": "~5.1.0",
"@types/jest": "^29.5.12",
"babel-jest": "^29.7.0",
"jasmine-core": "~5.1.0",
"jest": "^29.7.0",
"jest-preset-angular": "^14.0.3",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.3.2"
}
}
Do you want to create a pull request?
No
@kfrancois can you check please?
Interesting - I just set up a case where this does seem to function properly and ngOnChanges
does fire when calling .setInput
. If you can provide a reproduction example (some repo or StackBlitz) I'd be happy to take a look and fix!
As of Spectator 17, ngOnChanges
does not get called by Spectator anymore. Previously, Spectator would explicitly call a component's ngOnChanges
with a "mocked" SimpleChanges
object.
Now, this ngOnChanges
call happens by TestBed/Angular itself.
Example repo: https://github.com/Blafasel3/spectator-issue
The behavior has to do with change detection strategy of the component. If its the default, the test succeeds. If it's onPush
, the test fails on the expected HTMLButtonElement#disabled
in Line 23:
I was happy to actually find the time to reproduce this, but I could not dive much further. Hope this helps.
Same test succeeds with spectator v16 with both change detection strategies.
Edit: I confirmed this on our application code. Every test that fails succeeds if I change the changeDetectionStrategy from onPush
to Default
.
Update: Manually calling spectator.detectComponentChanges(); fixes the issue.
spectator.setInput({ disableButton: false })
spectator.detectComponentChanges();
Tried it in our code, looks good. Issue closed. Thanks for the quick fix & release!
Thank you for verifying @Blafasel3! 🙌