14

Generic issue

I have just started adding webpacker with angular 5 to the existing rails application. All is fine except for a weird issue with a DI in test.

It seems my Angular components are just working when created with browser, but when being tested with Jasmine/Karma, Dependency Injector fails to identify injection tokens. With pseudo code:

@Component({...})
export class SomeComponent {
  constructor(private service: SomeService) {}
}

The above works in the browser, but is giving Error: Can't resolve all parameters for SomeComponent: (?). in test. So far I have noticed it applies to all @Injectable()s, however once I replace each injection with explicit @Inject:

@Component({...})
export class SomeComponent {
  constructor(@Inject(SomeService) private service: SomeService) {}
}

everything works (but obviously is quite a cumbersome). Is there anything obvious that could cause this?

Actual code

I have a very simple service running with HttpClient:

import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";

import 'rxjs/add/operator/map'

@Injectable()
export class GeneralStatsService {
  constructor(
    private http : HttpClient
  ) {}

  getMinDate() {
    return this.http.get("/api/v1/general_stats/min_date")
      .map(r => new Date(r))
  }
}

which works as expected when I navigate to component that is using said service. However, it does not work when testing with Jasmine:

import { TestBed } from "@angular/core/testing";
import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
import { GeneralStatsService } from "./general-stats.service";


describe('GeneralStatsService', () => {
  let service : GeneralStatsService;
  let httpMock : HttpTestingController;

  beforeEach(()=> {
    TestBed.configureTestingModule({
      imports: [
        HttpClientTestingModule
      ],
      providers: [
        GeneralStatsService
      ]
    })
  });

  beforeEach(() => {
    service = TestBed.get(GeneralStatsService);
    httpMock = TestBed.get(HttpTestingController);
  });

  afterEach(() => {
    httpMock.verify();
  });

  describe('getMinDate()', () => {
    let fakeResponse : string = "2015-03-05T12:39:11.467Z";

    it('returns instance of Date', (done) => {
      service.getMinDate().subscribe((result : Date) => {
        expect(result.getFullYear()).toBe(2015);
        expect(result.getMonth()).toBe(2); // January is 0
        expect(result.getDate()).toBe(5);
        done();
      });

      const req = httpMock.expectOne("/api/v1/general_stats/min_date");
      expect(req.request.method).toBe('GET');
      req.flush(fakeResponse);
    })
  });
});

As mentioned above, adding explicit @Inject(HttpClient) fixes the test, but I'd prefer to avoid this.

Configuration

Karma:

const webpackConfig = require('./config/webpack/test.js');

module.exports = function(config) {
  config.set({
    basePath: '',
    frameworks: [ 'jasmine' ],
    plugins: [
      require('karma-webpack'),
      require('karma-jasmine'),
      require('karma-chrome-launcher'),
      require('karma-jasmine-html-reporter'),
      require('karma-coverage-istanbul-reporter'),
      require('karma-spec-reporter')
    ],
    files: [
      'config/webpack/angular-bundle.ts'
    ],
    webpack: webpackConfig,
    preprocessors: {
      'config/webpack/angular-bundle.ts': ["webpack"]
    },
    mime: { "text/x-typescript": ["ts"]},
    coverageIstanbulReporter: {
      reports: [ 'html', 'lcovonly' ],
      fixWebpackSourcePaths: true
    },
    client: { clearContext: false },

    reporters: [ 'progress', 'kjhtml', 'coverage-istanbul' ],
    port: 9876,
    colors: true,

    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: [ 'Chrome' ],
    singleRun: false,
    concurrency: Infinity
  })
};

config/webpack/test.js:

const environment = require('./environment');
environment.plugins.get('Manifest').opts.writeToFileEmit = process.env.NODE_ENV !== 'test';
environment.loaders.set('istanbul-instrumenter', {
  test: /\.ts$/,
  enforce: 'post',
  loader: 'istanbul-instrumenter-loader',
  query: {
    esModules: true
  },
  exclude: ["node_modules", /\.spec.ts$/]
});

module.exports = environment.toWebpackConfig()

config/webpack/angular-bundle.ts:

import 'zone.js/dist/zone'
import 'zone.js/dist/long-stack-trace-zone';
import 'zone.js/dist/proxy.js';
import 'zone.js/dist/sync-test';
import 'zone.js/dist/jasmine-patch';
import 'zone.js/dist/async-test';
import 'zone.js/dist/fake-async-test';
import { getTestBed } from '@angular/core/testing';
import {
    BrowserDynamicTestingModule,
    platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';

declare const require: any;

jasmine.MAX_PRETTY_PRINT_DEPTH = 3;

getTestBed().initTestEnvironment(
    BrowserDynamicTestingModule,
    platformBrowserDynamicTesting()
);

const context = (require as any).context('../../app/javascript', true, /\.spec\.ts$/);
context.keys().map(context);

tsconfig.json:

{
  "compilerOptions": {
    "declaration": false,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "lib": ["es6", "dom"],
    "module": "es6",
    "moduleResolution": "node",
    "sourceMap": true,
    "target": "es5"
  },
  "exclude": [
    "**/*.spec.ts",
    "node_modules",
    "vendor",
    "public",
    "config/**/*.ts"
  ],
  "compileOnSave": false
}

environment.js:

const environment = require('@rails/webpacker').environment;

const typescript =  require('./loaders/typescript');
const erb =  require('./loaders/erb');
const elm =  require('./loaders/elm');
const html =  require('./loaders/html');

environment.loaders.append('elm', elm);
environment.loaders.append('erb', erb);
environment.loaders.append('typescript', typescript);
environment.loaders.append('html', html);

module.exports = environment;

And just in case loaders/typescript:

module.exports = {
  test: /\.(ts|tsx)?(\.erb)?$/,
  use: [{
    loader: 'ts-loader'
  }]
}
BroiSatse
  • 44,031
  • 8
  • 61
  • 86

5 Answers5

2

Try with the injector and spyOn.

You have to create a mocked service, without the 'HttpClient', that has ALL methods of the Service you want to mock. Then with spyOn you can return what you want.

TestBed.configureTestingModule({
      imports: [
        FormsModule,
        BrowserAnimationsModule
      ],
      providers: [
        {
          provide: YourService,
          useValue: mockedYourService
        }
      ]
      ....

 beforeEach(() => {
   fixture = TestBed.createComponent(YourTestingComponent);
   component = fixture.componentInstance;
   element = fixture.nativeElement;
   fixture.detectChanges();
 });

 ...
      
describe('methodName', () => {
  it('message to print',
    () => {
      const your_Service = fixture.debugElement.injector.get(YourService);
      spyOn(your_Service, 'methodName').and.returnValue(true);
        
        .....

Hope this help!

Luca Taccagni
  • 1,059
  • 6
  • 23
  • Unfortunately it does not answer the question why @Inject() works ind @Injectable() doesn't. I've managed to get thte test working (with @Inject()) but I prefer to rely on a standard @Injectable() way of things. – BroiSatse Jan 08 '18 at 23:48
  • so probably will help this...? https://stackoverflow.com/questions/37315317/what-is-the-difference-between-inject-and-injectable-in-angular-2-typescript – Luca Taccagni Jan 09 '18 at 08:33
  • The answer there actually makes my question more clear. It states `obj:SomeType is equivalent to @Inject(SomeType) obj` - this is what I would expect and it is clearly not working **as I have to specify injection token even when the type of the argument is specified**. – BroiSatse Jan 10 '18 at 10:30
2

So looking at the JavaScript generated by using @Inject, and that generated with just @Component or @Injectable (extracted from the complete decorator):

__param(0, core_1.Inject(http_1.HttpClient)), // via @Inject
__metadata("design:paramtypes", [http_1.HttpClient]) // with @Component, @Injectable only

This is from this from the most recent version of Angular 5, but likely applies all the way back to 2. You can see @Inject generates an explicit parameter injection, while otherwise injection relies solely on metadata. This would seem to strongly indicate your issue is related to the emitDecoratorMetadata flag as you have suggested.

As emitDecoratorMetadata is not a default enabled option, it would appear your tsconfig.json may not be included in the build. You can explicitly specify its location with the ts-loader configFile property:

use: [{
        loader: 'ts-loader', 
        options: {
          configFile: 'tsconfig.json' // default
        }
      }] 

As the docs note, specifying a file name is different from a relative path. For a filename ts-node will traverse up folder tree attempting to locate the file, but for the relative path it will only try relative to your entry file. You can also specify an absolute path (for diagnosis just dropping a hard-coded path might be useful).

If this fails, I might also read through the Angular Webpack guide, which details usage of awesome-typescript-loader (yes I had to look it up before I believed it was real...) instead of ts-loader. It also explicitly defines the tsconfig path, using a helper to generate an absolute path.

Greg Rozmarynowycz
  • 2,037
  • 17
  • 20
  • I agree, it is quite sure something with `emitDecoratorMetadata`, however I've tested that `tsconfig.json` is included in the build (by changing it to be invalid json the build blew up). Setting configFile option does not fix the issue. :( – BroiSatse Jan 14 '18 at 13:47
  • Can you search the generated JS for `design:paramtypes` to see if the metadata calls are being generated? I believe the full string key should still be present even in minified code – Greg Rozmarynowycz Jan 14 '18 at 22:05
  • Ok, this is quite fascinating, as this metadata is present at the code that are run through karma: `GeneralStatsService=__decorate([Object(__WEBPACK_IMPORTED_MODULE_0__angular_core__["w" /* Injectable */])(),__metadata("design:paramtypes",[__WEBPACK_IMPORTED_MODULE_1__angular_common_http__["b" /* HttpClient */]])],GeneralStatsService);` – BroiSatse Jan 15 '18 at 14:09
  • Ok, very interesting; I wonder what that looks like compared to a `__param` (from `@Inject`). I'm also wondering if it could be some aspect of the `Reflect.metadata` extension, as it looks Angular may silently fail if its missing; can you try to add `console.log('TEST METADATA', Reflect.metadata);` at the top of your of your files? Should be a function. – Greg Rozmarynowycz Jan 15 '18 at 18:55
1

Have you tried adding HttpClient as a provider in the test bed configuration?

TestBed
  .configureTestingModule({
    imports: [HttpClientTestingModule],
    providers: [GeneralStatsService,
      { provide: HttpClient, useValue: new HttpClient() }
    ]
  })

That was a suggestion one of the karma developers made when someone had a similar problem. It's also what the Angular team recommends when you want to test a component with a dependency.

GMK
  • 2,890
  • 2
  • 20
  • 24
  • Thank you for the link, it seems to be exactly same issue. The solution proposed there is actually just a debug step to rule out lack of relevant provider - this is not a case unfortunately (as it would not even work with `@Inject()`. That link however might be very helpful, thanks for that. – BroiSatse Jan 10 '18 at 10:33
  • Ok, after reading the whole conversation under the issue, I am pretty certain this is the issue with emitDecoratorMetadata. Will need to dig into that a little bit more as I have this option in my tsconfig.js but it seems it is ignored by webpacker when running karma. – BroiSatse Jan 10 '18 at 10:38
  • PS. To clarify why the injection should not be necessary: HttpClientTestingModule already provides `HttpClient` token with a mock HttpClient service. Providing actual HttpClient instance is never recommended in tests as you ar enot supposed to make real http requests in your unit tests. – BroiSatse Jan 10 '18 at 10:40
0

Is the problem that your spec.ts files are excluded in your tsconfig.json, so that emitDecoratorMetadata isn't being applied to your specs?

Sam Stickland
  • 637
  • 1
  • 6
  • 17
0

I got similar issue, I fixed it by importing the core-js in the polyfills.js file. but I still don't know why it works.

import 'core-js';

Ronnie
  • 23
  • 4