Skip to content

Commit

Permalink
feat(MockBuilder): can be extended with custom methods #5417
Browse files Browse the repository at this point in the history
  • Loading branch information
satanTime committed Jun 11, 2023
1 parent 98f3e60 commit 8da8c9d
Show file tree
Hide file tree
Showing 6 changed files with 189 additions and 3 deletions.
1 change: 1 addition & 0 deletions .eslintrc.yml
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions docs/articles/api/MockBuilder.md
Expand Up @@ -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<IMockBuilderExtended['customMock']>`,
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<IMockBuilderExtended['customMock']>) => {
// 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`,
Expand Down
1 change: 1 addition & 0 deletions libs/ng-mocks/src/index.ts
Expand Up @@ -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,
Expand Down
55 changes: 52 additions & 3 deletions 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<any> | NgModuleWithProviders;

Expand All @@ -17,12 +20,22 @@ export type MockBuilderParam = string | AnyDeclaration<any> | NgModuleWithProvid
export function MockBuilder(
keepDeclaration?: MockBuilderParam | MockBuilderParam[] | null | undefined,
itsModuleToMock?: MockBuilderParam | MockBuilderParam[] | null | undefined,
): IMockBuilder;
): IMockBuilderExtended;

export function MockBuilder(...args: Array<MockBuilderParam | MockBuilderParam[] | null | undefined>): IMockBuilder {
const [keepDeclaration, itsModuleToMock] = args;

const instance = new MockBuilderPerformance(args.length < 2 ? { export: true } : { dependency: true });
const extensions: Map<any, any> = 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<any>) => {
extensions.get(func)(instance, args);
return instance;
});
}

if (keepDeclaration) {
for (const declaration of flatten(keepDeclaration)) {
Expand All @@ -43,3 +56,39 @@ export function MockBuilder(...args: Array<MockBuilderParam | MockBuilderParam[]

return instance;
}

function mockBuilderExtend<K extends keyof IMockBuilderExtended & string>(
func: K,
callback?: (builder: IMockBuilderExtended, parameters: never) => void,
): void {
const extensions: Map<string, typeof callback> = 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<K extends keyof IMockBuilderExtended & string>(
func: K,
callback: (builder: IMockBuilderExtended, parameters: never) => void,
): void;

/**
* Removes a custom function from MockBuilder
*/
export function extend<K extends keyof IMockBuilderExtended & string>(func: K): void;

export function extend<K extends keyof IMockBuilderExtended & string>(
func: K,
callback?: (builder: IMockBuilderExtended, parameters: never) => void,
): void {
mockBuilderExtend(func, callback);
}
}
7 changes: 7 additions & 0 deletions libs/ng-mocks/src/lib/mock-builder/types.ts
Expand Up @@ -212,3 +212,10 @@ export interface IMockBuilder extends Promise<IMockBuilderResult> {
config?: IMockBuilderConfigAll & IMockBuilderConfigModule,
): this;
}

/**
* IMockBuilderExtended
*
* @see https://ng-mocks.sudo.eu/api/MockBuilder#extending-mockbuilder
*/
export interface IMockBuilderExtended extends IMockBuilder {}
56 changes: 56 additions & 0 deletions 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);
});
});
});

0 comments on commit 8da8c9d

Please sign in to comment.