13

I am having trouble testing a component with OnPush change detection strategy.

The test goes like this

it('should show edit button for featured only for owners', () => {
    let selector = '.edit-button';

    component.isOwner = false;
    fixture.detectChanges();

    expect(fixture.debugElement.query(By.css(selector))).toBeFalsy();

    component.isOwner = true;
    fixture.detectChanges();

    expect(fixture.debugElement.query(By.css(selector))).toBeTruthy();
});

If I use Default strategy it works as expected, but with OnPush the change to isOwner is not rerendered by the call to detectChanges. Am I missing something?

altschuler
  • 3,694
  • 2
  • 28
  • 55
  • 1
    Take a look at this thread https://github.com/angular/angular/issues/12313 and here is your test https://plnkr.co/edit/llJG17sBZZUXXBertYPp?p=preview – yurzui Nov 30 '16 at 20:02

7 Answers7

12

This problem can be easily solved... https://github.com/angular/angular/issues/12313#issuecomment-298697327

TestBed.configureTestingModule({
  declarations: [ MyComponent ] 
})
.overrideComponent(MyComponent, {
  set: {  changeDetection: ChangeDetectionStrategy.Default  }
})
.compileComponents();

keep in mind this approach may cloak some change detection issues

credits: marchitos

Emmanuel Daher
  • 121
  • 1
  • 3
  • Note that `overrideComponent` is broken (at least with Ivy?) if you use a bundler for your tests -- it forces the component to be recompiled by the JIT compiler, which it can't do because it doesn't know where the template/style files are. – Coderer Oct 16 '20 at 09:39
5

You need to tell angular that you changed input property of the component. In an ideal world, you would replace

component.isOwner = false;
fixture.detectChanges();

with

component.isOwner = false;
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();

Unfortunately, that doesn't work since there is a bug in angular (https://github.com/angular/angular/issues/12313). You can use one of the workarounds described there.

Marcel Šebek
  • 291
  • 1
  • 4
  • 8
4

If you check out this great @Günter's answer angular 2 change detection and ChangeDetectionStrategy.OnPush then you can work around it by using event handler like:

const fixture = TestBed.overrideComponent(TestComponent, {set: {host: { "(click)": "dummy" }}}).createComponent(TestComponent);
// write your test
fixture.debugElement.triggerEventHandler('click', null);
fixture.detectChanges();

Here's Plunker Example

Community
  • 1
  • 1
yurzui
  • 205,937
  • 32
  • 433
  • 399
  • I went with the solution from your comment. While this might be "cleaner" it is more cumbersome, so I'd rather wrap the hack in a helper and hope for a better solution in the future :) Thanks! – altschuler Dec 01 '16 at 00:09
4

It doesn't work because the changeDetectorRef in your fixture isn't the same as in your component. Taken from the issue in Angular:

"...changeDetectorRef on a ComponentRef points to the change detector of the root (host) view of a dynamically created component. Then, inside the host view we've got the actual component view, but the component view is OnPush thus we never refresh it!" - source

Option A. One way to solve this is to use the components injector to get the real changeDetectionRef:

describe('MyComponent', () => {
  let fixture;
  let component;

  beforeEach(() => {
    TestBed.configureTestingModule({ ... }).compileComponents();
    fixture = TestBed.createComponent(MyComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('does something', () => {
    // set the property here
    component.property = 'something';

    // do a change detection on the real changeDetectionRef
    fixture.componentRef.injector.get(ChangeDetectorRef).detectChanges();

    expect(...).toBe(...);
  });
});

You could also just use the initial binding to an @Input (which initially triggers changedetection for an OnPush strategy):

Option B1:

describe('MyComponent', () => {
  let fixture;
  let component;

  beforeEach(() => {
    TestBed.configureTestingModule({ ... }).compileComponents();
    fixture = TestBed.createComponent(MyComponent);
    component = fixture.componentInstance;
  });

  it('does something', () => {
    // set the property here
    component.property = 'something';

    // do the first (and only) change detection here
    fixture.detectChanges();

    expect(...).toBe(...);
  });
});

or for example:

Option B2:

describe('MyComponent', () => {
  let fixture;
  let component;

  it('does something', () => {
    // set the property here
    setup({ property: 'something' });
    expect(...).toBe(...);
  });

  function setup(props: { property? } = {}) {
    TestBed.configureTestingModule({ ... }).compileComponents();
    fixture = TestBed.createComponent(MyComponent);
    component = fixture.componentInstance;

    Object.getOwnPropertyNames(props).forEach((propertyName) => {
      component[propertyName] = props[propertyName];
    });

    // do the first (and only) change detection here
    fixture.detectChanges();
  }
});
Remi
  • 4,663
  • 11
  • 49
  • 84
3

Edge cases for push a new state

Modifying input properties in TypeScript code. When you use an API like @ViewChild or @ContentChild to get a reference to a component in TypeScript and manually modify an @Input property, Angular will not automatically run change detection for OnPush components. If you need Angular to run change detection, you can inject ChangeDetectorRef in your component and call changeDetectorRef.markForCheck() to tell Angular to schedule a change detection.

so according to https://github.com/angular/angular/pull/46641 the best practice is to use setInput method: fixture.componentRef.setInput(), so to improve our code we can take advantage of Typescript and make a global function to deal with it.

function  setInput<T>(fixture: ComponentFixture<T>, prop: keyof T, value: T[keyof T]): void {
   fixture.componentRef.setInput(prop.toString(), value);
   fixture.detectChanges();
}

then use it inside our code like this:

  it('should show thumbnail when thumbnail input is filled', function () {
    setInput(fixture, 'thumbnailUrl', 'test/thumbnail.png');

    expect(fixure.debugElement.query(By.css('test'))).toBeTruthy();
  });
2

Similar to the work arounds that @michaelbromley did to expose the ChangeDetectionRef but since this is only for tests I just turned off TypeScript errors for the next line using // @ts-ignore flag from v2.6 so I could leave the ref private.

An example of how this might work:

import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';

import { WidgetComponent } from './widget.component';

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<my-widget *ngIf="widgetEnabled"></my-widget>`,
});
export class PushyComponent {
  @Input() widgetEnabled = true;

  constructor(private cdr: ChangeDetectorRef) {}

  // methods that actually use this.cdr here...
}

TestBed.configureTestingModule({
  declarations: [ PushyComponent, WidgetComponent ],
}).compileComponents();

const fixture = TestBed.createComponent(PushyComponent);
const component = fixture.componentInstance;
fixture.detectChanges();

expect(component.widgetEnabled).toBe(true);
let el = fixture.debugElement.query(By.directive(WidgetComponent));
expect(el).toBeTruthy();

component.widgetEnabled = false;
// @ts-ignore: for testing we need to access the private cdr to detect changes
component.cdr.detectChanges();
el = fixture.debugElement.query(By.directive(WidgetComponent));
expect(el).toBeFalsy();
Rob
  • 3,687
  • 2
  • 32
  • 40
1

There are a few solutions, but in your case, I think the easiest way is split your test into two separate tests. If in each of these tests you call fixture.detectChanges() function only once, everything should works fine.

Example:

it('should hide edit button if not owner', () => {
    let selector = '.edit-button';

    component.isOwner = false;
    fixture.detectChanges();

    expect(fixture.debugElement.query(By.css(selector))).toBeFalsy();
});

it('should show edit button for owner', () => {
    let selector = '.edit-button';

    component.isOwner = true;
    fixture.detectChanges();

    expect(fixture.debugElement.query(By.css(selector))).toBeTruthy();
});
Cichy
  • 4,602
  • 3
  • 23
  • 28