diff --git a/README.md b/README.md index 5b8ab5d2e2..8ec776ee30 100644 --- a/README.md +++ b/README.md @@ -1380,6 +1380,9 @@ and has a rich toolkit that supports: * [`exportAll` flag](#mockbuilder-exportall-flag) * [`dependency` flag](#mockbuilder-dependency-flag) * [`render` flag](#mockbuilder-render-flag) +* [`NG_MOCKS_GUARDS` token](#ng_mocks_guards-token) +* [`NG_MOCKS_INTERCEPTORS` token](#ng_mocks_interceptors-token) +* [`NG_MOCKS_ROOT_PROVIDERS` token](#ng_mocks_root_providers-token) * [Good to know](#mockbuilder-good-to-know)
Click to see a code sample demonstrating ease of mocking in Angular tests @@ -1542,25 +1545,6 @@ beforeEach(() => ); ``` -If we want to test guards we need to `.keep` them, but what should we do with other guards we do not want to care about at all? -The answer is to exclude `NG_MOCKS_GUARDS` token, it will removal all the guards from their routes except the explicitly configured ones. - -```typescript -beforeEach(() => - MockBuilder(MyGuard, MyModule).exclude(NG_MOCKS_GUARDS) -); -``` - -The same thing if we want to test interceptors. -If we exclude `NG_MOCKS_INTERCEPTORS` token, then all interceptors with `useValue` or `useFactory` will be excluded -together with other interceptors except the explicitly configured ones. - -```typescript -beforeEach(() => - MockBuilder(MyInterceptor, MyModule).exclude(NG_MOCKS_INTERCEPTORS) -); -``` - #### MockBuilder.replace If we want to replace something with something, we should use `.replace`. @@ -1722,6 +1706,61 @@ beforeEach(() => ); ``` +#### `NG_MOCKS_GUARDS` token + +If we want to test guards we need to `.keep` them, but what should we do with other guards we do not want to care about at all? +The answer is to exclude `NG_MOCKS_GUARDS` token, it will **remove all the guards from routes** except the explicitly configured ones. + +```typescript +beforeEach(() => + MockBuilder(MyGuard, MyModule).exclude(NG_MOCKS_GUARDS) +); +``` + +#### `NG_MOCKS_INTERCEPTORS` token + +Usually, when we want to test an interceptor, we want to avoid influences of other interceptors. +To **remove all interceptors in an angular test** we need to exclude `NG_MOCKS_INTERCEPTORS` token, +then all interceptors will be excluded except the explicitly configured ones. + +```typescript +beforeEach(() => + MockBuilder(MyInterceptor, MyModule).exclude(NG_MOCKS_INTERCEPTORS) +); +``` + +#### `NG_MOCKS_ROOT_PROVIDERS` token + +There are root services and tokens apart from provided ones in Angular applications. +It might happen that in a test we want these providers to be mocked, or kept. + +If we want to mock all root providers in an angular test we need to mock `NG_MOCKS_ROOT_PROVIDERS` token. + +```typescript +beforeEach(() => + MockBuilder( + MyComponentWithRootServices, + MyModuleWithRootTokens + ).mock(NG_MOCKS_ROOT_PROVIDERS) +); +``` + +In contrast to that, we might want to keep all root providers for mocked declarations. +For that, we need to keep `NG_MOCKS_ROOT_PROVIDERS` token. + +```typescript +beforeEach(() => + MockBuilder( + MyComponentWithRootServices, + MyModuleWithRootTokens + ).keep(NG_MOCKS_ROOT_PROVIDERS) +); +``` + +If we do not pass `NG_MOCKS_ROOT_PROVIDERS` anywhere, +then only root providers for kept modules will stay as they are. +All other root providers will be mocked, even for kept declarations of mocked modules. + #### MockBuilder good to know Anytime we can change our decision. The last action on the same object wins. SomeModule will be mocked. diff --git a/lib/common/core.config.ts b/lib/common/core.config.ts index 25f319d99f..22a6b4b839 100644 --- a/lib/common/core.config.ts +++ b/lib/common/core.config.ts @@ -5,15 +5,13 @@ export default { neverMockModule: [ApplicationModule, CommonModule], neverMockProvidedFunction: [ 'DomRendererFactory2', - 'DomSharedStylesHost', 'EventManager', - 'Injector', + 'Injector', // ivy only 'RendererFactory2', ], neverMockToken: [ - 'InjectionToken Set Injector scope.', - 'InjectionToken Application Initializer', - 'InjectionToken EventManagerPlugins', - 'InjectionToken HammerGestureConfig', + 'InjectionToken Set Injector scope.', // INJECTOR_SCOPE // ivy only + 'InjectionToken EventManagerPlugins', // EVENT_MANAGER_PLUGINS + 'InjectionToken HammerGestureConfig', // HAMMER_GESTURE_CONFIG ], }; diff --git a/lib/common/core.tokens.ts b/lib/common/core.tokens.ts index a895449715..1d9ed2b191 100644 --- a/lib/common/core.tokens.ts +++ b/lib/common/core.tokens.ts @@ -10,6 +10,7 @@ export const NG_MOCKS_OVERRIDES = new InjectionToken | AbstractTyp ); export const NG_MOCKS_GUARDS = new InjectionToken('NG_MOCKS_GUARDS'); export const NG_MOCKS_INTERCEPTORS = new InjectionToken('NG_MOCKS_INTERCEPTORS'); +export const NG_MOCKS_ROOT_PROVIDERS = new InjectionToken('NG_MOCKS_ROOT_PROVIDERS'); /** * Use NG_MOCKS_GUARDS instead. diff --git a/lib/mock-builder/mock-builder-promise.skip-dep.ts b/lib/mock-builder/mock-builder-promise.skip-dep.ts index 09edb4288c..3c29dfbd06 100644 --- a/lib/mock-builder/mock-builder-promise.skip-dep.ts +++ b/lib/mock-builder/mock-builder-promise.skip-dep.ts @@ -1,6 +1,7 @@ import { DOCUMENT } from '@angular/common'; -import { EVENT_MANAGER_PLUGINS } from '@angular/platform-browser'; +import { isNgInjectionToken } from 'ng-mocks'; +import ngConfig from '../common/core.config'; import { ngMocksUniverse } from '../common/ng-mocks-universe'; // Checks if we should avoid mocking of the provider. @@ -11,10 +12,15 @@ export default (provide: any): boolean => { if (ngMocksUniverse.touches.has(provide)) { return true; } + if (provide === DOCUMENT) { return true; } - if (provide === EVENT_MANAGER_PLUGINS) { + + if (typeof provide === 'function' && ngConfig.neverMockProvidedFunction.indexOf(provide.name) !== -1) { + return true; + } + if (isNgInjectionToken(provide) && ngConfig.neverMockToken.indexOf(provide.toString()) !== -1) { return true; } diff --git a/lib/mock-builder/mock-builder-promise.ts b/lib/mock-builder/mock-builder-promise.ts index 857f1b2e45..3e6a8b12e3 100644 --- a/lib/mock-builder/mock-builder-promise.ts +++ b/lib/mock-builder/mock-builder-promise.ts @@ -3,7 +3,7 @@ import { MetadataOverride, TestBed } from '@angular/core/testing'; import { extractDependency, flatten, mapEntries, mapValues } from '../common/core.helpers'; import { directiveResolver, jitReflector, ngModuleResolver } from '../common/core.reflect'; -import { NG_MOCKS, NG_MOCKS_OVERRIDES, NG_MOCKS_TOUCHES } from '../common/core.tokens'; +import { NG_MOCKS, NG_MOCKS_OVERRIDES, NG_MOCKS_ROOT_PROVIDERS, NG_MOCKS_TOUCHES } from '../common/core.tokens'; import { AnyType, Type } from '../common/core.types'; import { isNgDef } from '../common/func.is-ng-def'; import { isNgInjectionToken } from '../common/func.is-ng-injection-token'; @@ -63,6 +63,7 @@ export class MockBuilderPromise implements PromiseLike { ngMocksUniverse.touches = new Set(); ngMocksUniverse.config.set('multi', new Set()); // collecting multi flags of providers. ngMocksUniverse.config.set('deps', new Set()); // collecting all deps of providers. + ngMocksUniverse.config.set('depsSkip', new Set()); // collecting all declarations of kept modules. for (const def of mapValues(this.keepDef)) { ngMocksUniverse.builder.set(def, def); @@ -213,25 +214,40 @@ export class MockBuilderPromise implements PromiseLike { } } - // Adding missed providers. + // Mocking root providers. const parameters = new Set(); - if (ngMocksUniverse.touches.size || ngMocksUniverse.config.get('deps').size) { - const touchedDefs: any[] = mapValues(ngMocksUniverse.touches); - touchedDefs.push(...mapValues(ngMocksUniverse.config.get('deps'))); - for (const def of touchedDefs) { - if (!skipDep(def)) { - parameters.add(def); - } - - for (const decorators of jitReflector.parameters(def)) { - const provide: any = extractDep(decorators); - if (skipDep(provide)) { - continue; + if (!this.keepDef.has(NG_MOCKS_ROOT_PROVIDERS)) { + // We need buckets here to process first all depsSkip, then deps and only after that all other defs. + const buckets: any[] = []; + buckets.push(mapValues(ngMocksUniverse.config.get('depsSkip'))); + buckets.push(mapValues(ngMocksUniverse.config.get('deps'))); + buckets.push(mapValues(ngMocksUniverse.touches)); + // Also we need to track what has been touched to check params recursively, but avoiding duplicates. + const touched: any[] = [].concat(...buckets); + for (const bucket of buckets) { + for (const def of bucket) { + if (!skipDep(def)) { + if (this.mockDef.has(NG_MOCKS_ROOT_PROVIDERS) || !ngMocksUniverse.config.get('depsSkip').has(def)) { + parameters.add(def); + } } - if (typeof provide === 'function' && touchedDefs.indexOf(provide) === -1) { - touchedDefs.push(provide); + + for (const decorators of jitReflector.parameters(def)) { + const provide: any = extractDep(decorators); + if (skipDep(provide)) { + continue; + } + if (typeof provide === 'function' && touched.indexOf(provide) === -1) { + touched.push(provide); + bucket.push(provide); + } + + if (this.mockDef.has(NG_MOCKS_ROOT_PROVIDERS) || !ngMocksUniverse.config.get('depsSkip').has(def)) { + parameters.add(provide); + } else { + ngMocksUniverse.config.get('depsSkip').add(provide); + } } - parameters.add(provide); } } } diff --git a/lib/mock-module/mock-module.ts b/lib/mock-module/mock-module.ts index ed6c2b4ea2..4b350cac86 100644 --- a/lib/mock-module/mock-module.ts +++ b/lib/mock-module/mock-module.ts @@ -107,6 +107,9 @@ export function MockModule(module: any): any { if (!mockModule) { mockModule = ngModule; } + if (ngMocksUniverse.flags.has('skipMock')) { + ngMocksUniverse.config.get('depsSkip')?.add(mockModule); + } if (ngModuleProviders) { const [changed, ngModuleDef] = MockNgDef({ providers: ngModuleProviders }); @@ -191,6 +194,10 @@ export function MockNgDef(ngModuleDef: NgModule, ngModule?: Type): [boolean mockedDef = MockPipe(def); } + if (ngMocksUniverse.flags.has('skipMock')) { + ngMocksUniverse.config.get('depsSkip')?.add(mockedDef); + } + resolutions.set(def, mockedDef); changed = changed || mockedDef !== def; return mockedDef; diff --git a/lib/mock-service/helper.resolve-provider.ts b/lib/mock-service/helper.resolve-provider.ts index 1c7d51cb71..b9900e49eb 100644 --- a/lib/mock-service/helper.resolve-provider.ts +++ b/lib/mock-service/helper.resolve-provider.ts @@ -80,6 +80,7 @@ export default (def: any, resolutions: Map, changed?: (flag: boolean) } if (!mockedDef && ngMocksUniverse.flags.has('skipMock')) { + ngMocksUniverse.config.get('depsSkip')?.add(provider); mockedDef = def; } if (!mockedDef) { diff --git a/tests/NG_MOCKS_ROOT_PROVIDERS/test.spec.ts b/tests/NG_MOCKS_ROOT_PROVIDERS/test.spec.ts new file mode 100644 index 0000000000..a665cb20b8 --- /dev/null +++ b/tests/NG_MOCKS_ROOT_PROVIDERS/test.spec.ts @@ -0,0 +1,108 @@ +import { Component, Injectable as InjectableSource, NgModule, VERSION } from '@angular/core'; +import { MockBuilder, MockRender, NG_MOCKS_ROOT_PROVIDERS } from 'ng-mocks'; + +// Because of A5 we need to cast Injectable to any type. +// But because of A10+ we need to do it via a middle function. +function Injectable(...args: any[]): any { + return InjectableSource(...args); +} + +@Injectable({ + providedIn: 'root', +}) +class Target1Service { + public readonly name = 'target-1'; +} + +@Component({ + selector: 'target-1', + template: `{{ service.name }}`, +}) +class Target1Component { + public readonly service: Target1Service; + + constructor(service: Target1Service) { + this.service = service; + } +} + +@NgModule({ + declarations: [Target1Component], + exports: [Target1Component], +}) +class Target1Module {} + +@Injectable({ + providedIn: 'root', +}) +class Target2Service { + public readonly name = 'target-2'; +} + +@Component({ + selector: 'target-2', + template: `{{ service.name }}`, +}) +class Target2Component { + public readonly service: Target2Service; + + constructor(service: Target2Service) { + this.service = service; + } +} + +@NgModule({ + declarations: [Target2Component], + exports: [Target2Component], +}) +class Target2Module {} + +@NgModule({ + exports: [Target1Module, Target2Module], + imports: [Target1Module, Target2Module], +}) +class CombinedModule {} + +describe('NG_MOCKS_ROOT_PROVIDERS', () => { + beforeEach(() => { + if (parseInt(VERSION.major, 10) <= 5) { + pending('Need Angular > 5'); + } + }); + + describe('default for a kept module', () => { + beforeEach(() => MockBuilder(Target1Component, CombinedModule).keep(Target1Module)); + + it('keeps its global service', () => { + const fixture = MockRender(Target1Component); + expect(fixture.nativeElement.innerHTML).toEqual('target-1'); + }); + }); + + describe('mock the token', () => { + beforeEach(() => MockBuilder(Target1Component, CombinedModule).keep(Target1Module).mock(NG_MOCKS_ROOT_PROVIDERS)); + + it('mocks global service for a kept module', () => { + const fixture = MockRender(Target1Component); + expect(fixture.nativeElement.innerHTML).toEqual(''); + }); + }); + + describe('default for a mocked module', () => { + beforeEach(() => MockBuilder(Target1Component, CombinedModule)); + + it('mocks its global service', () => { + const fixture = MockRender(Target1Component); + expect(fixture.nativeElement.innerHTML).toEqual(''); + }); + }); + + describe('keep the token', () => { + beforeEach(() => MockBuilder(Target1Component, CombinedModule).keep(NG_MOCKS_ROOT_PROVIDERS)); + + it('keeps global service for a mocked module', () => { + const fixture = MockRender(Target1Component); + expect(fixture.nativeElement.innerHTML).toEqual('target-1'); + }); + }); +}); diff --git a/tests/issue-222/app-initializer.spec.ts b/tests/issue-222/app-initializer.spec.ts new file mode 100644 index 0000000000..9aed974025 --- /dev/null +++ b/tests/issue-222/app-initializer.spec.ts @@ -0,0 +1,45 @@ +import { APP_BASE_HREF } from '@angular/common'; +import { Component, NgModule } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { BrowserModule } from '@angular/platform-browser'; +import { RouterModule } from '@angular/router'; +import { MockBuilder, MockRender, ngMocks } from 'ng-mocks'; + +@Component({ + selector: 'target', + template: ``, +}) +class TargetComponent {} + +@NgModule({ + declarations: [TargetComponent], + imports: [BrowserModule, RouterModule.forRoot([])], +}) +class TargetModule {} + +describe('issue-222:APP_INITIALIZER:mock', () => { + beforeEach(() => MockBuilder(TargetComponent, TargetModule)); + + it('correctly handles APP_INITIALIZER in a mocked module', () => { + const fixture = MockRender(TargetComponent); + expect(fixture.nativeElement.innerHTML).toContain(''); + }); +}); + +describe('issue-222:APP_INITIALIZER:keep', () => { + beforeEach(() => MockBuilder(TargetComponent).keep(TargetModule).mock(APP_BASE_HREF, '')); + + it('correctly handles APP_INITIALIZER in a kept module', () => { + const fixture = MockRender(TargetComponent); + expect(fixture.nativeElement.innerHTML).toContain(''); + }); +}); + +describe('issue-222:APP_INITIALIZER:guts', () => { + beforeEach(() => TestBed.configureTestingModule(ngMocks.guts(TargetComponent, TargetModule)).compileComponents()); + + it('correctly handles APP_INITIALIZER in a kept module', () => { + const fixture = MockRender(TargetComponent); + expect(fixture.nativeElement.innerHTML).toContain(''); + }); +}); diff --git a/tests/issue-222/application-module.spec.ts b/tests/issue-222/application-module.spec.ts new file mode 100644 index 0000000000..9611825b11 --- /dev/null +++ b/tests/issue-222/application-module.spec.ts @@ -0,0 +1,18 @@ +import { ApplicationRef, NgModule } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { BrowserModule } from '@angular/platform-browser'; +import { MockBuilder } from 'ng-mocks'; + +@NgModule({ + imports: [BrowserModule], +}) +class TargetModule {} + +describe('issue-222:application-module', () => { + beforeEach(() => MockBuilder(null, TargetModule)); + + it('does not mock its guts', () => { + const service = TestBed.get(ApplicationRef); + expect(service.viewCount).toBeDefined(); + }); +}); diff --git a/tests/issue-222/common-module.spec.ts b/tests/issue-222/common-module.spec.ts new file mode 100644 index 0000000000..97359ca0ec --- /dev/null +++ b/tests/issue-222/common-module.spec.ts @@ -0,0 +1,29 @@ +import { CommonModule } from '@angular/common'; +import { Component, NgModule } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MockModule, MockRender } from 'ng-mocks'; + +@Component({ + selector: 'target', + template: `target`, +}) +class TargetComponent {} + +@NgModule({ + declarations: [TargetComponent], + imports: [CommonModule], +}) +class TargetModule {} + +describe('issue-222:CommonModule', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [CommonModule, MockModule(CommonModule), MockModule(TargetModule)], + }) + ); + + it('correctly handles kept and mocked CommonModule', () => { + const fixture = MockRender(TargetComponent); + expect(fixture.nativeElement.innerHTML).toEqual(''); + }); +}); diff --git a/tests/issue-222/dom-shared-styles-host.spec.ts b/tests/issue-222/dom-shared-styles-host.spec.ts new file mode 100644 index 0000000000..a1fd77b934 --- /dev/null +++ b/tests/issue-222/dom-shared-styles-host.spec.ts @@ -0,0 +1,79 @@ +import { animate, state, style, transition, trigger } from '@angular/animations'; +import { Component, NgModule } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { BrowserModule } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MockBuilder, MockRender, ngMocks } from 'ng-mocks'; + +@Component({ + animations: [ + trigger('openClose', [ + state( + 'open', + style({ + backgroundColor: 'yellow', + height: '200px', + opacity: 1, + }) + ), + state( + 'closed', + style({ + backgroundColor: 'green', + height: '100px', + opacity: 0.5, + }) + ), + transition('open => closed', [animate('1s')]), + transition('closed => open', [animate('0.5s')]), + ]), + ], + selector: 'target', + template: `
The box is now {{ isOpen ? 'Open' : 'Closed' }}!
`, +}) +class TargetComponent { + isOpen = true; + + toggle() { + this.isOpen = !this.isOpen; + } +} + +@NgModule({ + declarations: [TargetComponent], + imports: [BrowserModule, BrowserAnimationsModule], +}) +class TargetModule {} + +describe('issue-222:DomSharedStylesHost:mock', () => { + beforeEach(() => MockBuilder(TargetComponent, TargetModule)); + + it('correctly handles DomSharedStylesHost in a mocked module', () => { + const fixture = MockRender(TargetComponent); + expect(fixture.nativeElement.innerHTML).toContain('The box is now Open!'); + // Animations are mocked, therefore no styles. + expect(fixture.nativeElement.innerHTML).not.toContain('yellow'); + }); +}); + +describe('issue-222:DomSharedStylesHost:keep', () => { + beforeEach(() => MockBuilder(TargetComponent).keep(TargetModule)); + + it('correctly handles DomSharedStylesHost in a kept module', () => { + const fixture = MockRender(TargetComponent); + expect(fixture.nativeElement.innerHTML).toContain('The box is now Open!'); + // Animations are kept, therefore we should get styles. + expect(fixture.nativeElement.innerHTML).toContain('yellow'); + }); +}); + +describe('issue-222:DomSharedStylesHost:guts', () => { + beforeEach(() => TestBed.configureTestingModule(ngMocks.guts(TargetComponent, TargetModule)).compileComponents()); + + it('correctly handles DomSharedStylesHost in a mocked module', () => { + const fixture = MockRender(TargetComponent); + expect(fixture.nativeElement.innerHTML).toContain('The box is now Open!'); + // Animations are mocked, therefore no styles. + expect(fixture.nativeElement.innerHTML).not.toContain('yellow'); + }); +}); diff --git a/tests/issue-222/injector-scope.spec.ts b/tests/issue-222/injector-scope.spec.ts new file mode 100644 index 0000000000..75314cb108 --- /dev/null +++ b/tests/issue-222/injector-scope.spec.ts @@ -0,0 +1,59 @@ +// tslint:disable:no-unnecessary-class + +import { Component, Inject, Injectable as InjectableSource, NgModule, PLATFORM_ID, VERSION } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { MockBuilder, MockRender } from 'ng-mocks'; + +// Because of A5 we need to cast Injectable to any type. +// But because of A10+ we need to do it via a middle function. +function Injectable(...args: any[]): any { + return InjectableSource(...args); +} + +@Injectable({ + providedIn: 'root', +}) +class KeepService { + public readonly id: any; + + constructor(@Inject(PLATFORM_ID) id: any) { + this.id = id; + } + + public echo(): any { + return this.id; + } +} + +@NgModule({}) +class KeepModule { + constructor(service: KeepService) { + service.echo(); + } +} + +@Component({ + selector: 'target', + template: `target`, +}) +class TargetComponent {} + +@NgModule({ + declarations: [TargetComponent], + imports: [BrowserModule, KeepModule], +}) +class TargetModule {} + +describe('issue-222:INJECTOR_SCOPE', () => { + beforeEach(() => { + if (parseInt(VERSION.major, 10) <= 5) { + pending('Need Angular > 5'); + } + }); + + beforeEach(() => MockBuilder(TargetComponent, TargetModule).keep(KeepModule)); + + it('does not mock INJECTOR_SCOPE, fails on ivy only', () => { + expect(() => MockRender(TargetComponent)).not.toThrowError(/No provider for KeepService/); + }); +}); diff --git a/tests/issue-222/injector.spec.ts b/tests/issue-222/injector.spec.ts new file mode 100644 index 0000000000..6ae466bbba --- /dev/null +++ b/tests/issue-222/injector.spec.ts @@ -0,0 +1,35 @@ +import { Component, Injectable, NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { MockBuilder, MockRender } from 'ng-mocks'; + +@Injectable() +class MockService {} + +@Component({ + selector: 'target', + template: `target`, +}) +class TargetComponent { + public readonly service: MockService; + + constructor(service: MockService) { + this.service = service; + } +} + +@NgModule({ + declarations: [TargetComponent], + exports: [TargetComponent], + imports: [BrowserModule], + providers: [MockService], +}) +class TargetModule {} + +describe('issue-222', () => { + beforeEach(() => MockBuilder(TargetComponent, TargetModule)); + + it('does not mock Injector, fails on ivy only', () => { + const fixture = MockRender(TargetComponent); + expect(fixture.point.componentInstance.service).toBeDefined(); + }); +});