Angular 2: How to mock ChangeDetectorRef while unit testing
Asked Answered
R

7

38

I have just started with Unit-Testing, and I have been able to mock my own services and some of Angular and Ionic as well, but no matter what I do ChangeDetectorRef stays the same.

I mean which kind of sorcery is this?

beforeEach(async(() => 
    TestBed.configureTestingModule({
      declarations: [MyComponent],
      providers: [
        Form, DomController, ToastController, AlertController,
        PopoverController,

        {provide: Platform, useClass: PlatformMock},
        {
          provide: NavParams,
          useValue: new NavParams({data: new PageData().Data})
        },
        {provide: ChangeDetectorRef, useClass: ChangeDetectorRefMock}

      ],
      imports: [
        FormsModule,
        ReactiveFormsModule,
        IonicModule
      ],
    })
    .overrideComponent(MyComponent, {
      set: {
        providers: [
          {provide: ChangeDetectorRef, useClass: ChangeDetectorRefMock},
        ],
        viewProviders: [
          {provide: ChangeDetectorRef, useClass: ChangeDetectorRefMock},
        ]
      }
    })
    .compileComponents()
    .then(() => {
      let fixture = TestBed.createComponent(MyComponent);
      let cmp = fixture.debugElement.componentInstance;

      let cdRef = fixture.debugElement.injector.get(ChangeDetectorRef);

      console.log(cdRef); // logs ChangeDetectorRefMock
      console.log(cmp.cdRef); // logs ChangeDetectorRef , why ??
    })
  ));

 it('fails no matter what', async(() => {
    spyOn(cdRef, 'markForCheck');
    spyOn(cmp.cdRef, 'markForCheck');

    cmp.ngOnInit();

    expect(cdRef.markForCheck).toHaveBeenCalled();  // fail, why ??
    expect(cmp.cdRef.markForCheck).toHaveBeenCalled(); // success

    console.log(cdRef); // logs ChangeDetectorRefMock
    console.log(cmp.cdRef); // logs ChangeDetectorRef , why ??
  }));

@Component({
  ...
})
export class MyComponent {
 constructor(private cdRef: ChangeDetectorRef){}

 ngOnInit() {
   // do something
   this.cdRef.markForCheck();
 }
}

I have tried everything , async, fakeAsync, injector([ChangeDetectorRef], () => {}).

Nothing works.

Rebato answered 2/1, 2017 at 5:34 Comment(2)
The ChangeDetectorRef is given special treatment by the Angular 2 compiler. I think you cannot provide it. You can check test for AsyncPipe github.com/angular/angular/blob/… There is used SpyChangeDetectorRefStenger
I'm hitting the same issue - how are people working around this?Mosul
S
54

Update 2020:

I wrote this originally in May 2017, it's a solution that worked great at the time and still works.

We can't configure the injection of a changeDetectorRef mock through the test bed, so this is what I am doing these days:

 it('detects changes', () => {
      // This is a unique instance here, brand new
      const changeDetectorRef = fixture.debugElement.injector.get(ChangeDetectorRef); 
     
      // So, I am spying directly on the prototype.
      const detectChangesSpy = spyOn(changeDetectorRef.constructor.prototype, 'detectChanges');

      component.someMethod(); // Which internally calls the detectChanges.

      expect(detectChangesSpy).toHaveBeenCalled();
    });

Then you don't care about private attributes or any.


In case anyone runs into this, this is one way that has worked well for me:

As you are injecting the ChangeDetectorRef instance in your constructor:

 constructor(private cdRef: ChangeDetectorRef) { }

You have that cdRef as one of the private attributes on the component, which means you can spy on the component, stub that attribute and have it return whatever you want. Also, you can assert its calls and parameters, as needed.

In your spec file, call your TestBed without providing the ChangeDetectorRef as it won't provide what you give it. Set the component that same beforeEach block, so it is reset between specs as it is done in the docs here:

component = fixture.componentInstance;

Then in the tests, spy directly on the attribute

describe('someMethod()', () => {
  it('calls detect changes', () => {
    const spy = spyOn((component as any).cdRef, 'detectChanges');
    component.someMethod();

    expect(spy).toHaveBeenCalled();
  });
});

With the spy you can use .and.returnValue() and have it return whatever you need.

Notice that (component as any) is used as cdRef is a private attribute. But private doesn't exist in the actual compiled javascript so it is accessible.

It is up to you if you want to access private attributes at runtime that way for your tests.

Sedlik answered 30/5, 2017 at 18:45 Comment(8)
so why are you creating private fields when you don't treat them like a private? lolYelp
Original question is declared as private. I just answered ;) There is the old discussion of "should I test private methods" with two takes on it, yes and no. Both with valid reasons. My advice is to go with the one that makes you happy.Sedlik
@Sedlik Why ChangeDetectorRef cannot be provided in a TestBed and why to access the private field instead?Impersonalize
@mbharanidharan88 it was May 2017, I had an issue with it at the time. I just added to my answer recommending test bed as seen in the other answers, to not care about internal / private details.Sedlik
To get rid of linting issues, const spy = spyOn(component['cdRef'], 'detectChanges');Impersonalize
@Juan, Is it possible to inject the ChangeDetectorRef and check whether it is called rather than spying on the component.ChangeDetectorRefImpersonalize
yeah! I didn't mean to use it that way. Instead you use const cdRefMock = jasmine.createSpyObj('ChangeDetectorRef', ['detectChanges']); and then provide that one in the Test bed with useValue. Then you are injecting the spy. Don't spy on the component! I'll clarify in the edit.Sedlik
I updated it. There's good answers out there showing the last piece using the mock, so that is as far and as detailed this update will get for now as this is an old question and answer and there's good info out there. I hope it points you in the right direction now @mbharanidharan88Sedlik
L
6

Not sure if this a new thing or not, but changeDetectorRef can be accessed via fixture.

See docs: https://angular.io/guide/testing#componentfixture-properties

We ran into the same issue with change detector mocking and this is ended up being the solution

Lipoma answered 21/6, 2018 at 12:39 Comment(2)
This is the way to go. It's much better than spying on the component's properties, especially private ones. Thanks !Schoenfelder
fixture.changeDetectorRef is not the same as the component provided, so you cannot use it.Socialization
S
2

Probably one point that needs to be pointed out, is that in essence here you want to test your own code, not unit test the change detector itself (which was tested by the Angular team). In my opinion this is a good indicator that you should extract the call to the change detector to a local private method (private as it is something you don't want to unit test), e.g.

private detectChanges(): void {
    this.cdRef.detectChanges();
}

Then, in your unit test, you will want to verify that your code actually called this function, and thus called the method from the ChangeDetectorRef. For example:

it('should call the change detector',
    () => {
        const spyCDR = spyOn((cmp as any).cdRef, 'detectChanges' as any);
        cmp.ngOnInit();
        expect(spyCDR).toHaveBeenCalled();
    }
);

I had the exact same situation, and this was suggested to me as a general best practice for unit testing from a senior dev who told me that unit testing is actually forcing you by this pattern to structure your code better. With the proposed restructuring, you make sure your code is flexible to change, e.g. if Angular changes the way they provide us with change detection, then you will only have to adapt the detectChanges method.

Sparge answered 13/3, 2018 at 13:40 Comment(1)
Is it possible to inject the ChangeDetectorRef and check toHaveBeenCalled rather than spying on the Component.ChangeDetectorRef ?Impersonalize
P
1

For unit testing, if you are mocking ChangeDetectorRef just to satisfy dependency injection for a component to be creation, you can pass in any value.

For my case, I did this:

TestBed.configureTestingModule({
  providers: [
    FormBuilder,
    MyComponent,
    { provide: ChangeDetectorRef, useValue: {} }
  ]
}).compileComponents()
injector = getTestBed()
myComponent = injector.get(MyComponent)

It will create myComponent successfully. Just make sure test execution path does not need ChangeDetectorRef. If you do, then replace useValue: {} with a proper mock object.

In my case, I just needed to test some form creation stuff using FormBuilder.

Polybasite answered 20/6, 2018 at 11:1 Comment(1)
ChangeDetectorRef is not provided through DI, so you cannot provide a double.Socialization
C
0
// component
constructor(private changeDetectorRef: ChangeDetectorRef) {}

public someHandler() {
  this.changeDetectorRef.detectChanges();
}     

// spec
const changeDetectorRef = fixture.componentRef.changeDetectorRef;
jest.spyOn(changeDetectorRef, 'detectChanges');
fixture.detectChanges(); // <--- needed!!

component.someHandler();

expect(changeDetectorRef.detectChanges).toHaveBeenCalled();
Caren answered 31/1, 2022 at 13:57 Comment(0)
B
0

I have seen a lot of good answers.

In 2023, with jest, I would go with :

it('detects changes', () => {
  const changeDetectorRef = fixture.changeDetectorRef; 
   
  // Spying your method.
  jest.spyOn(changeDetectorRef, 'detectChanges');

  component.someMethod(); // Which internally calls the detectChanges.

  expect(changeDetectorRef.detectChanges).toHaveBeenCalled();
});
Blowhole answered 27/4, 2023 at 14:55 Comment(0)
S
0

I developed my test as it follows: Inject ChangeDetectorRef in my contructor and call in ngAfterViewChecked

constructor(private cdRef: ChangeDetectorRef)
  ngAfterViewChecked() {
this.cdRef.detectChanges();
}

const cdRef = fixture.debugElement.injector.get(ChangeDetectorRef); //Mock changeDetectorRef
    const detectChangesSpy = jest.spyOn(cdRef.constructor.prototype,'detectChanges');//call detectChances with jest.spyon
fixture.detectChanges();
component.ngAfterViewInit(); //starts my ngAfterViewInit
expect(detectChangesSpy).toHaveBeenCalled();
Siblee answered 4/12, 2023 at 13:56 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.