From 8da8c9d11d507fd9c664ceb82d22436e0e054808 Mon Sep 17 00:00:00 2001 From: satanTime Date: Sun, 11 Jun 2023 14:12:41 +0200 Subject: [PATCH] feat(MockBuilder): can be extended with custom methods #5417 --- .eslintrc.yml | 1 + docs/articles/api/MockBuilder.md | 72 +++++++++++++++++++ libs/ng-mocks/src/index.ts | 1 + .../src/lib/mock-builder/mock-builder.ts | 55 +++++++++++++- libs/ng-mocks/src/lib/mock-builder/types.ts | 7 ++ tests/issue-5417/test.spec.ts | 56 +++++++++++++++ 6 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 tests/issue-5417/test.spec.ts diff --git a/.eslintrc.yml b/.eslintrc.yml index 794f2c228b..c207bec444 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -94,6 +94,7 @@ overrides: '@angular-eslint/no-output-rename': off '@angular-eslint/no-outputs-metadata-property': off + '@typescript-eslint/no-empty-interface': off '@typescript-eslint/no-explicit-any': off '@typescript-eslint/no-namespace': off '@typescript-eslint/no-this-alias': off diff --git a/docs/articles/api/MockBuilder.md b/docs/articles/api/MockBuilder.md index 5b02f56f3f..58c5001639 100644 --- a/docs/articles/api/MockBuilder.md +++ b/docs/articles/api/MockBuilder.md @@ -612,6 +612,78 @@ beforeEach(() => { }); ``` +## Extending `MockBuilder` + +If you want to add your own methods to `MockBuilder`, you can use `MockBuilder.extend(method, callback)` for that. + +Let's assume, we want to add a method called `customMock` which accepts a string, +and the string will be used a return value in a service. + +Eventually, it should be used like that: + +```ts +MockBuilder(/* ... */) + .mock(/* ... */) + .customMock('value') // <-- the extention to MockBuilder + .keep(/* ... */); +``` + + +The first step is to declare `customMock` as a part of the type of `MockBuilder`: + +```ts +declare module 'ng-mocks' { + interface IMockBuilderExtended { + // parameters can be whatever you want + customMock(value: string): this; // it has to return this + } +} +``` + +Parameters of `customMock` can be whatever you want to pass to your custom callback, in our case, it's a string. +However, please note, that the return type has to be `this`. + +The second step is to register a callback as an implementation of `customMock` via `MockBuilder.extend()`. + +The callback will receive two parameters: + +- the first parameter is the current instance of `MockBuilder` +- the second parameter is an array of all parameters passed to `customMock` + +:::tip correct parameters type +You can use a builtin type called `Parameters` to get a correct type tuple: +`Parameters`, +simply replace `customMock` with the name of your custom method. +::: + +```ts +// Builtin `Parameters` type can be used for type safety. +MockBuilder.extend( + 'customMock', // <-- name of our custom method + + (builder, parameters: Parameters) => { + // Extracting the value. + const value = parameters[0]; + + // Calling custom logic on builder. + // In this case, TargetService.echo() should return the value. + builder.mock(TargetService, { + echo: () => value, + }); + }, +); +``` + +Profit, now if you call `MockBuilder().customMock('mock')` then, +in its test, a call of `TargetService.echo()` will return `'mock'`. + +If you need to delete a custom method, simply call `MockBuilder.extend()` without the second parameter: + +```ts +MockBuilder.extend('customMock'); +MockBulder().customMock(''); // throws an error now +``` + ## Adding schemas `MockBuilder` provides a method called `beforeCompileComponents`, diff --git a/libs/ng-mocks/src/index.ts b/libs/ng-mocks/src/index.ts index 5508ab85a7..f241786d88 100644 --- a/libs/ng-mocks/src/index.ts +++ b/libs/ng-mocks/src/index.ts @@ -28,6 +28,7 @@ export { MockInstance, MockReset } from './lib/mock-instance/mock-instance'; export { MockBuilder } from './lib/mock-builder/mock-builder'; export { IMockBuilder, + IMockBuilderExtended, IMockBuilderConfig, IMockBuilderConfigAll, IMockBuilderConfigComponent, 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 c194f7f01e..ddec8159bc 100644 --- a/libs/ng-mocks/src/lib/mock-builder/mock-builder.ts +++ b/libs/ng-mocks/src/lib/mock-builder/mock-builder.ts @@ -1,10 +1,13 @@ -import { flatten } from '../common/core.helpers'; +import coreDefineProperty from '../common/core.define-property'; +import { flatten, mapKeys } from '../common/core.helpers'; import { AnyDeclaration } from '../common/core.types'; import { NgModuleWithProviders } from '../common/func.is-ng-module-def-with-providers'; import { isStandalone } from '../common/func.is-standalone'; +import ngMocksUniverse from '../common/ng-mocks-universe'; +import helperExtractPropertyDescriptor from '../mock-service/helper.extract-property-descriptor'; import { MockBuilderPerformance } from './mock-builder.performance'; -import { IMockBuilder } from './types'; +import { IMockBuilder, IMockBuilderExtended } from './types'; export type MockBuilderParam = string | AnyDeclaration | NgModuleWithProviders; @@ -17,12 +20,22 @@ export type MockBuilderParam = string | AnyDeclaration | NgModuleWithProvid export function MockBuilder( keepDeclaration?: MockBuilderParam | MockBuilderParam[] | null | undefined, itsModuleToMock?: MockBuilderParam | MockBuilderParam[] | null | undefined, -): IMockBuilder; +): IMockBuilderExtended; export function MockBuilder(...args: Array): IMockBuilder { const [keepDeclaration, itsModuleToMock] = args; const instance = new MockBuilderPerformance(args.length < 2 ? { export: true } : { dependency: true }); + const extensions: Map = ngMocksUniverse.config.get('MockBuilderExtensions'); + for (const func of extensions ? mapKeys(extensions) : []) { + if (helperExtractPropertyDescriptor(instance, func)) { + throw new Error(`MockBuilder.${func} is a base method and cannot be customized, please use a different name.`); + } + coreDefineProperty(instance, func, (...args: Array) => { + extensions.get(func)(instance, args); + return instance; + }); + } if (keepDeclaration) { for (const declaration of flatten(keepDeclaration)) { @@ -43,3 +56,39 @@ export function MockBuilder(...args: Array( + func: K, + callback?: (builder: IMockBuilderExtended, parameters: never) => void, +): void { + const extensions: Map = ngMocksUniverse.config.get('MockBuilderExtensions') ?? new Map(); + if (callback) { + extensions.set(func, callback); + ngMocksUniverse.config.set('MockBuilderExtensions', extensions); + } else { + extensions.delete(func); + } +} + +// istanbul ignore next: issue in istanbul https://github.com/istanbuljs/nyc/issues/1209 +export namespace MockBuilder { + /** + * Adds a custom function to MockBuilder + */ + export function extend( + func: K, + callback: (builder: IMockBuilderExtended, parameters: never) => void, + ): void; + + /** + * Removes a custom function from MockBuilder + */ + export function extend(func: K): void; + + export function extend( + func: K, + callback?: (builder: IMockBuilderExtended, parameters: never) => void, + ): void { + mockBuilderExtend(func, callback); + } +} diff --git a/libs/ng-mocks/src/lib/mock-builder/types.ts b/libs/ng-mocks/src/lib/mock-builder/types.ts index f8daf22049..2379249a65 100644 --- a/libs/ng-mocks/src/lib/mock-builder/types.ts +++ b/libs/ng-mocks/src/lib/mock-builder/types.ts @@ -212,3 +212,10 @@ export interface IMockBuilder extends Promise { config?: IMockBuilderConfigAll & IMockBuilderConfigModule, ): this; } + +/** + * IMockBuilderExtended + * + * @see https://ng-mocks.sudo.eu/api/MockBuilder#extending-mockbuilder + */ +export interface IMockBuilderExtended extends IMockBuilder {} diff --git a/tests/issue-5417/test.spec.ts b/tests/issue-5417/test.spec.ts new file mode 100644 index 0000000000..35cbbbacb0 --- /dev/null +++ b/tests/issue-5417/test.spec.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@angular/core'; + +import { MockBuilder, ngMocks } from 'ng-mocks'; + +@Injectable() +class TargetService { + echo() { + return this.constructor.name; + } +} + +declare module 'ng-mocks' { + interface IMockBuilderExtended { + customMock(value: string): this; + } +} + +// @see https://github.com/help-me-mom/ng-mocks/issues/5417 +// A way to extend MockBuilder +describe('issue-5417', () => { + beforeAll(() => { + MockBuilder.extend('customMock', (builder, [value]: [string]) => { + builder.mock(TargetService, { + echo: () => value, + }); + }); + }); + afterAll(() => MockBuilder.extend('customMock')); + + describe('usage', () => { + beforeEach(() => MockBuilder().customMock('mock')); + + it('uses custom logic', () => { + const instance = ngMocks.get(TargetService); + expect(instance.echo()).toEqual('mock'); + }); + }); + + describe('core', () => { + it('throws on existing methods', () => { + MockBuilder.extend('mock', () => undefined); + expect(MockBuilder).toThrowError( + /MockBuilder.mock is a base method/, + ); + MockBuilder.extend('mock'); + expect(MockBuilder).not.toThrow(); + }); + + it('chains customizations back to the initial instance', () => { + const instance = MockBuilder(); + expect( + instance.customMock('mock').provide([]).customMock('real'), + ).toBe(instance); + }); + }); +});