angularmockingjasminekarma-jasminetestbed

How to mock Injector instance in Angular / Jasmine tests?


I need to test my service which is using Injector to inject services instead of constructor().

The main reason for which I use this way is the large number of services which extends my common SimpleDataService. Here are CompanyService, ProductService, TagService etc., and everyone extends SimpleDataService. So I don't want to define more than neccessary parameters to super() calls.

app.module.ts

import { Injector, NgModule } from '@angular/core';

export let InjectorInstance: Injector;

@NgModule({
  // ...
})
export class AppModule {
  constructor(private injector: Injector) {
    InjectorInstance = this.injector;
  }
}

simple-data.service.ts

import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { InjectorInstance } from 'src/app/app.module';
import { PaginateInterface } from 'src/app/models/paginate/paginate.interface';
import { environment } from 'src/environments/environment';

export class SimpleDataService<T> {
  public url = environment.apiUrl;
  private http: HttpClient;
  public options: any;
  public type: new () => T;

  constructor(
    private model: T,
  ) {
    this.http = InjectorInstance.get<HttpClient>(HttpClient);
  }

  getAll(): Observable<PaginateInterface> {
    return this.http.get(this.url + this.model.api_endpoint, this.options)
      .pipe(map((result: any) => result));
  }
}

simple-data.service.spec.ts

import { HttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { BrowserDynamicTestingModule,
  platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
import { Tag } from 'src/app/models/tag/tag.model';
import { SimpleDataService } from './simple-data.service';

describe('SimpleDataService', () => {
  TestBed.initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting());

  const model = new Tag();
  const simpleDataService = new SimpleDataService(model);
});

Now I get TypeError: Object prototype may only be an Object or null: undefined error message. And this occurs because of this line:

this.http = InjectorInstance.get<HttpClient>(HttpClient);

The InjectorInstance is the undefined here.

How can I mock an Injector instance into my InjectorInstance property avoiding this way?:

  constructor(
    private model: T,
    private injectorInstance: Injector,
  ) { }

Solution

  • Part of the issue is that you are using the export let to declare the InjectorInstance. This type of declaration makes it illegal to modify the field from any other file (as in: test file). One way to change that would be to make the InjectorInstance a static field of the AppModule class, as in:

    export class AppModule {
      static InjectorInstance: Injector;
    
      constructor(private injector: Injector) {
        AppModule.InjectorInstace = injector;
      }
    }
    

    Then you could use the TestBed in that field, as the interface of Injector is actually very simple and only contains the get method. As in :

    beforeEach(async(() => {
      TestBed.configureTestingModule({(...)});
    }));
    
    beforeEach(() => {
      AppModule.InjectorInstance = TestBed;
    });
    

    In the current implementation you should also be able to simply do:

    new AppModule(TestBed)
    

    I think it's less descriptive what this line does, but it leaves your InjectorInstance safer in production code.

    Keep in mind that all this can make your tests affect each other, because once you change that field it will be changed from the perspective of every other test, even in other files.

    Wether exchanging the dependency injection pattern for service locator pattern is a good idea is a completely different discussion.