ngneat / spectator

🦊 🚀 A Powerful Tool to Simplify Your Angular Tests

Home Page:https://ngneat.github.io/spectator

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

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:
grafik

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! 🙌