Search code examples
angularangular2-directivesangular2-servicesangular2-testing

How do you test Angular 2 directive that calls a service?


I used angular-cli to create a simple app to illustrate my problem. You can see all the code here: https://github.com/wholladay/tracking

The directive calls a service whenever the containing element is clicked on. Therefore, I'd like to mock the service and ensure that it is called when a click event is sent to the directive.

Here is my test code:

/* tslint:disable:no-unused-variable */
import { inject, addProviders } from '@angular/core/testing';
import { TestComponentBuilder } from '@angular/compiler/testing';
import { Component } from '@angular/core';
import { By } from '@angular/platform-browser';
import { TrackingDirective } from './tracking.directive';
import { TrackingService } from './tracking.service';

class MockTrackingService extends TrackingService {
  public eventCount = 0;

  public trackEvent(eventName: string) {
    this.eventCount++;
  }
}

describe('TrackingDirective', () => {
  let builder: TestComponentBuilder;
  let mockTrackingService: MockTrackingService;
  let trackingDirective: TrackingDirective;

  beforeEach(() => {
    mockTrackingService = new MockTrackingService();
    trackingDirective = new TrackingDirective(mockTrackingService);
    addProviders([
      {provide: TrackingDirective, use: trackingDirective}
    ]);
  });

  beforeEach(inject([TestComponentBuilder], (tcb: TestComponentBuilder) => {
    builder = tcb;
  }));

  // General button tests
  it('should apply class based on color attribute', (done: () => void) => {
    return builder.createAsync(TestApp).then(fixture => {
      let testComponent = fixture.debugElement.componentInstance;
      let buttonDebugElement = fixture.debugElement.query(By.css('button'));

      buttonDebugElement.nativeElement.click();
      expect(buttonDebugElement).toBeTruthy();
      expect(mockTrackingService.eventCount).toBe(1);

      done();
    });
  });
});

@Component({
  selector: 'test',
  template: `<button tracking="some button"></button>`,
  directives: [TrackingDirective]
})
class TestApp {
}

And here is my directive code:

import { Directive, HostListener, Input } from '@angular/core';
import { TrackingService } from './tracking.service';

@Directive({
  selector: '[tracking]',
  providers: [
    TrackingService
  ]
})
export class TrackingDirective {

  @Input() tracking: string;

  constructor(private trackingService: TrackingService) {
  }

  @HostListener('click', ['$event.target'])
  onClick(element) {
    this.trackingService.trackEvent(this.tracking);
  }
}

When I run the tests via ng test, the test fails because eventCount is still 0 instead of 1.


Solution

  • great question!

    You are trying to test a directive that has it's own provider:

    @Directive({
      selector: '[tracking]',
      providers: [
        TrackingService
      ]
    })
    

    In this case we need to make sure, that our MockService is injected in the directive and in our test code. Injecting in the test code is required because you want to check the eventCount property. I would suggest creating an Instance of the MockService and use this instance as a value for the TrackungService:

    let mockService = new MockTrackingService();
    
    beforeEach(() => {  
      addProviders([provide(TrackingService, {useValue: mockService})]);
    });
    

    The same instance of the MockService needs to be used as provider for our directive:

    builder
          .overrideProviders(TrackingDirective, [provide(TrackingService, {useValue: mockService})])
          .createAsync(TestApp).then(fixture => {
    
    ...
    
          done();
        });
    

    See the method overrideProviders of the builder.

    So the complete code for your test looks like this:

    /* tslint:disable:no-unused-variable */
    import { inject, addProviders } from '@angular/core/testing';
    import { TestComponentBuilder } from '@angular/compiler/testing';
    import { Component, provide } from '@angular/core';
    import { By } from '@angular/platform-browser';
    import { TrackingDirective } from './tracking.directive';
    import { TrackingService } from './tracking.service';
    
    // do not extend the TrackingService. If there are other 
    // dependencies this would be difficult or impossible.
    class MockTrackingService {
      public eventCount = 0;
    
      public trackEvent(eventName: string) {
        this.eventCount++;
      }
    }
    
    let mockService = new MockTrackingService();
    
    beforeEach(() => {  
      addProviders([provide(TrackingService, {useValue: mockService})]);
    });
    
    
    describe('TrackingDirective', () => {
      let builder: TestComponentBuilder;
      let mockTrackingService: MockTrackingService;
    
      beforeEach(inject([TestComponentBuilder, TrackingService], 
        (tcb: TestComponentBuilder, _trackingService: TrackingService) => {
    
        builder = tcb;
        // we need to cast to MockTrackingService because 
        // TrackingService has no eventCount property and we need it
        mockTrackingService = <MockTrackingService> _trackingService;
    
      }));
    
      // General button tests
      it('should apply class based on color attribute', (done: () => void) => {
    
        builder
          .overrideProviders(TrackingDirective, [provide(TrackingService, {useValue: mockService})])
          .createAsync(TestApp).then(fixture => {
    
          let testComponent = fixture.debugElement.componentInstance;
          let buttonDebugElement = fixture.debugElement.query(By.css('button'));
    
          buttonDebugElement.nativeElement.click();
    
          expect(mockTrackingService.eventCount).toBe(1);
    
          done();
        });
      });
    });
    
    @Component({
      selector: 'test',
      template: `<button tracking="some button"></button>`,
      directives: [TrackingDirective]
    })
    class TestApp {
    }