diff --git a/libs/ng-mocks/src/lib/mock-builder/mock-builder.promise.ts b/libs/ng-mocks/src/lib/mock-builder/mock-builder.promise.ts index a654a0edf1..9702269275 100644 --- a/libs/ng-mocks/src/lib/mock-builder/mock-builder.promise.ts +++ b/libs/ng-mocks/src/lib/mock-builder/mock-builder.promise.ts @@ -8,8 +8,8 @@ import { isNgModuleDefWithProviders } from '../common/func.is-ng-module-def-with import ngMocksUniverse from '../common/ng-mocks-universe'; import { MockBuilderStash } from './mock-builder-stash'; -import addMissedKeepDeclarationsAndModules from './promise/add-missed-keep-declarations-and-modules'; -import addMissedMockDeclarationsAndModules from './promise/add-missed-mock-declarations-and-modules'; +import addMissingKeepDeclarationsAndModules from './promise/add-missing-keep-declarations-and-modules'; +import addMissingMockDeclarationsAndModules from './promise/add-missing-mock-declarations-and-modules'; import addRequestedProviders from './promise/add-requested-providers'; import createNgMocksOverridesToken from './promise/create-ng-mocks-overrides-token'; import createNgMocksToken from './promise/create-ng-mocks-token'; @@ -75,8 +75,8 @@ export class MockBuilderPromise implements IMockBuilder { const params = this.combineParams(); const ngModule = initNgModules(params, initUniverse(params)); - addMissedKeepDeclarationsAndModules(ngModule, params); - addMissedMockDeclarationsAndModules(ngModule, params); + addMissingKeepDeclarationsAndModules(ngModule, params); + addMissingMockDeclarationsAndModules(ngModule, params); addRequestedProviders(ngModule, params); handleRootProviders(ngModule, params); handleEntryComponents(ngModule); diff --git a/libs/ng-mocks/src/lib/mock-builder/mock-builder.ts b/libs/ng-mocks/src/lib/mock-builder/mock-builder.ts index c9711ca7f5..67ed797c24 100644 --- a/libs/ng-mocks/src/lib/mock-builder/mock-builder.ts +++ b/libs/ng-mocks/src/lib/mock-builder/mock-builder.ts @@ -225,7 +225,8 @@ export function MockBuilder( } if (itsModuleToMock) { for (const declaration of flatten(itsModuleToMock)) { - instance.mock(declaration, { + instance.mock(declaration, declaration, { + export: true, exportAll: true, }); } diff --git a/libs/ng-mocks/src/lib/mock-builder/promise/add-missing-definition.ts b/libs/ng-mocks/src/lib/mock-builder/promise/add-missing-definition.ts new file mode 100644 index 0000000000..cca80ded6a --- /dev/null +++ b/libs/ng-mocks/src/lib/mock-builder/promise/add-missing-definition.ts @@ -0,0 +1,20 @@ +import { isNgDef } from '../../common/func.is-ng-def'; +import ngMocksUniverse from '../../common/ng-mocks-universe'; + +export default (def: any, configDef: Map): boolean => { + if (!isNgDef(def, 'i') && isNgDef(def)) { + return true; + } + + const config = configDef.get(def); + if (config?.dependency) { + return true; + } + + const configInstance = ngMocksUniverse.configInstance.get(def); + if (ngMocksUniverse.touches.has(def) && (configInstance?.exported || !config?.export)) { + return true; + } + + return false; +}; diff --git a/libs/ng-mocks/src/lib/mock-builder/promise/add-missed-keep-declarations-and-modules.ts b/libs/ng-mocks/src/lib/mock-builder/promise/add-missing-keep-declarations-and-modules.ts similarity index 69% rename from libs/ng-mocks/src/lib/mock-builder/promise/add-missed-keep-declarations-and-modules.ts rename to libs/ng-mocks/src/lib/mock-builder/promise/add-missing-keep-declarations-and-modules.ts index 4427441409..697bd0e586 100644 --- a/libs/ng-mocks/src/lib/mock-builder/promise/add-missed-keep-declarations-and-modules.ts +++ b/libs/ng-mocks/src/lib/mock-builder/promise/add-missing-keep-declarations-and-modules.ts @@ -1,23 +1,14 @@ import { mapValues } from '../../common/core.helpers'; -import { isNgDef } from '../../common/func.is-ng-def'; import { isNgInjectionToken } from '../../common/func.is-ng-injection-token'; import ngMocksUniverse from '../../common/ng-mocks-universe'; +import addMissingDefinition from './add-missing-definition'; import { BuilderData, NgMeta } from './types'; export default (ngModule: NgMeta, { keepDef, configDef }: BuilderData): void => { // Adding missed kept providers to test bed. for (const def of mapValues(keepDef)) { - if (!isNgDef(def, 'i') && isNgDef(def)) { - continue; - } - - if (ngMocksUniverse.touches.has(def)) { - continue; - } - - const config = configDef.get(def); - if (config && config.dependency) { + if (addMissingDefinition(def, configDef)) { continue; } diff --git a/libs/ng-mocks/src/lib/mock-builder/promise/add-missed-mock-declarations-and-modules.ts b/libs/ng-mocks/src/lib/mock-builder/promise/add-missing-mock-declarations-and-modules.ts similarity index 64% rename from libs/ng-mocks/src/lib/mock-builder/promise/add-missed-mock-declarations-and-modules.ts rename to libs/ng-mocks/src/lib/mock-builder/promise/add-missing-mock-declarations-and-modules.ts index b9f96f4855..9f33400134 100644 --- a/libs/ng-mocks/src/lib/mock-builder/promise/add-missed-mock-declarations-and-modules.ts +++ b/libs/ng-mocks/src/lib/mock-builder/promise/add-missing-mock-declarations-and-modules.ts @@ -1,22 +1,13 @@ import { mapValues } from '../../common/core.helpers'; -import { isNgDef } from '../../common/func.is-ng-def'; import ngMocksUniverse from '../../common/ng-mocks-universe'; +import addMissingDefinition from './add-missing-definition'; import { BuilderData, NgMeta } from './types'; export default (ngModule: NgMeta, { mockDef, configDef }: BuilderData): void => { // Adding missed mock providers to test bed. for (const def of mapValues(mockDef)) { - if (!isNgDef(def, 'i') && isNgDef(def)) { - continue; - } - - if (ngMocksUniverse.touches.has(def)) { - continue; - } - - const config = configDef.get(def); - if (config && config.dependency) { + if (addMissingDefinition(def, configDef)) { continue; } diff --git a/libs/ng-mocks/src/lib/mock-builder/promise/parse-mock-arguments.ts b/libs/ng-mocks/src/lib/mock-builder/promise/parse-mock-arguments.ts index 3b9d0f3302..b99d553377 100644 --- a/libs/ng-mocks/src/lib/mock-builder/promise/parse-mock-arguments.ts +++ b/libs/ng-mocks/src/lib/mock-builder/promise/parse-mock-arguments.ts @@ -11,7 +11,7 @@ export default ( } => { let mock: any = def === a1 ? defaultMockValue : a1; let config: any = a2 ? a2 : a1 !== defaultMockValue ? a1 : undefined; - if (isNgDef(def, 'p') && typeof a1 === 'function') { + if (isNgDef(def, 'p') && typeof a1 === 'function' && a1 !== def) { mock = a1; config = a2; } else if (isNgDef(def, 'i') || !isNgDef(def)) { diff --git a/libs/ng-mocks/src/lib/mock-module/create-resolvers.ts b/libs/ng-mocks/src/lib/mock-module/create-resolvers.ts new file mode 100644 index 0000000000..45db1c9d13 --- /dev/null +++ b/libs/ng-mocks/src/lib/mock-module/create-resolvers.ts @@ -0,0 +1,126 @@ +import { Provider } from '@angular/core'; + +import { isNgDef } from '../common/func.is-ng-def'; +import { isNgModuleDefWithProviders } from '../common/func.is-ng-module-def-with-providers'; +import ngMocksUniverse from '../common/ng-mocks-universe'; +import { MockComponent } from '../mock-component/mock-component'; +import { MockDirective } from '../mock-directive/mock-directive'; +import { MockPipe } from '../mock-pipe/mock-pipe'; +import helperMockService from '../mock-service/helper.mock-service'; + +import { MockModule } from './mock-module'; + +// tslint:disable-next-line variable-name +let BrowserAnimationsModule: any; +// tslint:disable-next-line variable-name +let NoopAnimationsModule: any; +// istanbul ignore next +let replaceWithNoop: (def: any) => boolean = () => false; +try { + // tslint:disable-next-line no-require-imports no-var-requires + const imports = require('@angular/platform-browser/animations'); + BrowserAnimationsModule = imports.BrowserAnimationsModule; + NoopAnimationsModule = imports.NoopAnimationsModule; + replaceWithNoop = (def: any) => + def === BrowserAnimationsModule && + !!BrowserAnimationsModule && + !!NoopAnimationsModule && + !ngMocksUniverse.getResolution(def); +} catch { + // nothing to do +} + +const processDefMap: Array<[any, any]> = [ + ['c', MockComponent], + ['d', MockDirective], + ['p', MockPipe], +]; + +const processDef = (def: any) => { + // BrowserAnimationsModule is a very special case. + // If it is not resolved manually, we simply replace it with NoopAnimationsModule. + if (replaceWithNoop(def)) { + return NoopAnimationsModule; + } + + if (isNgDef(def, 'm') || isNgModuleDefWithProviders(def)) { + return MockModule(def as any); + } + if (ngMocksUniverse.hasBuildDeclaration(def)) { + return ngMocksUniverse.getBuildDeclaration(def); + } + if (ngMocksUniverse.flags.has('skipMock') && ngMocksUniverse.getResolution(def) !== 'mock') { + return def; + } + for (const [flag, func] of processDefMap) { + if (isNgDef(def, flag)) { + return func(def); + } + } +}; + +// resolveProvider is a special case because of the def structure. +const createResolveProvider = + (resolutions: Map, change: () => void): ((def: Provider) => any) => + (def: Provider) => + helperMockService.resolveProvider(def, resolutions, change); + +const createResolveWithProviders = (def: any, mockDef: any): boolean => + mockDef && mockDef.ngModule && isNgModuleDefWithProviders(def); + +const createResolveExisting = (def: any, resolutions: Map, change: (flag?: boolean) => void): any => { + const mockDef = resolutions.get(def); + if (def !== mockDef) { + change(); + } + + return mockDef; +}; + +const createResolveExcluded = (def: any, resolutions: Map, change: (flag?: boolean) => void): void => { + resolutions.set(def, undefined); + + change(); +}; + +const createResolve = + (resolutions: Map, change: (flag?: boolean) => void): ((def: any) => any) => + (def: any) => { + if (resolutions.has(def)) { + return createResolveExisting(def, resolutions, change); + } + + const detectedDef = isNgModuleDefWithProviders(def) ? def.ngModule : def; + if (ngMocksUniverse.isExcludedDef(detectedDef)) { + return createResolveExcluded(def, resolutions, change); + } + ngMocksUniverse.touches.add(detectedDef); + + const mockDef = processDef(def); + if (createResolveWithProviders(def, mockDef)) { + resolutions.set(def.ngModule, mockDef.ngModule); + } + if (ngMocksUniverse.flags.has('skipMock')) { + ngMocksUniverse.config.get('ngMocksDepsSkip')?.add(mockDef); + } + resolutions.set(def, mockDef); + change(mockDef !== def); + + return mockDef; + }; + +export default ( + change: () => void, + resolutions: Map, +): { + resolve: (def: any) => any; + resolveProvider: (def: Provider) => any; +} => { + const resolve = createResolve(resolutions, change); + const resolveProvider = createResolveProvider(resolutions, change); + + return { + resolve, + resolveProvider, + }; +}; diff --git a/libs/ng-mocks/src/lib/mock-module/mark-providers.ts b/libs/ng-mocks/src/lib/mock-module/mark-providers.ts new file mode 100644 index 0000000000..2ef17768f8 --- /dev/null +++ b/libs/ng-mocks/src/lib/mock-module/mark-providers.ts @@ -0,0 +1,13 @@ +import { flatten } from '../common/core.helpers'; +import funcGetProvider from '../common/func.get-provider'; +import ngMocksUniverse from '../common/ng-mocks-universe'; + +export default (providers?: any[]): void => { + for (const provider of flatten(providers ?? [])) { + const provide = funcGetProvider(provider); + + const config = ngMocksUniverse.configInstance.get(provide) ?? {}; + config.exported = true; + ngMocksUniverse.configInstance.set(provide, config); + } +}; diff --git a/libs/ng-mocks/src/lib/mock-module/mock-ng-def.ts b/libs/ng-mocks/src/lib/mock-module/mock-ng-def.ts index d51a1e0852..61a22fcf2e 100644 --- a/libs/ng-mocks/src/lib/mock-module/mock-ng-def.ts +++ b/libs/ng-mocks/src/lib/mock-module/mock-ng-def.ts @@ -2,64 +2,11 @@ import { NgModule, Provider } from '@angular/core'; import { flatten } from '../common/core.helpers'; import { Type } from '../common/core.types'; -import { isNgDef } from '../common/func.is-ng-def'; import { isNgModuleDefWithProviders } from '../common/func.is-ng-module-def-with-providers'; import ngMocksUniverse from '../common/ng-mocks-universe'; -import { MockComponent } from '../mock-component/mock-component'; -import { MockDirective } from '../mock-directive/mock-directive'; -import { MockPipe } from '../mock-pipe/mock-pipe'; -import helperMockService from '../mock-service/helper.mock-service'; - -import { MockModule } from './mock-module'; - -// tslint:disable-next-line variable-name -let BrowserAnimationsModule: any; -// tslint:disable-next-line variable-name -let NoopAnimationsModule: any; -// istanbul ignore next -let replaceWithNoop: (def: any) => boolean = () => false; -try { - // tslint:disable-next-line no-require-imports no-var-requires - const imports = require('@angular/platform-browser/animations'); - BrowserAnimationsModule = imports.BrowserAnimationsModule; - NoopAnimationsModule = imports.NoopAnimationsModule; - replaceWithNoop = (def: any) => - def === BrowserAnimationsModule && - !!BrowserAnimationsModule && - !!NoopAnimationsModule && - !ngMocksUniverse.getResolution(def); -} catch { - // nothing to do -} - -const processDefMap: Array<[any, any]> = [ - ['c', MockComponent], - ['d', MockDirective], - ['p', MockPipe], -]; - -const processDef = (def: any) => { - // BrowserAnimationsModule is a very special case. - // If it is not resolved manually, we simply replace it with NoopAnimationsModule. - if (replaceWithNoop(def)) { - return NoopAnimationsModule; - } - if (isNgDef(def, 'm') || isNgModuleDefWithProviders(def)) { - return MockModule(def as any); - } - if (ngMocksUniverse.hasBuildDeclaration(def)) { - return ngMocksUniverse.getBuildDeclaration(def); - } - if (ngMocksUniverse.flags.has('skipMock') && ngMocksUniverse.getResolution(def) !== 'mock') { - return def; - } - for (const [flag, func] of processDefMap) { - if (isNgDef(def, flag)) { - return func(def); - } - } -}; +import createResolvers from './create-resolvers'; +import markProviders from './mark-providers'; const flatToExisting = (data: T | T[], callback: (arg: T) => R | undefined): R[] => flatten(data) @@ -97,6 +44,8 @@ const processMeta = ( mockModuleDef[key] = flatToExisting(ngModule[key], callback); } } + markProviders(mockModuleDef.providers); + if (!cachePipe) { ngMocksUniverse.flags.delete('cachePipe'); } @@ -104,56 +53,6 @@ const processMeta = ( return mockModuleDef; }; -// resolveProvider is a special case because of the def structure. -const createResolveProvider = - (resolutions: Map, change: () => void): ((def: Provider) => any) => - (def: Provider) => - helperMockService.resolveProvider(def, resolutions, change); - -const createResolveWithProviders = (def: any, mockDef: any): boolean => - mockDef && mockDef.ngModule && isNgModuleDefWithProviders(def); - -const createResolveExisting = (def: any, resolutions: Map, change: (flag?: boolean) => void): any => { - const mockDef = resolutions.get(def); - if (def !== mockDef) { - change(); - } - - return mockDef; -}; - -const createResolveExcluded = (def: any, resolutions: Map, change: (flag?: boolean) => void): void => { - resolutions.set(def, undefined); - - change(); -}; - -const createResolve = - (resolutions: Map, change: (flag?: boolean) => void): ((def: any) => any) => - (def: any) => { - if (resolutions.has(def)) { - return createResolveExisting(def, resolutions, change); - } - - const detectedDef = isNgModuleDefWithProviders(def) ? def.ngModule : def; - if (ngMocksUniverse.isExcludedDef(detectedDef)) { - return createResolveExcluded(def, resolutions, change); - } - ngMocksUniverse.touches.add(detectedDef); - - const mockDef = processDef(def); - if (createResolveWithProviders(def, mockDef)) { - resolutions.set(def.ngModule, mockDef.ngModule); - } - if (ngMocksUniverse.flags.has('skipMock')) { - ngMocksUniverse.config.get('ngMocksDepsSkip')?.add(mockDef); - } - resolutions.set(def, mockDef); - change(mockDef !== def); - - return mockDef; - }; - const resolveDefForExport = ( def: any, resolve: (def: any) => any, @@ -185,22 +84,6 @@ const resolveDefForExport = ( return mockDef; }; -const createResolvers = ( - change: () => void, - resolutions: Map, -): { - resolve: (def: any) => any; - resolveProvider: (def: Provider) => any; -} => { - const resolve = createResolve(resolutions, change); - const resolveProvider = createResolveProvider(resolutions, change); - - return { - resolve, - resolveProvider, - }; -}; - const skipAddExports = (mockDef: any, mockModuleDef: NgModule): mockDef is undefined => !mockDef || (!!mockModuleDef.exports && mockModuleDef.exports.indexOf(mockDef) !== -1); diff --git a/tests/issue-623/nested.spec.ts b/tests/issue-623/nested.spec.ts new file mode 100644 index 0000000000..114d14513a --- /dev/null +++ b/tests/issue-623/nested.spec.ts @@ -0,0 +1,65 @@ +import { Component, Injectable, NgModule } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MockRenderFactory, ngMocks } from 'ng-mocks'; + +let target = 0; + +@Injectable() +class TargetService { + public readonly name: string; + + public constructor() { + target += 1; + + this.name = `target:${target}`; + } +} + +@NgModule({ + providers: [TargetService], +}) +class NestedModule {} + +@NgModule({ + imports: [NestedModule], +}) +class ServiceModule {} + +@Component({ + selector: 'target', + template: `{{ service.name }}`, +}) +class TargetComponent { + public constructor(public readonly service: TargetService) {} +} + +@NgModule({ + declarations: [TargetComponent], + exports: [TargetComponent], + imports: [ServiceModule], +}) +class TargetModule {} + +// The test ensures that a provider is available everywhere, +// despite a missing export of its module. +describe('issue-623:nested', () => { + const factory = MockRenderFactory(TargetComponent); + + describe('TestBed', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [TargetModule], + }), + ); + beforeEach(() => factory.configureTestBed()); + + it('succeeds with the directive', () => { + expect(factory).not.toThrow(); + + target = 0; + expect(ngMocks.formatText(factory())).toEqual('target:1'); + expect(ngMocks.formatText(factory())).toEqual('target:1'); + expect(TestBed.get(TargetService).name).toEqual('target:1'); + }); + }); +}); diff --git a/tests/issue-623/test.spec.ts b/tests/issue-623/test.spec.ts new file mode 100644 index 0000000000..1a957e0712 --- /dev/null +++ b/tests/issue-623/test.spec.ts @@ -0,0 +1,211 @@ +import { + Component, + Directive, + Injectable, + NgModule, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MockBuilder, MockRenderFactory, ngMocks } from 'ng-mocks'; + +let target = 0; + +@Injectable() +class TargetService { + public readonly name: string; + + public constructor() { + target += 1; + + this.name = `target:${target}`; + } +} + +@Directive({ + providers: [TargetService], + selector: '[directive]', +}) +class TargetDirective {} + +@Component({ + selector: 'target', + template: `{{ service.name }}`, +}) +class TargetComponent { + public constructor(public readonly service: TargetService) {} +} + +@NgModule({ + declarations: [TargetComponent, TargetDirective], + exports: [TargetComponent, TargetDirective], +}) +class TargetModule {} + +describe('issue-623', () => { + const withoutDirective = MockRenderFactory(TargetComponent); + const withDirective = MockRenderFactory( + ``, + ); + + describe('TestBed', () => { + describe('without-provider', () => { + ngMocks.faster(); + beforeAll(() => + TestBed.configureTestingModule({ + imports: [TargetModule], + }), + ); + beforeAll(() => withoutDirective.configureTestBed()); + beforeAll(() => withDirective.configureTestBed()); + + it('fails without the directive', () => { + expect(withoutDirective).toThrowError( + /No provider for TargetService/, + ); + }); + + it('succeeds with the directive', () => { + expect(withDirective).not.toThrow(); + + target = 0; + expect(ngMocks.formatText(withDirective())).toEqual( + 'target:1', + ); + expect(ngMocks.formatText(withDirective())).toEqual( + 'target:2', + ); + expect(ngMocks.formatText(withDirective())).toEqual( + 'target:3', + ); + }); + }); + + describe('with-provider', () => { + ngMocks.faster(); + beforeAll(() => + TestBed.configureTestingModule({ + imports: [TargetModule], + providers: [TargetService], + }), + ); + beforeAll(() => withoutDirective.configureTestBed()); + beforeAll(() => withDirective.configureTestBed()); + + // a single instance is used + it('fallbacks without the directive', () => { + target = 0; + expect(withoutDirective).not.toThrow(); + + expect(ngMocks.formatText(withoutDirective())).toEqual( + 'target:1', + ); + expect(ngMocks.formatText(withoutDirective())).toEqual( + 'target:1', + ); + expect(ngMocks.formatText(withoutDirective())).toEqual( + 'target:1', + ); + }); + + // an instance is created on every render + it('uses the directive', () => { + target = 0; + expect(withDirective).not.toThrow(); + + expect(ngMocks.formatText(withDirective())).toEqual( + 'target:2', + ); + expect(ngMocks.formatText(withDirective())).toEqual( + 'target:3', + ); + expect(ngMocks.formatText(withDirective())).toEqual( + 'target:4', + ); + }); + }); + }); + + describe('MockBuilder', () => { + describe('without-provider', () => { + ngMocks.faster(); + beforeAll(() => MockBuilder(TargetComponent, TargetModule)); + beforeAll(() => withoutDirective.configureTestBed()); + beforeAll(() => withDirective.configureTestBed()); + + it('fails without the directive', () => { + expect(withoutDirective).toThrowError( + /No provider for TargetService/, + ); + }); + + it('succeeds with the directive', () => { + expect(withDirective).not.toThrow(); + + // a mock service returns empty name + expect(ngMocks.formatText(withDirective())).toEqual(''); + expect(ngMocks.formatText(withDirective())).toEqual(''); + expect(ngMocks.formatText(withDirective())).toEqual(''); + }); + }); + + describe('w/ provider w/o export', () => { + beforeEach(() => + MockBuilder(TargetComponent, TargetModule).mock( + TargetService, + ), + ); + beforeEach(() => withoutDirective.configureTestBed()); + + it('fails without export', () => { + expect(withoutDirective).toThrowError( + /No provider for TargetService/, + ); + }); + }); + + describe('w/ provider and w/ export', () => { + beforeEach(() => + MockBuilder(TargetComponent, TargetModule).mock( + TargetService, + TargetService, + { export: true }, + ), + ); + beforeEach(() => withoutDirective.configureTestBed()); + + it('uses the global provider', () => { + expect(withoutDirective).not.toThrow(); + }); + }); + + describe('w/ provider in params', () => { + beforeEach(() => + MockBuilder(TargetComponent, [TargetModule, TargetService]), + ); + beforeEach(() => withoutDirective.configureTestBed()); + + it('uses the global provider', () => { + expect(withoutDirective).not.toThrow(); + }); + }); + + describe('w/ provider as dependency', () => { + beforeEach(() => + MockBuilder(TargetComponent, [TargetModule]).mock( + TargetService, + TargetService, + { + dependency: true, + export: true, + }, + ), + ); + beforeEach(() => withoutDirective.configureTestBed()); + + it('fails with dependency flag', () => { + expect(withoutDirective).toThrowError( + /No provider for TargetService/, + ); + }); + }); + }); +});