Mocking Angular 9 Services with Jasmine

13,925

I decided it'd be easier to get rid of helper.ts and mock what's returned by the service. I also changed to import HttpClientTestingModule so the service can be instantiated, even if its HttpClient is never used. Here's my search.component.spec.ts after this refactoring:

import { async, ComponentFixture, TestBed } from '@angular/core/testing';

import { SearchComponent } from './search.component';
import { MockActivatedRoute } from '../shared/search/mocks/routes';
import { SearchService } from '../shared';
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs';
import { HttpClientTestingModule } from '@angular/common/http/testing';

describe('SearchComponent', () => {
  let component: SearchComponent;
  let fixture: ComponentFixture<SearchComponent>;
  let mockActivatedRoute: MockActivatedRoute;
  let mockSearchService: SearchService;

  beforeEach(async(() => {
    mockActivatedRoute = new MockActivatedRoute({term: 'nikola'});

    TestBed.configureTestingModule({
      declarations: [SearchComponent],
      providers: [
        {provide: ActivatedRoute, useValue: mockActivatedRoute}
      ],
      imports: [FormsModule, RouterTestingModule, HttpClientTestingModule]
    }).compileComponents();
  }));

  beforeEach(() => {
    // mock response
    mockSearchService = TestBed.inject(SearchService);
    mockSearchService.search = jasmine.createSpy().and.returnValue(of([]));

    // initialize component
    fixture = TestBed.createComponent(SearchComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('should search when a term is set and search() is called', () => {
    component = fixture.debugElement.componentInstance;
    component.query = 'J';
    component.search();
    expect(mockSearchService.search).toHaveBeenCalledWith('J');
  });

  it('should search automatically when a term is on the URL', () => {
    fixture.detectChanges();
    expect(mockSearchService.search).toHaveBeenCalledWith('nikola');
  });
});

For another test, I did something similar, returning expected data from the service.

import { EditComponent } from './edit.component';
import { TestBed } from '@angular/core/testing';
import { Address, Person, SearchService } from '../shared';
import { MockRouter, MockActivatedRoute } from '../shared/search/mocks/routes';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { of } from 'rxjs';
import { HttpClientTestingModule } from '@angular/common/http/testing';

describe('EditComponent', () => {
  let mockSearchService: SearchService;
  let mockActivatedRoute: MockActivatedRoute;
  let mockRouter: MockRouter;

  beforeEach(() => {
    mockActivatedRoute = new MockActivatedRoute({id: 1});
    mockRouter = new MockRouter();

    TestBed.configureTestingModule({
      declarations: [EditComponent],
      providers: [
        {provide: ActivatedRoute, useValue: mockActivatedRoute},
        {provide: Router, useValue: mockRouter}
      ],
      imports: [FormsModule, HttpClientTestingModule]
    }).compileComponents();

    mockSearchService = TestBed.inject(SearchService);
  });

  it('should fetch a single record', () => {
    const fixture = TestBed.createComponent(EditComponent);

    const person = new Person({id: 1, name: 'Gary Harris'});
    person.address = new Address({city: 'Denver'});

    // mock response
    spyOn(mockSearchService, 'get').and.returnValue(of(person));

    // initialize component
    fixture.detectChanges();

    // verify service was called
    expect(mockSearchService.get).toHaveBeenCalledWith(1);

    // verify data was set on component when initialized
    const editComponent = fixture.debugElement.componentInstance;
    expect(editComponent.editAddress.city).toBe('Denver');

    // verify HTML renders as expected
    const compiled = fixture.debugElement.nativeElement;
    expect(compiled.querySelector('h3').innerHTML).toBe('Gary Harris');
  });
});
Share:
13,925

Related videos on Youtube

Matt Raible
Author by

Matt Raible

Web developer and Java Champion that loves to architect and build slick-looking UIs using CSS and JavaScript. When he's not evangelizing Okta and open source, he likes to ski with his family, drive his VWs, and enjoy craft beer.

Updated on June 04, 2022

Comments

  • Matt Raible
    Matt Raible almost 2 years

    With Angular 7, I'm able to mock my SearchService with Jasmine by creating a few classes. The first is a helper.ts file that has a class you can extend.

    /// <reference path="../../../../../node_modules/@types/jasmine/index.d.ts"‌​/>
    
    export interface GuinessCompatibleSpy extends jasmine.Spy {
      /** By chaining the spy with and.returnValue, all calls to the function will return a specific
       * value. */
      andReturn(val: any): void;
      /** By chaining the spy with and.callFake, all calls to the spy will delegate to the supplied
       * function. */
      andCallFake(fn: Function): GuinessCompatibleSpy;
      /** removes all recorded calls */
      reset();
    }
    
    export class SpyObject {
      static stub(object = null, config = null, overrides = null) {
        if (!(object instanceof SpyObject)) {
          overrides = config;
          config = object;
          object = new SpyObject();
        }
    
        const m = {};
        Object.keys(config).forEach((key) => m[key] = config[key]);
        Object.keys(overrides).forEach((key) => m[key] = overrides[key]);
        for (const key in m) {
          object.spy(key).andReturn(m[key]);
        }
        return object;
      }
    
      constructor(type = null) {
        if (type) {
          for (const prop in type.prototype) {
            let m = null;
            try {
              m = type.prototype[prop];
            } catch (e) {
              // As we are creating spys for abstract classes,
              // these classes might have getters that throw when they are accessed.
              // As we are only auto creating spys for methods, this
              // should not matter.
            }
            if (typeof m === 'function') {
              this.spy(prop);
            }
          }
        }
      }
    
      spy(name) {
        if (!this[name]) {
          this[name] = this._createGuinnessCompatibleSpy(name);
        }
        return this[name];
      }
    
      prop(name, value) { this[name] = value; }
    
      /** @internal */
      _createGuinnessCompatibleSpy(name): GuinessCompatibleSpy {
        const newSpy: GuinessCompatibleSpy = <any>jasmine.createSpy(name);
        newSpy.andCallFake = <any>newSpy.and.callFake;
        newSpy.andReturn = <any>newSpy.and.returnValue;
        newSpy.reset = <any>newSpy.calls.reset;
        // revisit return null here (previously needed for rtts_assert).
        newSpy.and.returnValue(null);
        return newSpy;
      }
    }
    

    Here is the search.service.ts I'm trying to test:

    @Injectable({
      providedIn: 'root'
    })
    export class SearchService {
    
      constructor(private http: HttpClient) { }
    
      getAll() {
        return this.http.get('assets/data/people.json');
      }
    
      search(q: string): Observable<any> {
        // implementation
      }
    
      get(id: number) {
        // implementation
      }
    
      save(person: Person) {
        // implementation
      }
    }
    

    And here's my search.service.mock.ts:

    import { SpyObject } from './helper';
    import { SearchService } from '../search.service';
    import Spy = jasmine.Spy;
    
    export class MockSearchService extends SpyObject {
      getAllSpy: Spy;
      getByIdSpy: Spy;
      searchSpy: Spy;
      saveSpy: Spy;
      fakeResponse: any;
    
      constructor() {
        super(SearchService);
    
        this.fakeResponse = null;
        this.getAllSpy = this.spy('getAll').andReturn(this);
        this.getByIdSpy = this.spy('get').andReturn(this);
        this.searchSpy = this.spy('search').andReturn(this);
        this.saveSpy = this.spy('save').andReturn(this);
      }
    
      subscribe(callback: any) {
        callback(this.fakeResponse);
      }
    
      setResponse(json: any): void {
        this.fakeResponse = json;
      }
    }
    

    And then I mock it in a test.

    import { async, ComponentFixture, TestBed } from '@angular/core/testing';
    import { SearchComponent } from './search.component';
    import { MockSearchService } from '../shared/search/mocks/search.service';
    import { MockActivatedRoute, MockRouter } from '../shared/search/mocks/routes';
    import { SearchService } from '../shared';
    import { ActivatedRoute, Router } from '@angular/router';
    import { RouterTestingModule } from '@angular/router/testing';
    import { FormsModule } from '@angular/forms';
    
    describe('SearchComponent', () => {
      let component: SearchComponent;
      let fixture: ComponentFixture<SearchComponent>;
      let mockSearchService: MockSearchService;
      let mockActivatedRoute: MockActivatedRoute;
    
      beforeEach(async(() => {
        mockSearchService = new MockSearchService();
        mockActivatedRoute = new MockActivatedRoute({'term': 'peyton'});
    
        TestBed.configureTestingModule({
          declarations: [ SearchComponent ],
          providers: [
            {provide: SearchService, useValue: mockSearchService},
            {provide: ActivatedRoute, useValue: mockActivatedRoute}
          ],
          imports: [FormsModule, RouterTestingModule]
        }).compileComponents();
      }));
    
      beforeEach(() => {
        fixture = TestBed.createComponent(SearchComponent);
        component = fixture.componentInstance;
        fixture.detectChanges();
      });
    
      it('should create', () => {
        expect(component).toBeTruthy();
      });
    });
    

    This works with Angular 7. However, when I try it with Angular 9, I have to remove the // <reference path="..."/> at the top of helper.ts to fix some compiler errors.

    ERROR in node_modules/@types/jasmine/index.d.ts:25:1 - error TS6200: Definitions of the following identifiers conflict with those in another file: ImplementationCallback, Func, Constructor, ExpectedRecursive, Expected, SpyObjMethodNames, CustomEqualityTester, CustomMatcherFactory, ExpectationFailed, SpecFunction, SpyObj, jasmine
    
    25 type ImplementationCallback = jasmine.ImplementationCallback;
       ~~~~
    

    Then I get two errors:

    Chrome 78.0.3904 (Mac OS X 10.15.1) SearchComponent should create FAILED
        Failed: this.getSpy is not a function
            at <Jasmine>
    

    And:

    NullInjectorError: R3InjectorError(DynamicTestModule)[SearchService -> HttpClient -> HttpClient]:
      NullInjectorError: No provider for HttpClient!
    error properties: Object({ ngTempTokenPath: null, ngTokenPath: [ 'SearchService', 'HttpClient', 'HttpClient' ] })
    

    Any idea why this works in Angular 7 and not in Angular 9?

    The app that works with Angular 7 is on GitHub at https://github.com/mraible/ng-demo.

    • Xesenix
      Xesenix over 4 years
      why are you creating Mocks on your own instead of using: { provide: SearchService, useClass: MockSearchService } and RouterTestingModule angular.io/api/router/testing/RouterTestingModule
    • Matt Raible
      Matt Raible over 4 years
      I need to set it myself so I can refer to it later in tests. For example: expect(mockSearchService.searchSpy).toHaveBeenCalledWith('M'‌​);
    • Xesenix
      Xesenix over 4 years
      you can do the same whe you take out them from TesBed like this: const someMock = TestBed.get<MockSearchService>(SearchService) or via inject([SearchService], (searchService: MockSearchService) => {...})
    • Matt Raible
      Matt Raible over 4 years
      Unfortunately, this doesn't help. Using useClass has the same results. Here's my test: gist.github.com/mraible/6799ef0f466ff062f47c5c86b942a80b. It still results in TypeError: this.getSpy is not a function. I'm not sure where this is coming from. I tried renaming the get() method in my service to getById(), but the error stays the same.
    • Xesenix
      Xesenix over 4 years
      this error R3InjectorError(DynamicTestModule)[SearchService -> HttpClient -> HttpClient]: probably could be solved with use of HttpTestingModule angular.io/api/common/http/testing/HttpClientTestingModule But i have hard time understanding why you need that extending SpyObject or that SpyObject at all.
    • Xesenix
      Xesenix over 4 years
      Ok i think I have now wage idea why I that whole getSpy looks like something that is somwhere inside jasmine so maybe they changed versions?