Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions .releaserc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,7 @@ plugins:
scope: README
release: patch
- - '@semantic-release/exec'
- prepareCmd: |
sh compose.sh root
docker compose run --rm ng-mocks npm run lint
docker compose run --rm ng-mocks npm run ts:check
docker compose run --rm ng-mocks npm run build
- prepareCmd: npx npm install --force && npx npm run lint && npx npm run ts:check && npx npm run build
- '@semantic-release/release-notes-generator'
- - '@semantic-release/changelog'
- changelogFile: CHANGELOG.md
Expand Down
1 change: 0 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@
- `docker compose run --rm ng-mocks npm run prettier:check`
- `docker compose run --rm ng-mocks npm run lint`
- `docker compose run --rm ng-mocks npm run ts:check`
- If multiple worktrees are active, prefix direct `docker compose` commands with the same `COMPOSE_PROJECT_NAME` you use for wrappers so the checks stay inside that worktree's compose project.
- Run Prettier before `git commit`.

## Lockfiles and Dependency Refresh
Expand Down
12 changes: 0 additions & 12 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,3 @@
## [14.15.2](https://github.com/help-me-mom/ng-mocks/compare/v14.15.1...v14.15.2) (2026-03-15)


### Bug Fixes

* **a20:** adding support of angular 20 ([#13127](https://github.com/help-me-mom/ng-mocks/issues/13127)) ([340585e](https://github.com/help-me-mom/ng-mocks/commit/340585eab40e7d76a3f942c8e91eb9ff2d5887f3))
* **a21:** official Angular 21 support ([#13295](https://github.com/help-me-mom/ng-mocks/issues/13295)) ([1885657](https://github.com/help-me-mom/ng-mocks/commit/1885657cf00fe0f476af2a4e37c09e7bebc7198d))
* **a22:** official Angular 22 support ([#13298](https://github.com/help-me-mom/ng-mocks/issues/13298)) ([1d0d039](https://github.com/help-me-mom/ng-mocks/commit/1d0d039b16c19c645161dd46650c53f737b38576))
* allow MockBuilder.replace on components with injectable bases [#8201](https://github.com/help-me-mom/ng-mocks/issues/8201) ([0c99c20](https://github.com/help-me-mom/ng-mocks/commit/0c99c206ec0b01bcfe668339e96074ad46acbd03))
* avoid MockInstance false no-deprecated warnings [#10217](https://github.com/help-me-mom/ng-mocks/issues/10217) ([#13306](https://github.com/help-me-mom/ng-mocks/issues/13306)) ([dd917e0](https://github.com/help-me-mom/ng-mocks/commit/dd917e0ee2124290b0cabb19506d9e31f6dcb14b))
* keep useExisting providers for kept standalone CVAs [#10960](https://github.com/help-me-mom/ng-mocks/issues/10960) ([#13305](https://github.com/help-me-mom/ng-mocks/issues/13305)) ([db1f4c8](https://github.com/help-me-mom/ng-mocks/commit/db1f4c8b3afddaec8d2dee0652fe77e340aa964c))

## [14.15.1](https://github.com/help-me-mom/ng-mocks/compare/v14.15.0...v14.15.1) (2026-02-04)


Expand Down
11 changes: 0 additions & 11 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,6 @@ and click on the "Edit this page" link at the bottom of the page.

To develop `ng-mocks` you need to use `bash` and `WSL` in case if you are on Windows.

### Signed commits for pull requests

Pull requests need signed commits. Unsigned commits can be blocked by the repository settings,
so please configure commit signing before you open or update a PR.

- GitHub docs: https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits
- Any GitHub-supported signing method is fine as long as GitHub marks the commit as `Verified`

### How to install dependencies

- start `docker` and ensure it's running
Expand All @@ -65,13 +57,10 @@ so please configure commit signing before you open or update a PR.

To avoid collisions when multiple worktrees run docker compose in parallel, set `COMPOSE_PROJECT_NAME`.
Use your own unique string for each task/worktree.
Reuse the same value for every `docker compose`, `sh ./compose.sh`, and `sh ./test.sh` command you run in that worktree.
With a unique project name, Compose keeps the worktree resources separate, including the default network and the named `cache`, `gyp`, and `npm` volumes.

```shell
COMPOSE_PROJECT_NAME=ngmocks_<your-unique-string> sh ./compose.sh e2e
COMPOSE_PROJECT_NAME=ngmocks_<your-unique-string> sh ./test.sh e2e
COMPOSE_PROJECT_NAME=ngmocks_<your-unique-string> docker compose run --rm ng-mocks npm run lint
```

## How to run unit tests locally
Expand Down
54 changes: 54 additions & 0 deletions libs/ng-mocks/src/lib/mock-render/mock-render-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@ import { getTestBed, ModuleTeardownOptions, TestBed, TestModuleMetadata } from '

import coreDefineProperty from '../common/core.define-property';
import { getInjection } from '../common/core.helpers';
import coreReflectParametersResolve from '../common/core.reflect.parameters-resolve';
import coreReflectProvidedIn from '../common/core.reflect.provided-in';
import { AnyDeclaration, AnyType, Type } from '../common/core.types';
import funcGetName from '../common/func.get-name';
import funcImportExists from '../common/func.import-exists';
import { isNgDef } from '../common/func.is-ng-def';
import { isStandalone } from '../common/func.is-standalone';
import ngMocksStack from '../common/ng-mocks-stack';
import ngMocksUniverse from '../common/ng-mocks-universe';
import extractDep from '../mock-builder/promise/extract-dep';
import { ngMocks } from '../mock-helper/mock-helper';
import helperDefinePropertyDescriptor from '../mock-service/helper.define-property-descriptor';
import helperExtractMethodsFromPrototype from '../mock-service/helper.extract-methods-from-prototype';
import helperMockService from '../mock-service/helper.mock-service';
import { MockService } from '../mock-service/mock-service';

import funcCreateWrapper from './func.create-wrapper';
Expand Down Expand Up @@ -72,6 +78,53 @@ const renderInjection = (fixture: any, template: any, params: any): void => {
funcInstallPropReader(fixture.componentInstance, fixture.point.componentInstance, [], true);
};

const extractCtorTokens = (template: any): Set<any> => {
const tokens = new Set<any>();

for (const decorators of coreReflectParametersResolve(template)) {
tokens.add(extractDep(decorators));
}
tokens.delete(undefined);

return tokens;
};

const autoSpyStandaloneInjectProperties = (template: any, instance: any): void => {
if (
!instance ||
!isNgDef(template, 'c') ||
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper is gated to components only via isNgDef(template, 'c'), but this render path also serves standalone directives, and the pipe wrapper case reaches the same area too. A standalone directive or pipe that uses inject() still appears to bypass auto-spy support, so either the guard needs widening or the narrower scope should be documented and covered explicitly.

!isStandalone(template) ||
!helperMockService.mockFunction.customMockFunction
) {
return;
}

const ctorTokens = extractCtorTokens(template);

for (const key of Object.keys(instance)) {
const value = instance[key];
const provide = value ? value.constructor : undefined;

if (!value || typeof value !== 'object' || !provide || ctorTokens.has(provide) || !coreReflectProvidedIn(provide)) {
continue;
}

let injected: any;
try {
injected = getInjection(provide);
} catch {
continue;
}
if (injected !== value) {
continue;
}

for (const method of helperExtractMethodsFromPrototype(provide.prototype)) {
helperMockService.mock(value, method);
}
}
};

const tryWhen = (flag: boolean, callback: () => void) => {
if (!flag) {
return;
Expand Down Expand Up @@ -179,6 +232,7 @@ const generateFactory = (
(componentCtor.tpl && isNgDef(template, 'p'))
) {
renderDeclaration(fixture, template, params);
autoSpyStandaloneInjectProperties(template, fixture.point.componentInstance);
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

autoSpyStandaloneInjectProperties(...) runs only after the first fixture.detectChanges(). That means any inject()-backed service call made during ngOnInit, an effect triggered on the first render, or other first-pass lifecycle work still hits the original method and will never be counted by ngMocks.autoSpy(). The new regression test only exercises calls made after MockRender(), so this path stays uncovered.

} else {
renderInjection(fixture, template, params);
}
Expand Down
166 changes: 166 additions & 0 deletions tests/issue-9397/test.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import {
Component,
forwardRef,
Inject,
Injectable,
VERSION,
} from '@angular/core';
import * as ngCore from '@angular/core';
import { TestBed } from '@angular/core/testing';

import { MockBuilder, MockRender, ngMocks } from 'ng-mocks';

// @see https://github.com/help-me-mom/ng-mocks/issues/9397
describe('issue-9397', () => {
if (Number.parseInt(VERSION.major, 10) < 14) {
it('needs a14+', () => {
expect(true).toBeTruthy();
});

return;
}

beforeAll(() =>
ngMocks.autoSpy(typeof jest === 'undefined' ? 'jasmine' : 'jest'),
);
afterAll(() => ngMocks.autoSpy('reset'));

@Injectable({
providedIn: 'root',
})
class TodoListService {
public someMethod(): void {}
}

@Component({
selector: 'target-component',
template: '',
['standalone' as never /* TODO: remove after upgrade to a14 */]: true,
})
class TargetComponent {
public readonly todoListService = (ngCore as any).inject(
TodoListService,
);

public someMethod(): void {
this.todoListService.someMethod();
}
}

@Component({
selector: 'ctor-target-component',
template: '',
['standalone' as never /* TODO: remove after upgrade to a14 */]: true,
})
class CtorTargetComponent {
public constructor(
@Inject(forwardRef(() => TodoListService))
public readonly todoListService: TodoListService,
) {}

public someMethod(): void {
this.todoListService.someMethod();
}
}

@Component({
selector: 'plain-ctor-target-component',
template: '',
['standalone' as never /* TODO: remove after upgrade to a14 */]: true,
})
class PlainCtorTargetComponent {
public constructor(
public readonly todoListService: TodoListService,
) {}

public someMethod(): void {
this.todoListService.someMethod();
}
}

class SyntheticProvidedShapeService {
public someMethod(): void {}
}

(SyntheticProvidedShapeService as any)['ɵprov'] = {
providedIn: 'root',
};

@Component({
selector: 'detached-target-component',
template: '',
['standalone' as never /* TODO: remove after upgrade to a14 */]: true,
})
class DetachedTargetComponent {
public readonly detached = new TodoListService();

public readonly empty = null;

public readonly synthetic = new SyntheticProvidedShapeService();
}

beforeEach(() => MockBuilder(TargetComponent));

it('auto-spies services injected via inject()', () => {
const fixture = MockRender(TargetComponent);
const component = fixture.point.componentInstance;
const todoListService = TestBed.inject(TodoListService);

component.someMethod();

expect(todoListService.someMethod).toHaveBeenCalledTimes(1);
expect(component.todoListService.someMethod).toBe(
todoListService.someMethod,
);
});

describe('control', () => {
beforeEach(() => MockBuilder(CtorTargetComponent));

it('auto-spies services injected via constructor', () => {
const fixture = MockRender(CtorTargetComponent);
const component = fixture.point.componentInstance;
const todoListService = TestBed.inject(TodoListService);

component.someMethod();

expect(todoListService.someMethod).toHaveBeenCalledTimes(1);
expect(component.todoListService.someMethod).toBe(
todoListService.someMethod,
);
});
});

describe('plain ctor control', () => {
beforeEach(() => MockBuilder(PlainCtorTargetComponent));

it('keeps constructor-based auto-spy for plain parameters', () => {
const fixture = MockRender(PlainCtorTargetComponent);
const component = fixture.point.componentInstance;
const todoListService = TestBed.inject(TodoListService);

component.someMethod();

expect(todoListService.someMethod).toHaveBeenCalledTimes(1);
expect(component.todoListService.someMethod).toBe(
todoListService.someMethod,
);
});
});

describe('guards', () => {
beforeEach(() => MockBuilder(DetachedTargetComponent));

it('skips standalone properties which are not the injected singleton', () => {
const fixture = MockRender(DetachedTargetComponent);
const component = fixture.point.componentInstance;

expect(component.detached.someMethod).toBe(
TodoListService.prototype.someMethod,
);
expect(component.synthetic.someMethod).toBe(
SyntheticProvidedShapeService.prototype.someMethod,
);
});
});
});