ngneat / spectator

🦊 🚀 A Powerful Tool to Simplify Your Angular Tests
https://ngneat.github.io/spectator
MIT License
2.09k stars 177 forks source link

Specify usage when testing ngOnChanges/OnPush life-cycle hook #111

Closed IlCallo closed 5 years ago

IlCallo commented 5 years ago

As said in https://github.com/NetanelBasal/spectator/issues/38, it would be a good idea to specify that to test code which uses ngOnChanges (and also components with changeDetection OnPush I think, given https://github.com/angular/angular/issues/12313) you are forced to manually call ngOnChanges (won't work for OnPush scenario I guess) or use a setup with a custom host component binding a property to the given template.

I can also provide an example that show how to do it and why, given that I had to produce it to wrap my head around how that mess works (talking about Angular, not Spectator).

I did this with Jest, but I guess it works with Jasmine as well.

If not interested, this issue will serve as reference I guess. Here the link to the Gist: https://gist.github.com/IlCallo/a003f4f0a12b4e0276d461d5ff8c2562

import { Component, Directive, Host, Input, OnChanges, SimpleChanges } from '@angular/core';
import { createHostComponentFactory, SpectatorWithHost } from '@netbasal/spectator/jest';

@Component({
  selector: 'simple-component',
  template: '{{ name }}'
})
class SimpleComponent implements OnChanges {
  @Input() name!: string;

  ngOnChanges(changes: SimpleChanges) {
    console.log('ngOnChanges from Component, name: ', changes['name']);
  }
}

@Directive({
  selector: '[simpleDirective]'
})
class SimpleDirective implements OnChanges {
  @Input() nameButStronger!: string;

  constructor(@Host() private host: SimpleComponent) {}

  ngOnChanges(changes: SimpleChanges) {
    console.log('ngOnChanges from Directive, name: ', changes['nameButStronger']);
    this.host.name = changes['nameButStronger'].currentValue + ', stronger!';
  }
}

@Component({
  selector: 'host',
  template: ''
})
class HostComponent {
  nameMirror!: string;
  nameButStrongerMirror!: string;
}

// During a Change Detection cycle (automatic or triggered with detectChanges()).
// ngOnChanges hook it's called only when at least a change is registered on @Input properties.
// Programmatic manipulation of properties on the component effectively prevents the @Input mechanism to notice the change.
// These methods are or perform programmatic manipulations and as such won't cause ngOnChanges hook to be called.
// * directive.nameButStronger = 'Luca';
// * host.setInput('nameButStronger', 'Paolo');
// * createHost(`<simple-component simpleDirective></simple-component>`, undefined, { nameButStronger: 'Simone' } );
//
// The only working way, aside manually calling ngOnChanges hook, is to create a custom host component with properties bound
//  via template to the tested component/directive @Input properties, update host component mirror properties
//  and then force a Change Detection cycle (using detectChanges()).
describe('SimpleComponent and SimpleDirective', () => {
  let host: SpectatorWithHost<SimpleDirective, HostComponent>;

  const createHost = createHostComponentFactory({
    component: SimpleDirective,
    declarations: [SimpleComponent],
    host: HostComponent
  });

  it('should correctly call ngOnChanges hook and detectChanges on SimpleComponent', () => {
    host = createHost(`<simple-component [name]="nameMirror"></simple-component>`);
    const component = host.queryHost<SimpleComponent>(SimpleComponent);
    const componentElement = host.queryHost('simple-component');
    host.hostComponent.nameMirror = 'Paolo';
    host.detectChanges();
    console.log('Current host content: ', host.hostElement.innerHTML);
    console.log('Component property: ', component.name);
    expect(componentElement).toHaveText('Paolo');
    host.hostComponent.nameMirror = 'Luca';
    host.detectChanges();
    console.log('Current host content: ', host.hostElement.innerHTML);
    console.log('Component property: ', component.name);
    expect(componentElement).toHaveText('Luca');
    host.hostComponent.nameMirror = 'Simone';
    host.detectChanges();
    console.log('Current host content: ', host.hostElement.innerHTML);
    console.log('Component property: ', component.name);
    expect(componentElement).toHaveText('Simone');
  });

  it('should correctly call ngOnChanges hook and detectChanges on SimpleDirective', () => {
    host = createHost(
      `<simple-component [nameButStronger]="nameButStrongerMirror" simpleDirective></simple-component>`
    );
    const component = host.queryHost<SimpleComponent>(SimpleComponent);
    const componentElement = host.queryHost('simple-component');
    const directive = host.getDirectiveInstance<SimpleDirective>(SimpleDirective);
    host.hostComponent.nameButStrongerMirror = 'Paolo';
    host.detectChanges();
    console.log('Current host content: ', host.hostElement.innerHTML);
    console.log('Directive property: ', directive.nameButStronger);
    console.log('Component property: ', component.name);
    expect(componentElement).toHaveText('Paolo, stronger!');
    host.hostComponent.nameButStrongerMirror = 'Luca';
    host.detectChanges();
    console.log('Current host content: ', host.hostElement.innerHTML);
    console.log('Directive property: ', directive.nameButStronger);
    console.log('Component property: ', component.name);
    expect(componentElement).toHaveText('Luca, stronger!');
    host.hostComponent.nameButStrongerMirror = 'Simone';
    host.detectChanges();
    console.log('Current host content: ', host.hostElement.innerHTML);
    console.log('Directive property: ', directive.nameButStronger);
    console.log('Component property: ', component.name);
    expect(componentElement).toHaveText('Simone, stronger!');
  });
});
dirkluijk commented 5 years ago

The recommended way for testing inputs/outputs (thus, life-cycle hooks) is using a Host.

We have documented it briefly in the updated README, but we will add more docs with examples and cookbook articles when we have moved to a Gitbook wiki.

For now, I will close this issue. Feel free to open a new ticket if you have ideas or questions. ❤️

oauthentik commented 4 years ago

Hi, i have this code snippet which i'm testing an angular library which use the onPush change detection

@Component({ selector: "mat-host-component", template: `<mat-advanced-table [data]="data" [columns]="columns" [loading]="loading" [options]="options"

`, styles: [``], }) export class HostBaseComponent implements OnInit { constructor(protected matAdvancedService: MatAdvancedTableService) {} columns; hiddenColumns; data; loading = false; options: NgxMatTableOptions; transparentBg: boolean; rowNgClassFun;

ngOnInit(): void { this.columns = this.matAdvancedService.getColumnsOfType(MockClass); this.data = mockData; } }

*in my test file i used host component*
```typescript
  describe("Basic Implementation", () => {
    let spectator: SpectatorHost<MatAdvancedTableComponent>;
    let component: MatAdvancedTableComponent;
    let service: MatAdvancedTableService;
    const createHost = createHostFactory({
      component: MatAdvancedTableComponent,
      imports: [MatAdvancedTableModule],
      declareComponent: false,
    });
    beforeEach(() => {
      spectator = createHost( 
        `<mat-advanced-table [data]="data" [columns]="columns" [loading]="loading"></mat-advanced-table>`,
        {
          hostProps: { data: [], columns: [], loading: false },
        }
      );
      component = spectator.component;
      service = spectator.get(MatAdvancedTableService);
    });

    const setupColumns = (typeClass) => {
      spectator.setHostInput({ columns: service.getColumnsOfType(typeClass) });
      spectator.detectChanges();
      spectator.detectChanges();
    };
    const toggleLoadingData = (loading) => {
      spectator.setHostInput({ data: loading ? [] : mockData });
      spectator.setHostInput({ loading: loading });
      spectator.detectChanges();
    };
    const setupData = (data = mockData) => {
      spectator.setHostInput({ data });
      spectator.detectChanges();
    };

    // Test cases
    describe("creating the component with defaults", () => {
      beforeEach(() => {
        setupColumns(MockClass);
        setupData([]);
      });
      it("Should create the component", () => {
        expect(spectator.component).toBeTruthy();
      });

      it("Should contain a table", () => { // this works
        expect(spectator.queryHost(`table`)).toBeTruthy();
      });
      it("Should contain a set of table header columns", () => { // this fails
        expect(spectator.queryHostAll(`table thead th`)).toHaveLength(
          component.columns.length
        );
      });
 });

I'm running on Linux with kernel Linux 5.3.x using Node 10

 "@angular/cli": "~7.0.5",
    "@angular/compiler-cli": "~7.0.0",
    "@angular/language-service": "~7.0.0",
    "@ngneat/spectator": "^4.11.1",
    "@qiwi/semantic-release-gh-pages-plugin": "^1.15.10",
    "@types/jasmine": "~2.8.8",
    "@types/jasminewd2": "~2.0.3",
    "@types/node": "~8.9.4",
johncrim commented 3 years ago

I worked on a similar issue today, so I'm leaving a comment to help anyone else who ends up here:

My specific use case is that I'm testing a structural directive, similar to ngIf, which uses ngOnChanges to detect changes.

SpectatorDirective.setHostInput() calls ngOnChanges() on the host component. DomSpectator.setInput() calls ngOnChanges() on the directive or component under test.

Both calls directly set properties and call ngOnChanges(SimpleChanges); and like many Spectator APIs, both also call detectChanges() afterwards.

As indicated above, it's best to test directives and OnPush components within a host component, and the host (either a regular spectator host, or a custom host component) should not be OnPush, because OnPush will prevent change detection of the child component. This requirement for using components to test directives and OnPush components is not specific to Spectator.