Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Using Angular2 TestBed to Mock a service with a non-concrete class interface parameter

I have a component that I am trying to setup and test using TestBed.

This component contains one class that has a parameter in the constructor that is an interface, not a concrete class. This interface is satisfied by whatever class I choose to use (either the real one, or a mok one for unit testing). But when I am constructing the component that uses this service in TestBed, I cannot figure out how to define that parameter to the TestBed configuration.

Here is the TestBed config for the component:

describe('PanelContentAreaComponent', () => {
  let component: PanelContentAreaComponent;
  let fixture: ComponentFixture<PanelContentAreaComponent>;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ PanelContentAreaComponent
          ],
      providers:[
        MenuCommandService, ProcedureDataService, IOpenService],
      schemas: [CUSTOM_ELEMENTS_SCHEMA]
    })
    .compileComponents();
  }));

The service which is having trouble being constructed in TestBed is ProcedureDataService . It's definition is below:

@Injectable()
export class ProcedureDataService {

serverOpenFile: OpenFile;

constructor(private _openService: IOpenService) {
    this.serverOpenFile = emptyFileStatus;
}

The one parameter in the constructor of the ProcedureDataService is IOpenService whose definition is :

export interface IOpenService {
    openFile(fileType: string, dataType: string, filePath: string) ;
}

As you can see this is an interface, not a concrete class.

In my service unit test, we mock the IOpenService by implementing it as follows:

export class mockOpenService implements IOpenService{

    constructor(){}

    openFile(fileType: string, dataType: string, filePath: string) {
        let fileContent: OpenFile;
... 
...
[fake the data with mok junk]
...
        fileContent = {
            'filePath': filePath,
            'fileName': name,
            'openSuccess': isSuccess,
            'error': errorMsg,
            'fileData': jsonData
        };

        return Observable.of(fileContent);

    }

}

This works great in the ProcedureDataService service unit test. And, of course, in the real code, we implement the IOpenService with the full implemented file open service that gets the data properly.

But in trying to use this service inside of a component I get the following error:

PanelContentAreaComponent should create FAILED
        Failed: IOpenService is not defined
        ReferenceError: IOpenService is not defined

This makes sense, so then I am trying to figure out how to tell TestBed that I have a concrete class implementation of this IOpenService which I wish to use. I tried this, but it fails:

describe('PanelContentAreaComponent', () => {
  let component: PanelContentAreaComponent;
  let fixture: ComponentFixture<PanelContentAreaComponent>;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ PanelContentAreaComponent
          ],
      providers:[
        {provide: IOpenService, useClass: mockOpenService},
        MenuCommandService, ProcedureDataService, IOpenService],
      schemas: [CUSTOM_ELEMENTS_SCHEMA]
    })
    .compileComponents();
  }));

The compiler is telling me:

(31,19): error TS2693: 'IOpenService' only refers to a type, but is being used as a value here.

and I am still getting :

PanelContentAreaComponent should create FAILED
        Failed: IOpenService is not defined
        ReferenceError: IOpenService is not defined

So how do I instruct TestBed that I have a specific class (mockOpenService) implementation of an interface parameter (IOpenService) needed for the service (ProcedureDataService) being provided to test this component (PanelContentAreaComponent)?

like image 205
Kim Gentes Avatar asked Feb 27 '17 17:02

Kim Gentes


1 Answers

Interfaces can't be used as token. This is explained in the Angular docs DI chapter Dependency injection tokens

TypeScript interfaces aren't valid tokens

export interface AppConfig {
  apiEndpoint: string;
  title: string;
}

export const HERO_DI_CONFIG: AppConfig = {
  apiEndpoint: 'api.heroes.com',
  title: 'Dependency Injection'
};

The HERO_DI_CONFIG constant has an interface, AppConfig. Unfortunately, we cannot use a TypeScript interface as a token:

// FAIL! Can't use interface as provider token
[{ provide: AppConfig, useValue: HERO_DI_CONFIG })]

// FAIL! Can't inject using the interface as the parameter type
constructor(private config: AppConfig){ }

That seems strange if we're used to dependency injection in strongly typed languages, where an interface is the preferred dependency lookup key.

It's not Angular's fault. An interface is a TypeScript design-time artifact. JavaScript doesn't have interfaces. The TypeScript interface disappears from the generated JavaScript. There is no interface type information left for Angular to find at runtime.

The docs goes on to explain that you should create an OpaqueToken.

import { OpaqueToken } from '@angular/core';

export let APP_CONFIG = new OpaqueToken('app.config');

providers: [{ provide: APP_CONFIG, useValue: HERO_DI_CONFIG }]

constructor(@Inject(APP_CONFIG) config: AppConfig) {
   this.title = config.title;
}

It's ok for this example, but in our case of the service, this is not the most elegant solution. Personally, I think the more elegant solution is to not use interfaces at all for services. Instead use abstract classes. Abstract classes are transpiled to real code, just as a normal class would be. So you can use it as a token

export abstract class IOpenService {
    abstract openFile(fileType: string, dataType: string, filePath: string): any ;
}

class OpenService extends IOpenService {
  openFile(fileType: string, dataType: string, filePath: string): any  {

  }
}

Now you can do

{ provide: IOpenService, useClass: OpenService }
like image 192
Paul Samsotha Avatar answered Sep 28 '22 03:09

Paul Samsotha