18

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?

1

7 Answers 7

18

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

Sign up to request clarification or add additional context in comments.

1 Comment

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.
9

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();
  }
});

Comments

6

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();
  });

Comments

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.

Comments

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

1 Comment

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!
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();

Comments

2

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();
});

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.