روبی آن ریلز - تزریق وابستگی در انگولار - @Injectable() در تست شکست میخورد، در حالی که @Inject() کار میکند


مسئله عمومی

من تازه شروع به اضافه کردن webpacker با Angular 5 به برنامه Rails موجود کردهام. همه چیز خوب است به جز یک مشکل عجیب با DI در تست.

به نظر میرسد که کامپوننتهای Angular من فقط وقتی با مرورگر ایجاد میشوند درست کار میکنند، اما وقتی با Jasmine/Karma تست میشوند، Dependency Injector نمیتواند توکنهای تزریق را شناسایی کند. با کد شبه:

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

موارد فوق در مرورگر کار میکنند، اما در تست خطای Error: Can't resolve all parameters for SomeComponent: (?). را میدهد. تا کنون متوجه شدهام که این مسئله برای همه @Injectable()ها اعمال میشود، با این حال وقتی هر تزریق را با @Inject صریح جایگزین میکنم:

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

همه چیز کار میکند (اما واضح است که کمی دست و پا گیر است). آیا چیزی واضح وجود دارد که بتواند باعث این شود؟

کد واقعی

من یک سرویس بسیار ساده با 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))
  }
}

که همانطور که انتظار میرود هنگامی که به کامپوننتی که از سرویس مذکور استفاده میکند ناوبری میکنم، کار میکند. با این حال، هنگام تست با 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);
    })
  });
});

همانطور که در بالا ذکر شد، افزودن @Inject(HttpClient) صریح، تست را اصلاح میکند، اما من ترجیح میدهم از این کار اجتناب کنم.

پیکربندی

کارما :

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()

پیکربندی/وبپک/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;

و فقط در صورت نیاز به loaders/typescript:

module.exports = {
  test: /\.(ts|tsx)?(\.erb)?$/,
  use: [{
    loader: 'ts-loader'
  }]
}

پاسخ شماره ۱

با اینجکتور و spyOn امتحان کنید.

شما باید یک سرویس شبیهسازی شده ایجاد کنید، بدون استفاده از 'HttpClient'، که همهی متدهای سرویس مورد نظر برای شبیهسازی را داشته باشد. سپس با استفاده از spyOn میتوانید هر چیزی که میخواهید را بازگردانید.

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);
        
        .....

امیدوارم این کمک کند!

پاسخ شماره ۲

پس با نگاه کردن به جاوااسکریپت تولید شده با استفاده از @Inject، و آنچه که فقط با @Component یا @Injectable تولید شده است (استخراج شده از دکوراتور کامل):

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

این مربوط به جدیدترین نسخهی Angular 5 است، اما احتمال دارد که به نسخههای 2 نیز اعمال شود. شما میتوانید ببینید که @Inject یک تزریق پارامتر صریح ایجاد میکند، در حالی که در غیر این صورت تزریق تنها به متاداده متکی است. این به نظر میرسد به وضوح نشان دهد که مشکل شما به پرچم emitDecoratorMetadata مربوط است، همانطور که شما پیشنهاد کردهاید.

از آنجا که emitDecoratorMetadata یک گزینه فعالشده بهصورت پیشفرض نیست، به نظر میرسد که tsconfig.json شما ممکن است در فرآیند ساخت گنجانده نشده باشد. میتوانید مکان آن را بهطور صریح با ویژگی configFile در ts-loader مشخص کنید:

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

همانطور که مستندات اشاره میکنند، مشخص کردن یک نام فایل با مسیر نسبی متفاوت است. برای یک نام فایل ts-node به بالا در درخت پوشهها حرکت کرده و تلاش میکند فایل را پیدا کند، اما برای مسیر نسبی تنها نسبت به فایل ورودی شما تلاش خواهد کرد. شما همچنین میتوانید یک مسیر مطلق مشخص کنید (برای تشخیص، فقط قرار دادن یک مسیر کدنویسی شده میتواند مفید باشد).

اگر این شکست خورد، ممکن است راهنمای Angular Webpack را هم بخوانم، که جزئیات استفاده از awesome-typescript-loader (بله، قبل از اینکه باور کنم واقعی است، مجبور شدم آن را جستجو کنم...) را به جای ts-loader توضیح میدهد. همچنین مسیر tsconfig را به طور صریح تعریف میکند، با استفاده از یک کمک کننده برای ایجاد مسیر مطلق.