From b039ad62752bba6586843048de626278e7d838c2 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 12 Aug 2021 12:14:50 +0200 Subject: [PATCH] MOBILE-3833 test: Improve testing utils --- src/app/app.component.test.ts | 11 -- src/core/services/tests/navigator.test.ts | 12 +- src/core/singletons/index.ts | 6 +- src/testing/setup.ts | 7 +- .../stubs/directives/core-external-content.ts | 24 +++ src/testing/stubs/pipes/translate.ts | 26 +++ src/testing/utils.ts | 151 +++++++++++++----- 7 files changed, 175 insertions(+), 62 deletions(-) create mode 100644 src/testing/stubs/directives/core-external-content.ts create mode 100644 src/testing/stubs/pipes/translate.ts diff --git a/src/app/app.component.test.ts b/src/app/app.component.test.ts index e815b64bd..ca22b6277 100644 --- a/src/app/app.component.test.ts +++ b/src/app/app.component.test.ts @@ -12,17 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Observable, Subject } from 'rxjs'; - import { AppComponent } from '@/app/app.component'; -import { CoreApp } from '@services/app'; import { CoreEvents } from '@singletons/events'; import { CoreLang, CoreLangProvider } from '@services/lang'; -import { Network, Platform, NgZone } from '@singletons'; import { mockSingleton, renderComponent } from '@/testing/utils'; import { CoreNavigator, CoreNavigatorService } from '@services/navigator'; -import { CoreSitePlugins } from '@features/siteplugins/services/siteplugins'; describe('AppComponent', () => { @@ -30,12 +25,6 @@ describe('AppComponent', () => { let navigator: CoreNavigatorService; beforeEach(() => { - mockSingleton(CoreApp, { setStatusBarColor: jest.fn() }); - mockSingleton(Network, { onChange: () => new Observable() }); - mockSingleton(Platform, { ready: () => Promise.resolve(), resume: new Subject() }); - mockSingleton(NgZone, { run: jest.fn() }); - mockSingleton(CoreSitePlugins, { hasSitePluginsLoaded: false }); - navigator = mockSingleton(CoreNavigator, ['navigate']); langProvider = mockSingleton(CoreLang, ['clearCustomStrings']); }); diff --git a/src/core/services/tests/navigator.test.ts b/src/core/services/tests/navigator.test.ts index d2c3fe25d..a0000b07e 100644 --- a/src/core/services/tests/navigator.test.ts +++ b/src/core/services/tests/navigator.test.ts @@ -14,12 +14,9 @@ import { NavController as NavControllerService } from '@ionic/angular'; -import { mock, mockSingleton } from '@/testing/utils'; +import { mockSingleton } from '@/testing/utils'; import { CoreNavigatorService } from '@services/navigator'; -import { CoreUtils, CoreUtilsProvider } from '@services/utils/utils'; -import { CoreUrlUtils, CoreUrlUtilsProvider } from '@services/utils/url'; -import { CoreTextUtils, CoreTextUtilsProvider } from '@services/utils/text'; import { NavController, Router } from '@singletons'; import { ActivatedRoute, RouterState } from '@angular/router'; import { CoreSites } from '@services/sites'; @@ -36,15 +33,12 @@ describe('CoreNavigator', () => { let navControllerMock: NavControllerService; beforeEach(() => { - router = { url: '/' }; currentMainMenuHandlers = ['home']; navigator = new CoreNavigatorService(); navControllerMock = mockSingleton(NavController, ['navigateRoot', 'navigateForward']); - mockSingleton(Router, router); - mockSingleton(CoreUtils, new CoreUtilsProvider(mock())); - mockSingleton(CoreUrlUtils, new CoreUrlUtilsProvider()); - mockSingleton(CoreTextUtils, new CoreTextUtilsProvider()); + router = mockSingleton(Router, { url: '/' }); + mockSingleton(CoreSites, { getCurrentSiteId: () => 42, isLoggedIn: () => true }); mockSingleton(CoreMainMenu, { isMainMenuTab: path => Promise.resolve(currentMainMenuHandlers.includes(path)) }); }); diff --git a/src/core/singletons/index.ts b/src/core/singletons/index.ts index d22a26290..deee2a2be 100644 --- a/src/core/singletons/index.ts +++ b/src/core/singletons/index.ts @@ -73,8 +73,9 @@ let createSingletonMethodProxy = (instance: any, method: Function, property: str * * @see makeSingleton */ -export type CoreSingletonProxy = Service & { +export type CoreSingletonProxy = Service & { instance: Service; + injectionToken: Type | AbstractType | Type | string; setInstance(instance: Service): void; }; @@ -113,13 +114,14 @@ export function makeSingleton( // eslint-disabl injectionToken: Type | AbstractType | Type | string, ): CoreSingletonProxy { const singleton = { + injectionToken, setInstance(instance: Service) { Object.defineProperty(singleton, 'instance', { value: instance, configurable: true, }); }, - } as { instance: Service; setInstance(instance: Service) }; + } as Pick, 'injectionToken' | 'instance' | 'setInstance'>; Object.defineProperty(singleton, 'instance', { get: () => { diff --git a/src/testing/setup.ts b/src/testing/setup.ts index b5a513699..b988506bb 100644 --- a/src/testing/setup.ts +++ b/src/testing/setup.ts @@ -14,7 +14,9 @@ import 'jest-preset-angular'; -import { setCreateSingletonMethodProxy } from '@singletons'; +import { setCreateSingletonMethodProxy, setSingletonsInjector } from '@singletons'; + +import { resetTestingEnvironment, getServiceInstance } from './utils'; // eslint-disable-next-line no-console console.debug = () => { @@ -35,3 +37,6 @@ setCreateSingletonMethodProxy( instance[`mock_${String(property)}`] ?? jest.fn((...args) => method.call(instance, ...args)), ); + +setSingletonsInjector({ get: injectionToken => getServiceInstance(injectionToken) }); +beforeEach(() => resetTestingEnvironment()); diff --git a/src/testing/stubs/directives/core-external-content.ts b/src/testing/stubs/directives/core-external-content.ts new file mode 100644 index 000000000..c929bd2b4 --- /dev/null +++ b/src/testing/stubs/directives/core-external-content.ts @@ -0,0 +1,24 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Directive, Input } from '@angular/core'; + +@Directive({ + selector: '[core-external-content]', +}) +export class CoreExternalContentDirectiveStub { // eslint-disable-line @angular-eslint/directive-class-suffix + + @Input() siteId?: string; + +} diff --git a/src/testing/stubs/pipes/translate.ts b/src/testing/stubs/pipes/translate.ts new file mode 100644 index 000000000..093693b91 --- /dev/null +++ b/src/testing/stubs/pipes/translate.ts @@ -0,0 +1,26 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'translate', +}) +export class TranslatePipeStub implements PipeTransform { + + transform(text: string): string { + return text; + } + +} diff --git a/src/testing/utils.ts b/src/testing/utils.ts index f2b16afec..6b7700f9e 100644 --- a/src/testing/utils.ts +++ b/src/testing/utils.ts @@ -12,18 +12,108 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, CUSTOM_ELEMENTS_SCHEMA, Type, ViewChild } from '@angular/core'; +import { AbstractType, Component, CUSTOM_ELEMENTS_SCHEMA, Type, ViewChild } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Network } from '@ionic-native/network/ngx'; +import { Observable, Subject } from 'rxjs'; +import { Platform } from '@ionic/angular'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; import { CoreSingletonProxy } from '@singletons'; import { CoreTextUtilsProvider } from '@services/utils/text'; +import { TranslatePipeStub } from './stubs/pipes/translate'; +import { CoreExternalContentDirectiveStub } from './stubs/directives/core-external-content'; + abstract class WrapperComponent { child!: U; }; +type ServiceInjectionToken = AbstractType | Type | string; + +let testBedInitialized = false; +const textUtils = new CoreTextUtilsProvider(); + +async function renderAngularComponent(component: Type, config: RenderConfig): Promise> { + config.declarations.push(component); + + TestBed.configureTestingModule({ + declarations: [ + ...getDefaultDeclarations(), + ...config.declarations, + ], + providers: [ + ...getDefaultProviders(), + ...config.providers, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [BrowserModule], + }); + + testBedInitialized = true; + + await TestBed.compileComponents(); + + const fixture = TestBed.createComponent(component); + + fixture.autoDetectChanges(true); + + await fixture.whenRenderingDone(); + await fixture.whenStable(); + + return fixture; +} + +function createWrapperComponent(template: string, componentClass: Type): Type> { + @Component({ template }) + class HostComponent extends WrapperComponent { + + @ViewChild(componentClass) child!: U; + + } + + return HostComponent; +} + +function getDefaultDeclarations(): unknown[] { + return [ + TranslatePipeStub, + CoreExternalContentDirectiveStub, + ]; +} + +function getDefaultProviders(): unknown[] { + const platformMock = mock({ is: () => false, ready: () => Promise.resolve(), resume: new Subject() }); + const networkMock = mock({ onChange: () => new Observable() }); + + return [ + { provide: Platform, useValue: platformMock }, + { provide: Network, useValue: networkMock }, + { provide: CORE_SITE_SCHEMAS, multiple: true, useValue: [] }, + ]; +} + +function resolveServiceInstanceFromTestBed(injectionToken: Exclude): Record | null { + if (!testBedInitialized) { + return null; + } + + return TestBed.inject(injectionToken) as Record | null; +} + +function createNewServiceInstance(injectionToken: Exclude): Record | null { + try { + const constructor = injectionToken as { new (): Record }; + + return new constructor(); + } catch (e) { + return null; + } +} + export interface RenderConfig { declarations: unknown[]; providers: unknown[]; @@ -31,8 +121,6 @@ export interface RenderConfig { export type WrapperComponentFixture = ComponentFixture>; -const textUtils = new CoreTextUtilsProvider(); - export function mock(instance?: Record): T; export function mock(methods: string[], instance?: Record): T; export function mock( @@ -69,19 +157,36 @@ export function mockSingleton( ): T; export function mockSingleton( singleton: CoreSingletonProxy, - methodsOrInstance: string[] | Record = [], - instance: Record = {}, + methodsOrProperties: string[] | Record = [], + properties: Record = {}, ): T { - instance = Array.isArray(methodsOrInstance) ? instance : methodsOrInstance; + properties = Array.isArray(methodsOrProperties) ? properties : methodsOrProperties; - const methods = Array.isArray(methodsOrInstance) ? methodsOrInstance : []; + const methods = Array.isArray(methodsOrProperties) ? methodsOrProperties : []; + const instance = getServiceInstance(singleton.injectionToken); const mockInstance = mock(methods, instance); + Object.assign(mockInstance, properties); + singleton.setInstance(mockInstance); return mockInstance; } +export function resetTestingEnvironment(): void { + testBedInitialized = false; +} + +export function getServiceInstance(injectionToken: ServiceInjectionToken): Record { + if (typeof injectionToken === 'string') { + return {}; + } + + return resolveServiceInstanceFromTestBed(injectionToken) + ?? createNewServiceInstance(injectionToken) + ?? {}; +} + export async function renderComponent(component: Type, config: Partial = {}): Promise> { return renderAngularComponent(component, { declarations: [], @@ -121,35 +226,3 @@ export async function renderWrapperComponent( return renderTemplate(component, `<${tag} ${inputAttributes}>`, config); } - -async function renderAngularComponent(component: Type, config: RenderConfig): Promise> { - config.declarations.push(component); - - TestBed.configureTestingModule({ - declarations: config.declarations, - schemas: [CUSTOM_ELEMENTS_SCHEMA], - providers: config.providers, - }); - - await TestBed.compileComponents(); - - const fixture = TestBed.createComponent(component); - - fixture.autoDetectChanges(true); - - await fixture.whenRenderingDone(); - await fixture.whenStable(); - - return fixture; -} - -function createWrapperComponent(template: string, componentClass: Type): Type> { - @Component({ template }) - class HostComponent extends WrapperComponent { - - @ViewChild(componentClass) child!: U; - - } - - return HostComponent; -}