diff --git a/e2e/automocks/__mocks__/test-manual-mock.component.ts b/e2e/automocks/__mocks__/test-manual-mock.component.ts new file mode 100644 index 0000000000..faf3e11268 --- /dev/null +++ b/e2e/automocks/__mocks__/test-manual-mock.component.ts @@ -0,0 +1,13 @@ +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'test', + template: 'this is a manual mock for test', + standalone: true, + styles: [':host { display: block; }'], +}) +export class TestComponent { + public value = input.required(); + + method() {} +} diff --git a/e2e/automocks/__tests__/test-automocks.ts b/e2e/automocks/__tests__/test-automocks.ts new file mode 100644 index 0000000000..1befe03136 --- /dev/null +++ b/e2e/automocks/__tests__/test-automocks.ts @@ -0,0 +1,34 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import * as testManualMockComponent from '../test-manual-mock.component'; +import * as testModule from '../test.module'; + +jest.mock('../test.module'); +jest.mock('../test-manual-mock.component'); + +it('should mock module', () => { + @Component({ + template: ` + {{ 'test' | test }} +
{{ 'test' | test }}
+ `, + imports: [testModule.TestModule], + }) + class TestComponent {} + + jest.spyOn(console, 'error'); + + expect(() => TestBed.createComponent(TestComponent).detectChanges()).not.toThrow(); + + expect(console.error).not.toHaveBeenCalled(); +}); + +it('should ignore modules with manual mock', () => { + const fixture = TestBed.createComponent(testManualMockComponent.TestComponent); + + fixture.detectChanges(); + + expect(fixture.debugElement.nativeElement.innerHTML).toBe('this is a manual mock for test'); + expect(jest.isMockFunction(fixture.componentInstance.method)).toBeFalsy(); +}); diff --git a/e2e/automocks/external-lib/index.ts b/e2e/automocks/external-lib/index.ts new file mode 100644 index 0000000000..358415f999 --- /dev/null +++ b/e2e/automocks/external-lib/index.ts @@ -0,0 +1,3 @@ +export * from './some.service'; +export * from './some.component'; +export * from './some.pipe'; diff --git a/e2e/automocks/external-lib/some.component.ts b/e2e/automocks/external-lib/some.component.ts new file mode 100644 index 0000000000..7f7f0692d9 --- /dev/null +++ b/e2e/automocks/external-lib/some.component.ts @@ -0,0 +1,11 @@ +import { Component, input, output } from '@angular/core'; + +@Component({ + selector: 'some', + template: ``, +}) +export class SomeComponent { + public readonly value = input.required(); + + public readonly helloWorld = output(); +} diff --git a/e2e/automocks/external-lib/some.pipe.ts b/e2e/automocks/external-lib/some.pipe.ts new file mode 100644 index 0000000000..ab10143425 --- /dev/null +++ b/e2e/automocks/external-lib/some.pipe.ts @@ -0,0 +1,10 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'some', +}) +export class SomePipe implements PipeTransform { + public transform(value: string): string { + return value.toUpperCase(); + } +} diff --git a/e2e/automocks/external-lib/some.service.ts b/e2e/automocks/external-lib/some.service.ts new file mode 100644 index 0000000000..a78368be9f --- /dev/null +++ b/e2e/automocks/external-lib/some.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class SomeService { + public doSomething(): void {} +} diff --git a/e2e/automocks/jest-cjs.config.ts b/e2e/automocks/jest-cjs.config.ts new file mode 100644 index 0000000000..d08f177268 --- /dev/null +++ b/e2e/automocks/jest-cjs.config.ts @@ -0,0 +1,23 @@ +import type { JestConfigWithTsJest } from 'ts-jest'; + +const config: JestConfigWithTsJest = { + displayName: 'e2e-automocks', + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['/../setup-test-env.ts', '/setup-test-env.ts'], + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + '/../../build/index.js', + { + babelConfig: true, + tsconfig: '/tsconfig-cjs.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + moduleNameMapper: { + '^external-lib$': '/external-lib/index.ts', + }, +}; + +export default config; diff --git a/e2e/automocks/jest-esm.config.ts b/e2e/automocks/jest-esm.config.ts new file mode 100644 index 0000000000..9fae9c6a9c --- /dev/null +++ b/e2e/automocks/jest-esm.config.ts @@ -0,0 +1,22 @@ +import type { JestConfigWithTsJest } from 'ts-jest'; + +const config: JestConfigWithTsJest = { + displayName: 'e2e-automocks', + extensionsToTreatAsEsm: ['.ts', '.mts'], + setupFilesAfterEnv: ['/../setup-test-env.mts', '/setup-test-env.mts'], + transform: { + '^.+\\.(ts|mts|mjs|js|html)$': [ + '/../../build/index.js', + { + babelConfig: true, + useESM: true, + tsconfig: '/tsconfig-esm.spec.json', + }, + ], + }, + moduleNameMapper: { + '^external-lib$': '/external-lib/index.ts', + }, +}; + +export default config; diff --git a/e2e/automocks/jest-transpile-cjs.config.ts b/e2e/automocks/jest-transpile-cjs.config.ts new file mode 100644 index 0000000000..24e255cf4a --- /dev/null +++ b/e2e/automocks/jest-transpile-cjs.config.ts @@ -0,0 +1,23 @@ +import type { JestConfigWithTsJest } from 'ts-jest'; + +const config: JestConfigWithTsJest = { + displayName: 'e2e-automocks', + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['/../setup-test-env.ts', '/setup-test-env.ts'], + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + '/../../build/index.js', + { + babelConfig: true, + tsconfig: '/tsconfig-transpile-cjs.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + moduleNameMapper: { + '^external-lib$': '/external-lib/index.ts', + }, +}; + +export default config; diff --git a/e2e/automocks/jest-transpile-esm.config.ts b/e2e/automocks/jest-transpile-esm.config.ts new file mode 100644 index 0000000000..11762729c7 --- /dev/null +++ b/e2e/automocks/jest-transpile-esm.config.ts @@ -0,0 +1,22 @@ +import type { JestConfigWithTsJest } from 'ts-jest'; + +const config: JestConfigWithTsJest = { + displayName: 'e2e-automocks', + extensionsToTreatAsEsm: ['.ts', '.mts'], + setupFilesAfterEnv: ['/../setup-test-env.mts', '/setup-test-env.mts'], + transform: { + '^.+\\.(ts|mts|mjs|js|html)$': [ + '/../../build/index.js', + { + babelConfig: true, + useESM: true, + tsconfig: '/tsconfig-transpile-esm.spec.json', + }, + ], + }, + moduleNameMapper: { + '^external-lib$': '/external-lib/index.ts', + }, +}; + +export default config; diff --git a/e2e/automocks/mock-component-example/my.component.spec.ts b/e2e/automocks/mock-component-example/my.component.spec.ts new file mode 100644 index 0000000000..168a3c95c5 --- /dev/null +++ b/e2e/automocks/mock-component-example/my.component.spec.ts @@ -0,0 +1,52 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { SomeComponent } from 'external-lib'; + +import { MyComponent } from './my.component'; + +jest.mock('external-lib'); + +describe('MyComponent', () => { + let fixture: ComponentFixture; + let component: MyComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(MyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('if isVisible is true', () => { + beforeEach(() => { + fixture.componentRef.setInput('isVisible', true); + fixture.detectChanges(); + }); + + it('should render SomeComponent', () => { + expect(fixture.debugElement.query(By.directive(SomeComponent))).toBeTruthy(); + }); + + describe('on helloWorld event', () => { + beforeEach(() => { + jest.spyOn(component, 'onHelloWorld'); + + fixture.debugElement.query(By.directive(SomeComponent)).triggerEventHandler('helloWorld', 'hello'); + }); + + it('should call onHelloWorld', () => { + expect(component.onHelloWorld).toHaveBeenCalledWith('hello'); + }); + }); + }); + + describe('if isVisible is false', () => { + beforeEach(() => { + fixture.componentRef.setInput('isVisible', false); + fixture.detectChanges(); + }); + + it('should not render SomeComponent', () => { + expect(fixture.debugElement.query(By.directive(SomeComponent))).toBeFalsy(); + }); + }); +}); diff --git a/e2e/automocks/mock-component-example/my.component.ts b/e2e/automocks/mock-component-example/my.component.ts new file mode 100644 index 0000000000..e4b472037a --- /dev/null +++ b/e2e/automocks/mock-component-example/my.component.ts @@ -0,0 +1,20 @@ +import { Component, input, signal } from '@angular/core'; +import { SomeComponent } from 'external-lib'; + +@Component({ + selector: 'my', + template: ` + @if (isVisible()) { + + } + `, + imports: [SomeComponent], +}) +export class MyComponent { + public readonly isVisible = input(false); + public readonly value = signal(''); + + public onHelloWorld(data: string): void { + console.log(data); + } +} diff --git a/e2e/automocks/mock-pipe-example/my.component.spec.ts b/e2e/automocks/mock-pipe-example/my.component.spec.ts new file mode 100644 index 0000000000..590f729006 --- /dev/null +++ b/e2e/automocks/mock-pipe-example/my.component.spec.ts @@ -0,0 +1,35 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SomePipe } from 'external-lib'; + +import { MyComponent } from './my.component'; + +jest.mock('external-lib'); + +describe('MyComponent', () => { + let fixture: ComponentFixture; + let component: MyComponent; + + beforeEach(() => { + jest.mocked(SomePipe).prototype.transform.mockReturnValue('transformed'); + + fixture = TestBed.createComponent(MyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should render transformed value', () => { + expect(fixture.nativeElement.textContent).toBe('transformed'); + }); + + describe('if value changes', () => { + beforeEach(() => { + component.value.set('new value'); + jest.mocked(SomePipe).prototype.transform.mockReturnValue('new transformed'); + fixture.detectChanges(); + }); + + it('should render new transformed value', () => { + expect(fixture.nativeElement.textContent).toBe('new transformed'); + }); + }); +}); diff --git a/e2e/automocks/mock-pipe-example/my.component.ts b/e2e/automocks/mock-pipe-example/my.component.ts new file mode 100644 index 0000000000..e6acb64cb4 --- /dev/null +++ b/e2e/automocks/mock-pipe-example/my.component.ts @@ -0,0 +1,11 @@ +import { Component, signal } from '@angular/core'; +import { SomePipe } from 'external-lib'; + +@Component({ + selector: 'my', + template: `

{{ value() | some }}

`, + imports: [SomePipe], +}) +export class MyComponent { + public readonly value = signal(''); +} diff --git a/e2e/automocks/mock-service-example/my.component.spec.ts b/e2e/automocks/mock-service-example/my.component.spec.ts new file mode 100644 index 0000000000..60fc4a841a --- /dev/null +++ b/e2e/automocks/mock-service-example/my.component.spec.ts @@ -0,0 +1,29 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { SomeService } from 'external-lib'; + +import { MyComponent } from './my.component'; + +jest.mock('external-lib'); + +describe('MyComponent', () => { + let fixture: ComponentFixture; + let someService: jest.Mocked; + + beforeEach(() => { + someService = jest.mocked(TestBed.inject(SomeService)); + + fixture = TestBed.createComponent(MyComponent); + fixture.detectChanges(); + }); + + describe('on button click', () => { + beforeEach(() => { + fixture.debugElement.query(By.css('button')).triggerEventHandler('click', null); + }); + + it('should call doSomething on SomeService', () => { + expect(someService.doSomething).toHaveBeenCalled(); + }); + }); +}); diff --git a/e2e/automocks/mock-service-example/my.component.ts b/e2e/automocks/mock-service-example/my.component.ts new file mode 100644 index 0000000000..ed4cc8c3b3 --- /dev/null +++ b/e2e/automocks/mock-service-example/my.component.ts @@ -0,0 +1,14 @@ +import { Component, inject } from '@angular/core'; +import { SomeService } from 'external-lib'; + +@Component({ + selector: 'my', + template: ``, +}) +export class MyComponent { + private readonly someService = inject(SomeService); + + public onButtonClick(): void { + this.someService.doSomething(); + } +} diff --git a/e2e/automocks/setup-test-env.mts b/e2e/automocks/setup-test-env.mts new file mode 100644 index 0000000000..054a5f64bd --- /dev/null +++ b/e2e/automocks/setup-test-env.mts @@ -0,0 +1,3 @@ +import { setupAutoMocks } from '../../setup-env/automocks.mjs'; + +setupAutoMocks(); diff --git a/e2e/automocks/setup-test-env.ts b/e2e/automocks/setup-test-env.ts new file mode 100644 index 0000000000..03ad3eaf10 --- /dev/null +++ b/e2e/automocks/setup-test-env.ts @@ -0,0 +1,3 @@ +import { setupAutoMocks } from '../../setup-env/automocks.js'; + +setupAutoMocks(); diff --git a/e2e/automocks/test-manual-mock.component.ts b/e2e/automocks/test-manual-mock.component.ts new file mode 100644 index 0000000000..6c1cd3b648 --- /dev/null +++ b/e2e/automocks/test-manual-mock.component.ts @@ -0,0 +1,13 @@ +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'test', + template: 'this is a test', + standalone: true, + styles: [':host { display: block; }'], +}) +export class TestComponent { + public value = input.required(); + + method() {} +} diff --git a/e2e/automocks/test.component.ts b/e2e/automocks/test.component.ts new file mode 100644 index 0000000000..6c1cd3b648 --- /dev/null +++ b/e2e/automocks/test.component.ts @@ -0,0 +1,13 @@ +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'test', + template: 'this is a test', + standalone: true, + styles: [':host { display: block; }'], +}) +export class TestComponent { + public value = input.required(); + + method() {} +} diff --git a/e2e/automocks/test.directive.ts b/e2e/automocks/test.directive.ts new file mode 100644 index 0000000000..647c074d9b --- /dev/null +++ b/e2e/automocks/test.directive.ts @@ -0,0 +1,11 @@ +import { Directive, input } from '@angular/core'; + +@Directive({ + selector: '[test]', + standalone: true, +}) +export class TestDirective { + public value = input.required(); + + method() {} +} diff --git a/e2e/automocks/test.module.ts b/e2e/automocks/test.module.ts new file mode 100644 index 0000000000..274e96e033 --- /dev/null +++ b/e2e/automocks/test.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; + +import { TestComponent } from './test.component'; +import { TestDirective } from './test.directive'; +import { TestPipe } from './test.pipe'; + +@NgModule({ + imports: [TestComponent, TestDirective, TestPipe], + exports: [TestComponent, TestDirective, TestPipe], +}) +export class TestModule {} diff --git a/e2e/automocks/test.pipe.ts b/e2e/automocks/test.pipe.ts new file mode 100644 index 0000000000..5e33d66b78 --- /dev/null +++ b/e2e/automocks/test.pipe.ts @@ -0,0 +1,10 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'test', +}) +export class TestPipe implements PipeTransform { + public transform(value: string): string { + return `test ${value}`; + } +} diff --git a/e2e/automocks/tsconfig-base.spec.json b/e2e/automocks/tsconfig-base.spec.json new file mode 100644 index 0000000000..8e1cc0db38 --- /dev/null +++ b/e2e/automocks/tsconfig-base.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig-base.spec.json", + "compilerOptions": { + "isolatedModules": false, + "paths": { + "external-lib": ["e2e/automocks/external-lib/index.ts"] + } + }, + "include": ["**/*.ts"] +} diff --git a/e2e/automocks/tsconfig-cjs.spec.json b/e2e/automocks/tsconfig-cjs.spec.json new file mode 100644 index 0000000000..ac6ddd7c92 --- /dev/null +++ b/e2e/automocks/tsconfig-cjs.spec.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig-base.spec.json", + "compilerOptions": { + "isolatedModules": false + } +} diff --git a/e2e/automocks/tsconfig-esm.spec.json b/e2e/automocks/tsconfig-esm.spec.json new file mode 100644 index 0000000000..361a8ab5f1 --- /dev/null +++ b/e2e/automocks/tsconfig-esm.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig-base.spec.json", + "compilerOptions": { + "module": "ES2022", + "esModuleInterop": true, + "isolatedModules": false + } +} diff --git a/e2e/automocks/tsconfig-transpile-cjs.spec.json b/e2e/automocks/tsconfig-transpile-cjs.spec.json new file mode 100644 index 0000000000..c3a5cf432c --- /dev/null +++ b/e2e/automocks/tsconfig-transpile-cjs.spec.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig-cjs.spec.json", + "compilerOptions": { + "isolatedModules": true + } +} diff --git a/e2e/automocks/tsconfig-transpile-esm.spec.json b/e2e/automocks/tsconfig-transpile-esm.spec.json new file mode 100644 index 0000000000..28c24ab660 --- /dev/null +++ b/e2e/automocks/tsconfig-transpile-esm.spec.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig-esm.spec.json", + "compilerOptions": { + "isolatedModules": true + } +} diff --git a/jest.config.ts b/jest.config.ts index c5cf825ef4..ca82627c72 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -12,6 +12,8 @@ const config: Config = { }, ], }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + setupFilesAfterEnv: ['/setup-test-env.ts'], }; export default config; diff --git a/setup-env/automocks.d.mts b/setup-env/automocks.d.mts new file mode 100644 index 0000000000..250c9e2785 --- /dev/null +++ b/setup-env/automocks.d.mts @@ -0,0 +1 @@ +export declare const setupAutoMocks: () => void; diff --git a/setup-env/automocks.d.ts b/setup-env/automocks.d.ts new file mode 100644 index 0000000000..2a2e6ab4b5 --- /dev/null +++ b/setup-env/automocks.d.ts @@ -0,0 +1,4 @@ +declare const _default: { + setupAutoMocks: () => void; +}; +export = _default; diff --git a/setup-env/automocks.js b/setup-env/automocks.js new file mode 100644 index 0000000000..917805c388 --- /dev/null +++ b/setup-env/automocks.js @@ -0,0 +1,3 @@ +const { setupAutoMocks } = require('../build/automocks/setup-auto-mocks'); + +module.exports = { setupAutoMocks }; diff --git a/setup-env/automocks.mjs b/setup-env/automocks.mjs new file mode 100644 index 0000000000..94d847de36 --- /dev/null +++ b/setup-env/automocks.mjs @@ -0,0 +1 @@ +export { setupAutoMocks } from '../build/automocks/setup-auto-mocks.js'; diff --git a/setup-test-env.ts b/setup-test-env.ts new file mode 100644 index 0000000000..29ed7a41b9 --- /dev/null +++ b/setup-test-env.ts @@ -0,0 +1,3 @@ +import { setupZonelessTestEnv } from './setup-env/zoneless'; + +setupZonelessTestEnv(); diff --git a/src/automocks/__fixtures__/non-root.service.ts b/src/automocks/__fixtures__/non-root.service.ts new file mode 100644 index 0000000000..3e290d414c --- /dev/null +++ b/src/automocks/__fixtures__/non-root.service.ts @@ -0,0 +1,6 @@ +import { Injectable } from '@angular/core'; + +@Injectable() +export class NonRootService { + method() {} +} diff --git a/src/automocks/__fixtures__/root.service.ts b/src/automocks/__fixtures__/root.service.ts new file mode 100644 index 0000000000..0c8826cabc --- /dev/null +++ b/src/automocks/__fixtures__/root.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class RootService { + method() {} +} diff --git a/src/automocks/__fixtures__/test-not-standalone.component.ts b/src/automocks/__fixtures__/test-not-standalone.component.ts new file mode 100644 index 0000000000..f15e7ec5b3 --- /dev/null +++ b/src/automocks/__fixtures__/test-not-standalone.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'test', + template: 'this is a test', + standalone: false, + styles: [':host { display: block; }'], +}) +export class TestComponent { + method() {} +} diff --git a/src/automocks/__fixtures__/test-not-standalone.directive.ts b/src/automocks/__fixtures__/test-not-standalone.directive.ts new file mode 100644 index 0000000000..1424fee03d --- /dev/null +++ b/src/automocks/__fixtures__/test-not-standalone.directive.ts @@ -0,0 +1,9 @@ +import { Directive } from '@angular/core'; + +@Directive({ + selector: '[test]', + standalone: false, +}) +export class TestDirective { + method() {} +} diff --git a/src/automocks/__fixtures__/test-not-standalone.module.ts b/src/automocks/__fixtures__/test-not-standalone.module.ts new file mode 100644 index 0000000000..36005a13fd --- /dev/null +++ b/src/automocks/__fixtures__/test-not-standalone.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; + +import { TestComponent } from './test-not-standalone.component'; +import { TestDirective } from './test-not-standalone.directive'; +import { TestPipe } from './test-not-standalone.pipe'; + +@NgModule({ + declarations: [TestComponent, TestPipe, TestDirective], + exports: [TestComponent, TestPipe, TestDirective], +}) +export class TestModule {} diff --git a/src/automocks/__fixtures__/test-not-standalone.pipe.ts b/src/automocks/__fixtures__/test-not-standalone.pipe.ts new file mode 100644 index 0000000000..3982172412 --- /dev/null +++ b/src/automocks/__fixtures__/test-not-standalone.pipe.ts @@ -0,0 +1,11 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'test', + standalone: false, +}) +export class TestPipe implements PipeTransform { + public transform(value: string): string { + return `test ${value}`; + } +} diff --git a/src/automocks/__fixtures__/test-with-host-directives-with-signal-inputs.component.ts b/src/automocks/__fixtures__/test-with-host-directives-with-signal-inputs.component.ts new file mode 100644 index 0000000000..394a1fde44 --- /dev/null +++ b/src/automocks/__fixtures__/test-with-host-directives-with-signal-inputs.component.ts @@ -0,0 +1,19 @@ +import { Component } from '@angular/core'; + +import { TestDirective } from './test-with-signal-inputs.directive'; + +@Component({ + selector: 'test', + template: 'this is a test', + standalone: true, + styles: [':host { display: block; }'], + hostDirectives: [ + { + directive: TestDirective, + inputs: ['value1', 'value2', 'aValue3', 'aValue4'], + }, + ], +}) +export class TestComponent { + method() {} +} diff --git a/src/automocks/__fixtures__/test-with-host-directives-with-signal-inputs.directive.ts b/src/automocks/__fixtures__/test-with-host-directives-with-signal-inputs.directive.ts new file mode 100644 index 0000000000..ef4c90657a --- /dev/null +++ b/src/automocks/__fixtures__/test-with-host-directives-with-signal-inputs.directive.ts @@ -0,0 +1,17 @@ +import { Directive } from '@angular/core'; + +import { TestDirective as TestDirectiveSimple } from './test-with-signal-inputs.directive'; + +@Directive({ + selector: '[test]', + standalone: true, + hostDirectives: [ + { + directive: TestDirectiveSimple, + inputs: ['value1', 'value2', 'aValue3', 'aValue4'], + }, + ], +}) +export class TestDirective { + method() {} +} diff --git a/src/automocks/__fixtures__/test-with-host-directives.component.ts b/src/automocks/__fixtures__/test-with-host-directives.component.ts new file mode 100644 index 0000000000..b34d495009 --- /dev/null +++ b/src/automocks/__fixtures__/test-with-host-directives.component.ts @@ -0,0 +1,14 @@ +import { Component } from '@angular/core'; + +import { TestDirective } from './test.directive'; + +@Component({ + selector: 'test', + template: 'this is a test', + standalone: true, + styles: [':host { display: block; }'], + hostDirectives: [TestDirective], +}) +export class TestComponent { + method() {} +} diff --git a/src/automocks/__fixtures__/test-with-host-directives.directive.ts b/src/automocks/__fixtures__/test-with-host-directives.directive.ts new file mode 100644 index 0000000000..eee0feff6b --- /dev/null +++ b/src/automocks/__fixtures__/test-with-host-directives.directive.ts @@ -0,0 +1,12 @@ +import { Directive } from '@angular/core'; + +import { TestDirective as TestDirectiveSimple } from './test.directive'; + +@Directive({ + selector: '[test]', + standalone: true, + hostDirectives: [TestDirectiveSimple], +}) +export class TestDirective { + method() {} +} diff --git a/src/automocks/__fixtures__/test-with-signal-inputs.component.ts b/src/automocks/__fixtures__/test-with-signal-inputs.component.ts new file mode 100644 index 0000000000..c7c8da9032 --- /dev/null +++ b/src/automocks/__fixtures__/test-with-signal-inputs.component.ts @@ -0,0 +1,20 @@ +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'test', + template: 'this is a test', + standalone: true, + styles: [':host { display: block; }'], +}) +export class TestComponent { + public readonly value1 = input(''); + public readonly value2 = input.required(); + public readonly value3 = input('', { + alias: 'aValue3', + }); + public readonly value4 = input.required({ + alias: 'aValue4', + }); + + method() {} +} diff --git a/src/automocks/__fixtures__/test-with-signal-inputs.directive.ts b/src/automocks/__fixtures__/test-with-signal-inputs.directive.ts new file mode 100644 index 0000000000..a268e15e74 --- /dev/null +++ b/src/automocks/__fixtures__/test-with-signal-inputs.directive.ts @@ -0,0 +1,18 @@ +import { Directive, input } from '@angular/core'; + +@Directive({ + selector: '[test]', + standalone: true, +}) +export class TestDirective { + public readonly value1 = input(''); + public readonly value2 = input.required(); + public readonly value3 = input('', { + alias: 'aValue3', + }); + public readonly value4 = input.required({ + alias: 'aValue4', + }); + + method() {} +} diff --git a/src/automocks/__fixtures__/test.component.ts b/src/automocks/__fixtures__/test.component.ts new file mode 100644 index 0000000000..864b95708e --- /dev/null +++ b/src/automocks/__fixtures__/test.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'test', + template: 'this is a test', + standalone: true, + styles: [':host { display: block; }'], +}) +export class TestComponent { + method() {} +} diff --git a/src/automocks/__fixtures__/test.directive.ts b/src/automocks/__fixtures__/test.directive.ts new file mode 100644 index 0000000000..0bd7914466 --- /dev/null +++ b/src/automocks/__fixtures__/test.directive.ts @@ -0,0 +1,9 @@ +import { Directive } from '@angular/core'; + +@Directive({ + selector: '[test]', + standalone: true, +}) +export class TestDirective { + method() {} +} diff --git a/src/automocks/__fixtures__/test.module.ts b/src/automocks/__fixtures__/test.module.ts new file mode 100644 index 0000000000..274e96e033 --- /dev/null +++ b/src/automocks/__fixtures__/test.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; + +import { TestComponent } from './test.component'; +import { TestDirective } from './test.directive'; +import { TestPipe } from './test.pipe'; + +@NgModule({ + imports: [TestComponent, TestDirective, TestPipe], + exports: [TestComponent, TestDirective, TestPipe], +}) +export class TestModule {} diff --git a/src/automocks/__fixtures__/test.pipe.ts b/src/automocks/__fixtures__/test.pipe.ts new file mode 100644 index 0000000000..5e33d66b78 --- /dev/null +++ b/src/automocks/__fixtures__/test.pipe.ts @@ -0,0 +1,10 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'test', +}) +export class TestPipe implements PipeTransform { + public transform(value: string): string { + return `test ${value}`; + } +} diff --git a/src/automocks/setup-auto-mocks.ts b/src/automocks/setup-auto-mocks.ts new file mode 100644 index 0000000000..9c8ecde987 --- /dev/null +++ b/src/automocks/setup-auto-mocks.ts @@ -0,0 +1,20 @@ +import { version } from 'jest/package.json'; + +import { stubAnything } from './stub-anything'; +import { StubCache } from './stub-cache'; + +export function setupAutoMocks(): void { + const cache = new StubCache(); + + if (Number(version.split('.')[0]) < 30) { + throw new Error('This function requires `jest>=30`. Please upgrade jest package.'); + } + + jest.onGenerateMock((modulePath: string, moduleMock: unknown) => { + const moduleActual = jest.requireActual(modulePath); + + stubAnything(cache, moduleActual, moduleMock); + + return moduleMock; + }); +} diff --git a/src/automocks/stub-anything.spec.ts b/src/automocks/stub-anything.spec.ts new file mode 100644 index 0000000000..f95bb86f49 --- /dev/null +++ b/src/automocks/stub-anything.spec.ts @@ -0,0 +1,114 @@ +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { stubAnything } from './stub-anything'; +import { StubCache } from './stub-cache'; + +const wrappers = [ + (value: unknown) => value, + (value: unknown) => [value], + (value: unknown) => ({ value }), + (value: unknown) => [{ value }], +]; + +describe('stubAnything', () => { + it.each(wrappers)('should stub root service', (wrap) => { + const actual = jest.requireActual('./__fixtures__/root.service'); + const mock = + jest.createMockFromModule('./__fixtures__/root.service'); + + const cache = new StubCache(); + + stubAnything(cache, wrap(actual.RootService), wrap(mock.RootService)); + + const service = TestBed.inject(mock.RootService); + + expect(jest.isMockFunction(service.method)).toBeTruthy(); + }); + + it.each(wrappers)('should stub pipe', (wrap) => { + const actual = jest.requireActual('./__fixtures__/test.pipe'); + const mock = jest.createMockFromModule('./__fixtures__/test.pipe'); + + const cache = new StubCache(); + + stubAnything(cache, wrap(actual.TestPipe), wrap(mock.TestPipe)); + + @Component({ + template: '{{value() | test}}', + imports: [mock.TestPipe], + }) + class RootComponent { + public readonly value = signal('value'); + } + + const root = TestBed.createComponent(RootComponent); + + root.detectChanges(); + + expect(root.debugElement.nativeElement.innerHTML).toBe(''); + + root.componentInstance.value.set('test'); + jest.mocked(mock.TestPipe.prototype.transform).mockImplementation(() => 'stub value'); + + root.detectChanges(); + + expect(root.debugElement.nativeElement.innerHTML).toBe('stub value'); + }); + + it.each(wrappers)('stub directive with host directives that have inputs', (wrap) => { + const actual = jest.requireActual< + typeof import('./__fixtures__/test-with-host-directives-with-signal-inputs.directive') + >('./__fixtures__/test-with-host-directives-with-signal-inputs.directive'); + const mock = jest.createMockFromModule< + typeof import('./__fixtures__/test-with-host-directives-with-signal-inputs.directive') + >('./__fixtures__/test-with-host-directives-with-signal-inputs.directive'); + + const cache = new StubCache(); + + stubAnything(cache, wrap(actual.TestDirective), wrap(mock.TestDirective)); + + @Component({ + template: '
', + imports: [mock.TestDirective], + standalone: true, + }) + class RootComponent {} + + jest.spyOn(console, 'error'); + + expect(() => { + TestBed.createComponent(RootComponent).detectChanges(); + }).not.toThrow(); + + expect(console.error).not.toHaveBeenCalled(); + }); + + it.each(wrappers)('stub component with signal inputs', (wrap) => { + const actual = jest.requireActual( + './__fixtures__/test-with-signal-inputs.component', + ); + const mock = jest.createMockFromModule( + './__fixtures__/test-with-signal-inputs.component', + ); + + const cache = new StubCache(); + + stubAnything(cache, wrap(actual.TestComponent), wrap(mock.TestComponent)); + + @Component({ + template: '', + imports: [mock.TestComponent], + standalone: true, + }) + class RootComponent {} + + jest.spyOn(console, 'error'); + + expect(() => { + TestBed.createComponent(RootComponent).detectChanges(); + }).not.toThrow(); + + expect(console.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/automocks/stub-anything.ts b/src/automocks/stub-anything.ts new file mode 100644 index 0000000000..02b45b4d0b --- /dev/null +++ b/src/automocks/stub-anything.ts @@ -0,0 +1,81 @@ +import { StubCache } from './stub-cache'; +import { stubComponent } from './stub-component'; +import { stubDirective } from './stub-directive'; +import { stubInjectable } from './stub-injectable'; +import { stubModule } from './stub-module'; +import { stubPipe } from './stub-pipe'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isFunction(value: unknown): value is (...args: unknown[]) => unknown { + return typeof value === 'function'; +} + +function isFunctionOrRecord(value: unknown): value is ((...args: unknown[]) => unknown) | Record { + return isFunction(value) || isRecord(value); +} + +function isConstructor(value: unknown): value is new (...args: unknown[]) => unknown { + return ( + typeof value === 'function' && + typeof value.prototype === 'object' && + value.prototype !== null && + value.prototype.constructor === value + ); +} + +function* walk( + actual: T, + mock: T, + walkedNodes: unknown[] = [], +): Generator<[actual: new (...args: unknown[]) => unknown, mock: new (...args: unknown[]) => unknown]> { + if (!isFunctionOrRecord(actual) || !isFunctionOrRecord(mock) || walkedNodes.includes(actual)) { + return; + } + + const keys = Object.getOwnPropertyNames(actual) as (string & keyof T)[]; + + if (isConstructor(actual) && isConstructor(mock) && keys.some((key) => key.startsWith('ɵ'))) { + yield [actual, mock]; + + return; + } + + for (const key of Object.getOwnPropertyNames(actual) as (string & keyof T)[]) { + if (!Reflect.has(actual, key)) { + continue; + } + + try { + yield* walk(Reflect.get(actual, key), Reflect.get(mock, key), [...walkedNodes, actual]); + } catch { + // pass + } + } +} + +export function stubAnything(cache: StubCache, actual: T, mock: T): void { + for (const [actualCtor, mockCtor] of walk(actual, mock)) { + if ('ɵprov' in actualCtor) { + stubInjectable(cache, actualCtor, mockCtor); + } + + if ('ɵcmp' in actualCtor) { + stubComponent(cache, actualCtor, mockCtor); + } + + if ('ɵdir' in actualCtor) { + stubDirective(cache, actualCtor, mockCtor); + } + + if ('ɵpipe' in actualCtor) { + stubPipe(cache, actualCtor, mockCtor); + } + + if ('ɵmod' in actualCtor) { + stubModule(cache, actualCtor, mockCtor); + } + } +} diff --git a/src/automocks/stub-cache.ts b/src/automocks/stub-cache.ts new file mode 100644 index 0000000000..230ed9aa9a --- /dev/null +++ b/src/automocks/stub-cache.ts @@ -0,0 +1,49 @@ +import { Type, ɵComponentDef, ɵDirectiveDef, ɵNgModuleDef, ɵPipeDef } from '@angular/core'; + +export class StubCache { + private readonly componentsCache = new WeakMap, ɵComponentDef>(); + private readonly directivesCache = new WeakMap, ɵDirectiveDef>(); + private readonly pipesCache = new WeakMap, ɵPipeDef>(); + private readonly modulesCache = new WeakMap, ɵNgModuleDef>(); + private readonly mocks = new WeakMap, Type>(); + + public getOrCreateComponentDef(actual: Type, notFoundValueFactory: () => ɵComponentDef): ɵComponentDef { + if (!this.componentsCache.has(actual)) { + this.componentsCache.set(actual, notFoundValueFactory()); + } + + return this.componentsCache.get(actual) as ɵComponentDef; + } + + public getOrCreateDirectiveDef(actual: Type, notFoundValueFactory: () => ɵDirectiveDef): ɵDirectiveDef { + if (!this.directivesCache.has(actual)) { + this.directivesCache.set(actual, notFoundValueFactory()); + } + + return this.directivesCache.get(actual) as ɵDirectiveDef; + } + + public getOrCreatePipeDef(actual: Type, notFoundValueFactory: () => ɵPipeDef): ɵPipeDef { + if (!this.pipesCache.has(actual)) { + this.pipesCache.set(actual, notFoundValueFactory()); + } + + return this.pipesCache.get(actual) as ɵPipeDef; + } + + public getOrCreateModuleDef(actual: Type, notFoundValueFactory: () => ɵNgModuleDef): ɵNgModuleDef { + if (!this.modulesCache.has(actual)) { + this.modulesCache.set(actual, notFoundValueFactory()); + } + + return this.modulesCache.get(actual) as ɵNgModuleDef; + } + + public getMock(actual: Type, notFoundValueFactory: () => Type): Type { + return (this.mocks.get(actual) as Type) ?? notFoundValueFactory(); + } + + public setMock(actual: Type, mock: Type): void { + this.mocks.set(actual, mock); + } +} diff --git a/src/automocks/stub-component.spec.ts b/src/automocks/stub-component.spec.ts new file mode 100644 index 0000000000..35a2d55be0 --- /dev/null +++ b/src/automocks/stub-component.spec.ts @@ -0,0 +1,214 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { StubCache } from './stub-cache'; +import { stubComponent } from './stub-component'; +import { stubDirective } from './stub-directive'; + +describe('stubComponent', () => { + it('stub simple component', () => { + const actual = jest.requireActual( + './__fixtures__/test.component', + ); + const mock = jest.createMockFromModule( + './__fixtures__/test.component', + ); + + const cache = new StubCache(); + + stubComponent(cache, actual.TestComponent, mock.TestComponent); + + @Component({ + template: '', + imports: [mock.TestComponent], + standalone: true, + }) + class RootComponent {} + + const root = TestBed.createComponent(RootComponent); + + root.detectChanges(); + + const testComponent = root.debugElement.query(By.directive(mock.TestComponent)); + + expect(testComponent.componentInstance).toBeInstanceOf(mock.TestComponent); + expect(testComponent.nativeElement.innerHTML).toBe(''); + }); + + it('reuse stubbed component', () => { + const actual = jest.requireActual( + './__fixtures__/test.component', + ); + const mock1 = jest.createMockFromModule>( + './__fixtures__/test.component', + ); + const mock2 = jest.createMockFromModule>( + './__fixtures__/test.component', + ); + + expect(mock1).not.toBe(mock2); + + const cache = new StubCache(); + + stubComponent(cache, actual.TestComponent, mock1.TestComponent); + stubComponent(cache, actual.TestComponent, mock2.TestComponent); + + expect(mock1.TestComponent.ɵcmp).toBe(mock2.TestComponent.ɵcmp); + }); + + it('stub component with host directives', () => { + const actual = jest.requireActual( + './__fixtures__/test-with-host-directives.component', + ); + const mock = jest.createMockFromModule( + './__fixtures__/test-with-host-directives.component', + ); + const actualDirective = jest.requireActual( + './__fixtures__/test.directive', + ); + const mockDirective = jest.createMockFromModule( + './__fixtures__/test.directive', + ); + + const cache = new StubCache(); + + stubComponent(cache, actual.TestComponent, mock.TestComponent); + stubDirective(cache, actualDirective.TestDirective, mockDirective.TestDirective); + + @Component({ + template: '', + imports: [mock.TestComponent], + standalone: true, + }) + class RootComponent {} + + const root = TestBed.createComponent(RootComponent); + + root.detectChanges(); + + const testComponent = root.debugElement.query(By.directive(mockDirective.TestDirective)); + + expect(testComponent.componentInstance).toBeInstanceOf(mock.TestComponent); + expect(testComponent.injector.get(mockDirective.TestDirective)).toBeTruthy(); + }); + + it('stub component with host directives that not imported', () => { + const actual = jest.requireActual( + './__fixtures__/test-with-host-directives.component', + ); + const mock = jest.createMockFromModule( + './__fixtures__/test-with-host-directives.component', + ); + + const cache = new StubCache(); + + stubComponent(cache, actual.TestComponent, mock.TestComponent); + + @Component({ + template: '', + imports: [mock.TestComponent], + standalone: true, + }) + class RootComponent {} + + const root = TestBed.createComponent(RootComponent); + + root.detectChanges(); + + const testComponent = root.debugElement.query(By.directive(mock.TestComponent)); + + expect(testComponent.componentInstance).toBeInstanceOf(mock.TestComponent); + }); + + it('stub component with signal inputs', () => { + const actual = jest.requireActual( + './__fixtures__/test-with-signal-inputs.component', + ); + const mock = jest.createMockFromModule( + './__fixtures__/test-with-signal-inputs.component', + ); + + const cache = new StubCache(); + + stubComponent(cache, actual.TestComponent, mock.TestComponent); + + @Component({ + template: '', + imports: [mock.TestComponent], + standalone: true, + }) + class RootComponent {} + + jest.spyOn(console, 'error'); + + expect(() => { + TestBed.createComponent(RootComponent).detectChanges(); + }).not.toThrow(); + + expect(console.error).not.toHaveBeenCalled(); + }); + + it('stub component with host directives that have inputs', () => { + const actual = jest.requireActual< + typeof import('./__fixtures__/test-with-host-directives-with-signal-inputs.component') + >('./__fixtures__/test-with-host-directives-with-signal-inputs.component'); + const mock = jest.createMockFromModule< + typeof import('./__fixtures__/test-with-host-directives-with-signal-inputs.component') + >('./__fixtures__/test-with-host-directives-with-signal-inputs.component'); + const actualHostDirective = jest.requireActual< + typeof import('./__fixtures__/test-with-signal-inputs.directive') + >('./__fixtures__/test-with-signal-inputs.directive'); + const mockHostDirective = jest.createMockFromModule< + typeof import('./__fixtures__/test-with-signal-inputs.directive') + >('./__fixtures__/test-with-signal-inputs.directive'); + + const cache = new StubCache(); + + stubComponent(cache, actual.TestComponent, mock.TestComponent); + stubDirective(cache, actualHostDirective.TestDirective, mockHostDirective.TestDirective); + + @Component({ + template: '', + imports: [mock.TestComponent], + standalone: true, + }) + class RootComponent {} + + jest.spyOn(console, 'error'); + + expect(() => { + TestBed.createComponent(RootComponent).detectChanges(); + }).not.toThrow(); + + expect(console.error).not.toHaveBeenCalled(); + }); + + it('stub component with host directives that have inputs and not imported', () => { + const actual = jest.requireActual< + typeof import('./__fixtures__/test-with-host-directives-with-signal-inputs.component') + >('./__fixtures__/test-with-host-directives-with-signal-inputs.component'); + const mock = jest.createMockFromModule< + typeof import('./__fixtures__/test-with-host-directives-with-signal-inputs.component') + >('./__fixtures__/test-with-host-directives-with-signal-inputs.component'); + + const cache = new StubCache(); + + stubComponent(cache, actual.TestComponent, mock.TestComponent); + + @Component({ + template: '', + imports: [mock.TestComponent], + standalone: true, + }) + class RootComponent {} + + jest.spyOn(console, 'error'); + + expect(() => { + TestBed.createComponent(RootComponent).detectChanges(); + }).not.toThrow(); + + expect(console.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/automocks/stub-component.ts b/src/automocks/stub-component.ts new file mode 100644 index 0000000000..46bfac5f6c --- /dev/null +++ b/src/automocks/stub-component.ts @@ -0,0 +1,48 @@ +import { Type, ɵComponentDef, ɵɵdefineComponent } from '@angular/core'; + +import type { StubCache } from './stub-cache'; +import { stubHostDirectives } from './stub-host-directives'; +import { stubInputsFactory } from './stub-inputs-factory'; +import { ComponentType } from './types'; + +export function stubComponent(cache: StubCache, actual: Type, mock: Type): asserts mock is ComponentType { + const { selectors, exportAs, standalone, signals, ngContentSelectors, hostDirectives, inputs, inputConfig } = ( + actual as ComponentType + ).ɵcmp; + + cache.setMock(actual, mock); + + Object.defineProperty(mock, 'ɵcmp', { + get(): ɵComponentDef { + return cache.getOrCreateComponentDef(actual, () => { + const features: never[] = []; + + stubHostDirectives(cache, hostDirectives, features); + + return ɵɵdefineComponent({ + type: mock, + selectors, + inputs: inputConfig, + outputs: {}, + exportAs: exportAs ?? undefined, + standalone, + signals, + decls: 0, + vars: 0, + template: () => {}, + ngContentSelectors, + hostAttrs: ['stub-component', Math.random()], + features, + }); + }); + }, + }); + + let factory = () => new mock(); + + factory = stubInputsFactory(factory, inputs); + + Object.defineProperty(mock, 'ɵfac', { + value: factory, + }); +} diff --git a/src/automocks/stub-constructor.ts b/src/automocks/stub-constructor.ts new file mode 100644 index 0000000000..d088855c12 --- /dev/null +++ b/src/automocks/stub-constructor.ts @@ -0,0 +1,21 @@ +import { Type } from '@angular/core'; + +import { StubCache } from './stub-cache'; + +export function stubConstructor( + cache: StubCache, + actual: Type, + stubFn: (cache: StubCache, actual: Type, mock: Type) => void, +): Type { + const mock = jest.fn() as Type; + + for (const key of Object.getOwnPropertyNames(actual.prototype)) { + if (key !== 'constructor' && typeof actual.prototype[key] === 'function') { + mock.prototype[key] = jest.fn(); + } + } + + stubFn(cache, actual, mock); + + return mock; +} diff --git a/src/automocks/stub-directive.spec.ts b/src/automocks/stub-directive.spec.ts new file mode 100644 index 0000000000..95458dade8 --- /dev/null +++ b/src/automocks/stub-directive.spec.ts @@ -0,0 +1,212 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { StubCache } from './stub-cache'; +import { stubDirective } from './stub-directive'; + +describe('stubDirective', () => { + it('stub simple directive', () => { + const actual = jest.requireActual( + './__fixtures__/test.directive', + ); + const mock = jest.createMockFromModule( + './__fixtures__/test.directive', + ); + + const cache = new StubCache(); + + stubDirective(cache, actual.TestDirective, mock.TestDirective); + + @Component({ + template: '
', + imports: [mock.TestDirective], + standalone: true, + }) + class RootComponent {} + + const root = TestBed.createComponent(RootComponent); + + root.detectChanges(); + + const testDirective = root.debugElement.query(By.directive(mock.TestDirective)); + + expect(testDirective.injector.get(mock.TestDirective)).toBeTruthy(); + }); + + it('reuse stubbed directive', () => { + const actual = jest.requireActual( + './__fixtures__/test.directive', + ); + const mock1 = jest.createMockFromModule>( + './__fixtures__/test.directive', + ); + const mock2 = jest.createMockFromModule>( + './__fixtures__/test.directive', + ); + + const cache = new StubCache(); + + stubDirective(cache, actual.TestDirective, mock1.TestDirective); + stubDirective(cache, actual.TestDirective, mock2.TestDirective); + + expect(mock1.TestDirective.ɵdir).toBe(mock2.TestDirective.ɵdir); + }); + + it('stub directive with host directives', () => { + const actual = jest.requireActual( + './__fixtures__/test-with-host-directives.directive', + ); + const mock = jest.createMockFromModule( + './__fixtures__/test-with-host-directives.directive', + ); + const actualHostDirective = jest.requireActual( + './__fixtures__/test.directive', + ); + const mockHostDirective = jest.createMockFromModule( + './__fixtures__/test.directive', + ); + + const cache = new StubCache(); + + stubDirective(cache, actual.TestDirective, mock.TestDirective); + stubDirective(cache, actualHostDirective.TestDirective, mockHostDirective.TestDirective); + + @Component({ + template: '
', + imports: [mock.TestDirective], + standalone: true, + }) + class RootComponent {} + + const root = TestBed.createComponent(RootComponent); + + root.detectChanges(); + + const testDirective = root.debugElement.query(By.directive(mock.TestDirective)); + const testHostDirective = root.debugElement.query(By.directive(mockHostDirective.TestDirective)); + + expect(testDirective.injector.get(mock.TestDirective)).toBeTruthy(); + expect(testHostDirective.injector.get(mockHostDirective.TestDirective)).toBeTruthy(); + expect(testDirective.nativeElement).toBe(testHostDirective.nativeElement); + }); + + it('stub directive with host directives that not imported', () => { + const actual = jest.requireActual( + './__fixtures__/test-with-host-directives.directive', + ); + const mock = jest.createMockFromModule( + './__fixtures__/test-with-host-directives.directive', + ); + + const cache = new StubCache(); + + stubDirective(cache, actual.TestDirective, mock.TestDirective); + + @Component({ + template: '
', + imports: [mock.TestDirective], + standalone: true, + }) + class RootComponent {} + + const root = TestBed.createComponent(RootComponent); + + root.detectChanges(); + + const testDirective = root.debugElement.query(By.directive(mock.TestDirective)); + + expect(testDirective.injector.get(mock.TestDirective)).toBeTruthy(); + }); + + it('stub directive with signal inputs', () => { + const actual = jest.requireActual( + './__fixtures__/test-with-signal-inputs.directive', + ); + const mock = jest.createMockFromModule( + './__fixtures__/test-with-signal-inputs.directive', + ); + + const cache = new StubCache(); + + stubDirective(cache, actual.TestDirective, mock.TestDirective); + + @Component({ + template: '
', + imports: [mock.TestDirective], + standalone: true, + }) + class RootComponent {} + + jest.spyOn(console, 'error'); + + expect(() => { + TestBed.createComponent(RootComponent).detectChanges(); + }).not.toThrow(); + + expect(console.error).not.toHaveBeenCalled(); + }); + + it('stub directive with host directives that have inputs', () => { + const actual = jest.requireActual< + typeof import('./__fixtures__/test-with-host-directives-with-signal-inputs.directive') + >('./__fixtures__/test-with-host-directives-with-signal-inputs.directive'); + const mock = jest.createMockFromModule< + typeof import('./__fixtures__/test-with-host-directives-with-signal-inputs.directive') + >('./__fixtures__/test-with-host-directives-with-signal-inputs.directive'); + const actualHostDirective = jest.requireActual< + typeof import('./__fixtures__/test-with-signal-inputs.directive') + >('./__fixtures__/test-with-signal-inputs.directive'); + const mockHostDirective = jest.createMockFromModule< + typeof import('./__fixtures__/test-with-signal-inputs.directive') + >('./__fixtures__/test-with-signal-inputs.directive'); + + const cache = new StubCache(); + + stubDirective(cache, actual.TestDirective, mock.TestDirective); + stubDirective(cache, actualHostDirective.TestDirective, mockHostDirective.TestDirective); + + @Component({ + template: '
', + imports: [mock.TestDirective], + standalone: true, + }) + class RootComponent {} + + jest.spyOn(console, 'error'); + + expect(() => { + TestBed.createComponent(RootComponent).detectChanges(); + }).not.toThrow(); + + expect(console.error).not.toHaveBeenCalled(); + }); + + it('stub directive with host directives that have inputs and not imported', () => { + const actual = jest.requireActual< + typeof import('./__fixtures__/test-with-host-directives-with-signal-inputs.directive') + >('./__fixtures__/test-with-host-directives-with-signal-inputs.directive'); + const mock = jest.createMockFromModule< + typeof import('./__fixtures__/test-with-host-directives-with-signal-inputs.directive') + >('./__fixtures__/test-with-host-directives-with-signal-inputs.directive'); + + const cache = new StubCache(); + + stubDirective(cache, actual.TestDirective, mock.TestDirective); + + @Component({ + template: '
', + imports: [mock.TestDirective], + standalone: true, + }) + class RootComponent {} + + jest.spyOn(console, 'error'); + + expect(() => { + TestBed.createComponent(RootComponent).detectChanges(); + }).not.toThrow(); + + expect(console.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/automocks/stub-directive.ts b/src/automocks/stub-directive.ts new file mode 100644 index 0000000000..d2d88a0db4 --- /dev/null +++ b/src/automocks/stub-directive.ts @@ -0,0 +1,42 @@ +import { Type, ɵɵdefineDirective } from '@angular/core'; + +import type { StubCache } from './stub-cache'; +import { stubHostDirectives } from './stub-host-directives'; +import { stubInputsFactory } from './stub-inputs-factory'; +import { DirectiveType } from './types'; + +export function stubDirective(cache: StubCache, actual: Type, mock: Type): asserts mock is DirectiveType { + const { selectors, exportAs, standalone, signals, hostDirectives, inputs, inputConfig } = ( + actual as DirectiveType + ).ɵdir; + + cache.setMock(actual, mock); + + Object.defineProperty(mock, 'ɵdir', { + get: () => + cache.getOrCreateDirectiveDef(actual, () => { + const features: never[] = []; + + stubHostDirectives(cache, hostDirectives, features); + + return ɵɵdefineDirective({ + type: mock, + selectors, + inputs: inputConfig, + outputs: {}, + exportAs: exportAs ?? undefined, + standalone, + signals, + features, + }); + }), + }); + + let factory = () => new mock(); + + factory = stubInputsFactory(factory, inputs); + + Object.defineProperty(mock, 'ɵfac', { + value: factory, + }); +} diff --git a/src/automocks/stub-host-directives.ts b/src/automocks/stub-host-directives.ts new file mode 100644 index 0000000000..d77e153ab8 --- /dev/null +++ b/src/automocks/stub-host-directives.ts @@ -0,0 +1,35 @@ +import { Type, ɵComponentDef, ɵDirectiveDef, ɵɵHostDirectivesFeature } from '@angular/core'; +import { jest } from '@jest/globals'; + +import { StubCache } from './stub-cache'; +import { stubDirective } from './stub-directive'; + +export function stubHostDirectives( + cache: StubCache, + hostDirectives: (ɵDirectiveDef | ɵComponentDef)['hostDirectives'], + features: unknown[], +): void { + if (hostDirectives) { + features.push( + ɵɵHostDirectivesFeature( + hostDirectives.map((hostDirective) => { + if (typeof hostDirective === 'object') { + return { + directive: cache.getMock(hostDirective.directive, () => { + const mock = jest.fn(() => ({})) as unknown as Type; + + stubDirective(new StubCache(), hostDirective.directive, mock); + + return mock; + }), + inputs: Object.entries(hostDirective.inputs).flat(), + outputs: [], + }; + } else { + throw new Error('Unknown case'); + } + }), + ), + ); + } +} diff --git a/src/automocks/stub-injectable.spec.ts b/src/automocks/stub-injectable.spec.ts new file mode 100644 index 0000000000..ba8f138b5a --- /dev/null +++ b/src/automocks/stub-injectable.spec.ts @@ -0,0 +1,45 @@ +import { TestBed } from '@angular/core/testing'; + +import { StubCache } from './stub-cache'; +import { stubInjectable } from './stub-injectable'; + +describe('stubInjectable', () => { + it('should stub root service', () => { + const actual = jest.requireActual('./__fixtures__/root.service'); + const mock = + jest.createMockFromModule('./__fixtures__/root.service'); + + const cache = new StubCache(); + + stubInjectable(cache, actual.RootService, mock.RootService); + + const service = TestBed.inject(mock.RootService); + + expect(jest.isMockFunction(service.method)).toBeTruthy(); + }); + + it('should stub non root service', () => { + const actual = jest.requireActual( + './__fixtures__/non-root.service', + ); + const mock = jest.createMockFromModule( + './__fixtures__/non-root.service', + ); + + const cache = new StubCache(); + + stubInjectable(cache, actual.NonRootService, mock.NonRootService); + + expect(() => TestBed.inject(mock.NonRootService)).toThrow(); + + TestBed.resetTestingModule(); + + TestBed.configureTestingModule({ + providers: [mock.NonRootService], + }); + + const service = TestBed.inject(mock.NonRootService); + + expect(jest.isMockFunction(service.method)).toBeTruthy(); + }); +}); diff --git a/src/automocks/stub-injectable.ts b/src/automocks/stub-injectable.ts new file mode 100644 index 0000000000..9ae95a0fae --- /dev/null +++ b/src/automocks/stub-injectable.ts @@ -0,0 +1,23 @@ +import { Type, ɵɵdefineInjectable } from '@angular/core'; + +import { StubCache } from './stub-cache'; +import { InjectableType } from './types'; + +export function stubInjectable(cache: StubCache, actual: Type, mock: Type): asserts mock is InjectableType { + const { providedIn } = (actual as InjectableType).ɵprov; + + cache.setMock(actual, mock); + + Object.defineProperty(mock, 'ɵprov', { + get: () => + ɵɵdefineInjectable({ + token: mock, + providedIn, + factory: () => new mock(), + }), + }); + + Object.defineProperty(mock, 'ɵfac', { + value: () => new mock(), + }); +} diff --git a/src/automocks/stub-inputs-factory.ts b/src/automocks/stub-inputs-factory.ts new file mode 100644 index 0000000000..7b06c40915 --- /dev/null +++ b/src/automocks/stub-inputs-factory.ts @@ -0,0 +1,27 @@ +import { input } from '@angular/core'; + +import { InputFlags } from './types'; + +export function stubInputsFactory( + factory: () => T, + inputs: Record unknown) | null]>, +): () => T { + return () => { + const obj = factory(); + + for (const [propertyName, flags] of Object.values(inputs)) { + switch (flags) { + case InputFlags.SignalBased: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (obj as any)[propertyName] = input(); + + break; + } + default: + throw new Error('Unknown input flag'); + } + } + + return obj; + }; +} diff --git a/src/automocks/stub-module.spec.ts b/src/automocks/stub-module.spec.ts new file mode 100644 index 0000000000..9a088decd7 --- /dev/null +++ b/src/automocks/stub-module.spec.ts @@ -0,0 +1,89 @@ +import { Component, Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { StubCache } from './stub-cache'; +import { stubComponent } from './stub-component'; +import { stubDirective } from './stub-directive'; +import { stubModule } from './stub-module'; +import { stubPipe } from './stub-pipe'; + +describe('stubModule', () => { + it.each(['test-not-standalone', 'test'] as const)('stub simple module', (modulePath) => { + const actual = jest.requireActual<{ TestModule: Type }>(`./__fixtures__/${modulePath}.module`); + const mock = jest.createMockFromModule<{ TestModule: Type }>(`./__fixtures__/${modulePath}.module`); + const actualComponent = jest.requireActual<{ TestComponent: Type }>( + `./__fixtures__/${modulePath}.component`, + ); + const mockComponent = jest.createMockFromModule<{ TestComponent: Type }>( + `./__fixtures__/${modulePath}.component`, + ); + const actualDirective = jest.requireActual<{ TestDirective: Type }>( + `./__fixtures__/${modulePath}.directive`, + ); + const mockDirective = jest.createMockFromModule<{ TestDirective: Type }>( + `./__fixtures__/${modulePath}.directive`, + ); + const actualPipe = jest.requireActual<{ TestPipe: Type }>(`./__fixtures__/${modulePath}.pipe`); + const mockPipe = jest.createMockFromModule<{ TestPipe: Type }>(`./__fixtures__/${modulePath}.pipe`); + + const cache = new StubCache(); + + stubModule(cache, actual.TestModule, mock.TestModule); + stubComponent(cache, actualComponent.TestComponent, mockComponent.TestComponent); + stubDirective(cache, actualDirective.TestDirective, mockDirective.TestDirective); + stubPipe(cache, actualPipe.TestPipe, mockPipe.TestPipe); + + @Component({ + template: ` + +
{{ 'value' | test }}
+ `, + imports: [mock.TestModule], + standalone: true, + }) + class RootComponent {} + + jest.mocked(mockPipe.TestPipe).prototype.transform.mockImplementation((value: string) => `test ${value}`); + + const root = TestBed.createComponent(RootComponent); + + root.detectChanges(); + + const testComponentElement = root.debugElement.query(By.directive(mockComponent.TestComponent)); + const testDirectiveElement = root.debugElement.query(By.directive(mockDirective.TestDirective)); + + expect(testComponentElement).toBeTruthy(); + expect(testDirectiveElement).toBeTruthy(); + expect(testComponentElement.componentInstance).toBeInstanceOf(mockComponent.TestComponent); + expect(testDirectiveElement.nativeElement.innerHTML).toBe('test value'); + }); + + it('stub simple module with component that not imported', () => { + const actual = jest.requireActual( + './__fixtures__/test-not-standalone.module', + ); + const mock = jest.createMockFromModule( + './__fixtures__/test-not-standalone.module', + ); + + const cache = new StubCache(); + + stubModule(cache, actual.TestModule, mock.TestModule); + + @Component({ + template: '', + imports: [mock.TestModule], + standalone: true, + }) + class RootComponent {} + + jest.spyOn(console, 'error'); + + expect(() => { + TestBed.createComponent(RootComponent).detectChanges(); + }).not.toThrow(); + + expect(console.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/automocks/stub-module.ts b/src/automocks/stub-module.ts new file mode 100644 index 0000000000..13878f4fb0 --- /dev/null +++ b/src/automocks/stub-module.ts @@ -0,0 +1,50 @@ +import { Type, ɵNgModuleDef, ɵɵdefineNgModule } from '@angular/core'; + +import { stubAnything } from './stub-anything'; +import type { StubCache } from './stub-cache'; +import { stubConstructor } from './stub-constructor'; +import { ModuleType } from './types'; + +function assertNotFunction(value: T): asserts value is Exclude unknown> { + if (typeof value === 'function') { + throw new Error('Not implemented'); + } +} + +function stubArray>(cache: StubCache, actual: T[]): T[] { + return actual.map((itemActual) => + cache.getMock(itemActual, () => stubConstructor(cache, itemActual, stubAnything)), + ) as T[]; +} + +export function stubModule(cache: StubCache, actual: Type, mock: Type): asserts mock is ModuleType { + const { + imports: importsActual, + exports: exportsActual, + declarations: declarationsActual, + } = (actual as ModuleType).ɵmod; + + assertNotFunction(importsActual); + assertNotFunction(exportsActual); + assertNotFunction(declarationsActual); + + cache.setMock(actual, mock); + + Object.defineProperty(mock, 'ɵmod', { + get: () => + cache.getOrCreateModuleDef( + actual, + () => + ɵɵdefineNgModule({ + type: mock, + imports: stubArray(cache, importsActual), + declarations: stubArray(cache, declarationsActual), + exports: stubArray(cache, exportsActual), + }) as ɵNgModuleDef, + ), + }); + + Object.defineProperty(mock, 'ɵfac', { + value: () => new mock(), + }); +} diff --git a/src/automocks/stub-pipe.spec.ts b/src/automocks/stub-pipe.spec.ts new file mode 100644 index 0000000000..71e1b92a7d --- /dev/null +++ b/src/automocks/stub-pipe.spec.ts @@ -0,0 +1,35 @@ +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { StubCache } from './stub-cache'; +import { stubPipe } from './stub-pipe'; + +it('stubPipe', () => { + const actual = jest.requireActual('./__fixtures__/test.pipe'); + const mock = jest.createMockFromModule('./__fixtures__/test.pipe'); + + const cache = new StubCache(); + + stubPipe(cache, actual.TestPipe, mock.TestPipe); + + @Component({ + template: '{{value() | test}}', + imports: [mock.TestPipe], + }) + class RootComponent { + public readonly value = signal('value'); + } + + const root = TestBed.createComponent(RootComponent); + + root.detectChanges(); + + expect(root.debugElement.nativeElement.innerHTML).toBe(''); + + root.componentInstance.value.set('test'); + jest.mocked(mock.TestPipe.prototype.transform).mockImplementation(() => 'stub value'); + + root.detectChanges(); + + expect(root.debugElement.nativeElement.innerHTML).toBe('stub value'); +}); diff --git a/src/automocks/stub-pipe.ts b/src/automocks/stub-pipe.ts new file mode 100644 index 0000000000..8b6e1b7179 --- /dev/null +++ b/src/automocks/stub-pipe.ts @@ -0,0 +1,28 @@ +import { Type, ɵPipeDef, ɵɵdefinePipe } from '@angular/core'; + +import { StubCache } from './stub-cache'; +import { PipeType } from './types'; + +export function stubPipe(cache: StubCache, actual: Type, mock: Type): asserts mock is PipeType { + const { name, pure, standalone } = (actual as PipeType).ɵpipe; + + cache.setMock(actual, mock); + + Object.defineProperty(mock, 'ɵpipe', { + get: () => + cache.getOrCreatePipeDef( + actual, + () => + ɵɵdefinePipe({ + name, + type: mock, + pure, + standalone, + }) as ɵPipeDef, + ), + }); + + Object.defineProperty(mock, 'ɵfac', { + value: () => new mock(), + }); +} diff --git a/src/automocks/types.ts b/src/automocks/types.ts new file mode 100644 index 0000000000..ff66a27b0a --- /dev/null +++ b/src/automocks/types.ts @@ -0,0 +1,32 @@ +import { Type, ɵComponentDef, ɵDirectiveDef, ɵNgModuleDef, ɵPipeDef, ɵɵInjectableDeclaration } from '@angular/core'; + +export interface ComponentType extends Type { + ɵcmp: ɵComponentDef; + ɵfac: () => T; +} + +export interface DirectiveType extends Type { + ɵdir: ɵDirectiveDef; + ɵfac: () => T; +} + +export interface PipeType extends Type { + ɵpipe: ɵPipeDef; + ɵfac: () => T; +} + +export interface ModuleType extends Type { + ɵmod: ɵNgModuleDef; + ɵfac: () => T; +} + +export interface InjectableType extends Type { + ɵprov: ɵɵInjectableDeclaration; + ɵfac: () => T; +} + +export enum InputFlags { + None = 0, + SignalBased = 1, + HasDecoratorInputTransform = 2, +} diff --git a/website/docs/guides/automocks.md b/website/docs/guides/automocks.md new file mode 100644 index 0000000000..1a7bd62a0f --- /dev/null +++ b/website/docs/guides/automocks.md @@ -0,0 +1,287 @@ +--- +id: automatic-mocking +title: Automatic Mocks +--- + +:::important +**New in Jest Preset Angular v14.6+** – Automatic mocking for Angular components and classes (requires **Jest 30** or newer). This feature is **opt-in** and must be enabled in your Jest setup. +::: + +## Why Automatic Mocks? + +When testing Angular applications, you often need to isolate a component or service under test from its Angular dependencies. In the past, this meant manually creating stub components, using `NO_ERRORS_SCHEMA`, `TestBed.override...` functions, third party services, or creating mock entities manually. With **automatic mocking**, `jest-preset-angular` can generate **stubbed Angular components, directives, pipes, modules, and services** on the fly whenever you use Jest’s module mocking. This helps to: + +- **Simplify Test Setup:** Avoid writing boilerplate stubs for every child component or injected service, avoid using third-party libraries. Instead, let Jest auto-mock entire Angular modules or libraries, and the preset will replace Angular classes with safe stubs. +- **Prevent Injection Errors:** By default, Jest’s auto-mocks replace Angular factory functions (`ɵfac`) with `jest.fn()` returning `undefined`, causing injection to fail. The automatic mocking system fixes these by providing factories that return stub instances, so your components can inject services without errors. +- **Isolate External Dependencies:** You can mock out an entire Angular library with a single `jest.mock('some-angular-lib')` call. The preset will stub all components/directives/pipes from that library, so your tests don’t execute their templates or logic, improving test performance and focus. + +## Enabling Automatic Mocks + +To activate this feature, you need to register it in your Jest configuration. The preset provides a function `setupAutoMocks()` which uses Jest’s `jest.onGenerateMock()` hook internally. You should call this **before your tests run**, typically in a Jest setup file. Here’s how to configure it: + +### Setup + +In your project root, update a setup file with following contents: + +```ts title="setup-jest.ts" tab={"label":"Setup file CJS"} +import { setupAutoMocks } from 'jest-preset-angular/setup-env/automocks'; + +setupAutoMocks(); +``` + +```ts title="setup-jest.ts" tab={"label":"Setup file ESM"} +import { setupAutoMocks } from 'jest-preset-angular/setup-env/automocks.mjs'; + +setupAutoMocks(); +``` + +Update `setupFilesAfterEnv` in your Jest config as following: + +```ts title="jest.config.ts" +import type { Config } from 'jest'; +import { createCjsPreset } from 'jest-preset-angular/presets'; + +export default { + ...createCjsPreset(), + setupFilesAfterEnv: ['/setup-jest.ts'], +} satisfies Config; +``` + +## Using Automatic Mocks in Tests + +Once enabled, using the auto-mocking is straightforward. You trigger it by mocking the modules or components you want stubbed. This can be done via explicit `jest.mock` calls (or by setting `automock: true` in your Jest config, though explicit mocking is more common in Angular tests). + +Here are some usage examples: + +### Mocking Angular Service + +Suppose you have `SomeService` provided in `root` by an external library `external-lib`, and your component uses `inject(SomeService)`. In your test, do: + +```ts title="my.component.ts" tab={"label":"my.component.ts"} +import { Component, inject } from '@angular/core'; +import { SomeService } from 'external-lib'; + +@Component({ + selector: 'my', + template: ``, +}) +export class MyComponent { + private readonly someService = inject(SomeService); + + public onButtonClick(): void { + this.someService.doSomething(); + } +} +``` + +```ts title="my.component.spec.ts" tab={"label":"my.component.spec.ts"} +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { SomeService } from 'external-lib'; + +import { MyComponent } from './my.component'; + +jest.mock('external-lib'); + +describe('MyComponent', () => { + let fixture: ComponentFixture; + let someService: jest.Mocked; + + beforeEach(() => { + someService = jest.mocked(TestBed.inject(SomeService)); + + fixture = TestBed.createComponent(MyComponent); + fixture.detectChanges(); + }); + + describe('on button click', () => { + beforeEach(() => { + fixture.debugElement.query(By.css('button')).triggerEventHandler('click', null); + }); + + it('should call doSomething on SomeService', () => { + expect(someService.doSomething).toHaveBeenCalled(); + }); + }); +}); +``` + +Now `SomeService` is replaced by a stub class. Its Angular factory (`ɵfac`) is adjusted to return a new instance of the stub, so `inject(SomeService)` will get a valid object (not `undefined`). Any methods on the service are Jest mocks (no-op by default), which you can spy on or configure as needed. For example, `SomeService.prototype.myMethod` will be a `jest.fn` that you can `.mockReturnValue(...)` in your test. Your component can call `someService.doSomething()` without error, and you can verify the call via `expect(someService.myMethod).toHaveBeenCalled()`. + +### Mocking Angular Component + +Suppose you have `SomeService` provided in `root` by an external library `external-lib`, and your component uses `inject(SomeService)`. In your test, do: + +```ts title="my.component.ts" tab={"label":"my.component.ts"} +import { Component, input, signal } from '@angular/core'; +import { SomeComponent } from 'external-lib'; + +@Component({ + selector: 'my', + template: ` + @if (isVisible()) { + + } + `, + imports: [SomeComponent], +}) +export class MyComponent { + public readonly isVisible = input(false); + public readonly value = signal(''); + + public onHelloWorld(data: string): void { + console.log(data); + } +} +``` + +```ts title="my.component.spec.ts" tab={"label":"my.component.spec.ts"} +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { SomeComponent } from 'external-lib'; + +import { MyComponent } from './my.component'; + +jest.mock('external-lib'); + +describe('MyComponent', () => { + let fixture: ComponentFixture; + let component: MyComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(MyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('if isVisible is true', () => { + beforeEach(() => { + fixture.componentRef.setInput('isVisible', true); + fixture.detectChanges(); + }); + + it('should render SomeComponent', () => { + expect(fixture.debugElement.query(By.directive(SomeComponent))).toBeTruthy(); + }); + + describe('on helloWorld event', () => { + beforeEach(() => { + jest.spyOn(component, 'onHelloWorld'); + + fixture.debugElement.query(By.directive(SomeComponent)).triggerEventHandler('helloWorld', 'hello'); + }); + + it('should call onHelloWorld', () => { + expect(component.onHelloWorld).toHaveBeenCalledWith('hello'); + }); + }); + }); + + describe('if isVisible is false', () => { + beforeEach(() => { + fixture.componentRef.setInput('isVisible', false); + fixture.detectChanges(); + }); + + it('should not render SomeComponent', () => { + expect(fixture.debugElement.query(By.directive(SomeComponent))).toBeFalsy(); + }); + }); +}); +``` + +With `external-lib` mocked, `SomeComponent` is now a stub Component. These stub components have the same selector and input properties as the real ones, but their template is empty and their methods are no-ops. This means Angular’s rendering won’t error out – it recognizes the `` element via the stub – but none of the real component’s logic runs. In your test, you can still interact with `MyComponent` and even set inputs on the stubbed child component if needed. The stub child’s outputs are omitted, so you can emit values by `debugElement.triggerEventHandler('event', data)`. + +### Mocking Angular Pipe + +Suppose you have `SomeService` provided in `root` by an external library `external-lib`, and your component uses `inject(SomeService)`. In your test, do: + +```ts title="my.component.ts" tab={"label":"my.component.ts"} +import { Component, signal } from '@angular/core'; +import { SomePipe } from 'external-lib'; + +@Component({ + selector: 'my', + template: `

{{ value() | some }}

`, + imports: [SomePipe], +}) +export class MyComponent { + public readonly value = signal(''); +} +``` + +```ts title="my.component.spec.ts" tab={"label":"my.component.spec.ts"} +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SomePipe } from 'external-lib'; + +import { MyComponent } from './my.component'; + +jest.mock('external-lib'); + +describe('MyComponent', () => { + let fixture: ComponentFixture; + let component: MyComponent; + + beforeEach(() => { + jest.mocked(SomePipe).prototype.transform.mockReturnValue('transformed'); + + fixture = TestBed.createComponent(MyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should render transformed value', () => { + expect(fixture.nativeElement.textContent).toBe('transformed'); + }); + + describe('if value changes', () => { + beforeEach(() => { + component.value.set('new value'); + jest.mocked(SomePipe).prototype.transform.mockReturnValue('new transformed'); + fixture.detectChanges(); + }); + + it('should render new transformed value', () => { + expect(fixture.nativeElement.textContent).toBe('new transformed'); + }); + }); +}); +``` + +Similarly, if your component template uses a pipe or directive from an external module, auto-mocking that module will stub those as well. A stub pipe implements a `transform()` method that by default returns `undefined` and is a `jest.fn` – so your component’s template can call the pipe without error. Stub directives are inert; they won’t run any real logic but will satisfy Angular’s compiler so that `[directive]` attributes or structural directives (e.g. `*externalDir`) don’t cause unknown selector errors. + +These examples demonstrate how you can quickly stub out entire dependencies. You no longer need to specify schemas or declare dummy components for every child – a single `jest.mock()` does the job. The stubbed pieces will seamlessly integrate, letting you focus on testing the logic of your component or service under test. + +## How Does It Work? + +Under the hood, the automatic mocking system relies on several utilities to create minimal Angular-compatible substitutes: + +- **[Jest automatic mock](https://jestjs.io/docs/es6-class-mocks#automatic-mock)** – Jest’s built-in `jest.mock()` function creates a mock module that can be used in your tests. It replaces all statically accessible methods and classes of original module by `jest.fn()`. + +- **[Jest onGenerateMock hook](https://jestjs.io/blog/2025/06/04/jest-30#jestongeneratemockcallback)** – Jest 30 introduced the `jest.onGenerateMock(callback)` API, which allows transforming automatically generated mocks. `setupAutoMocks()` registers a callback that is invoked whenever Jest auto-mocks a module. The callback receives the module’s mock exports and scans through them to find any Angular Ivy entities. + +In summary, when you mock a module using Jest, the preset’s hook transforms the resulting module object by replacing each export with either the original Jest mock (for non-Angular entities) or a rich stub (for Angular entities). The outcome is that your test sees a module where Angular classes are present (so Angular can consume them) but they are all dummy implementations. This all happens behind the scenes when the module is first imported in the test. + +## Notes and Limitations + +Keep the following in mind when using the automatic mocking: + +- **Jest Version Requirement:** This feature only works with **Jest v30 or later**, since it uses the `jest.onGenerateMock` API introduced in Jest 30. Ensure you’ve upgraded Jest. If the hook is not supported, calling `setupAutoMocks()` will throw an error or have no effect. + +- **Opt-In Behavior:** Automatic mocking is _not_ enabled by default to avoid surprising behaviors in existing tests. You must call `setupAutoMocks()` (typically in your `setupFilesAfterEnv`). If you decide not to use it, Jest will continue to auto-mock modules in the normal way (and Angular internals might remain problematic without manual intervention). + +- **Only Affects Auto-Mocks:** The `onGenerateMock` hook only runs for **automatically generated mocks** – that is, when you use `jest.mock('moduleName')` with no manual factory, or if you have `automock: true` for all modules. It **does not** run for modules you manually mock with a factory or those with a custom `__mocks__` implementation. In those cases, you are responsible for providing any needed stubs. (You can still manually use the stub utilities if desired, but that’s an advanced use case.) + +- **Combining with Manual Spies:** The stubbed classes and instances are still Jest mocks under the hood. You can treat them like any mock – for example, use `.mockReturnValue` or `.mockImplementation` on stubbed methods to tailor their behavior. A stubbed pipe’s `transform` can be overridden to return specific values if your component logic depends on it. The automatic stub provides the structure, and you are free to customize the behavior in your test. + +- **No Real Logic Executed:** By design, **none** of the original component/directive/pipe logic runs. If your test inadvertently relies on some effect of an external component (e.g. a child component’s lifecycle setting up something, or a pipe computing a value), be aware that with the dependency stubbed, that effect won’t happen. Usually this is what you want in unit tests. If you do need the real behavior, you should not mock that particular module or component. + +- **Maintaining API:** The stub generation attempts to preserve the public API of components/directives so that binding to them in templates won’t throw errors. However, the **values** of those inputs are not used by any internal logic (since there is none). If a child stub component has an input that your component sets, it will accept the value, but it doesn’t do anything with it. Similarly, outputs won’t emit on their own. You can manually trigger an output if needed for your test scenario (via `debugElement.triggerEventHandler` method). + +- **Module Imports and Providers:** When stubbing an NgModule, the system will recursively stub its declarations (and possibly imported modules). + +- **Interaction with Other Mocking Libraries:** If you are using libraries like `ng-mocks` or others that provide their own Angular stubs, be cautious. The automatic Ivy mocks from `jest-preset-angular` may overlap in purpose. It’s recommended to use one approach consistently. If you enable `setupAutoMocks()`, you typically do not need to call `MockComponent`/`MockService` from other libraries for the same dependencies, as the preset will have already replaced them. + +- **Troubleshooting:** If a mock isn’t behaving as expected, you can inspect the stub. For example, logging `fixture.debugElement.query(By.directive(SomeChildComponent)).componentInstance.method`. If it is an instance of `jest.Mock`, this can help confirm that the auto-mocking occurred. We welcome issues or PRs if you find something that the automatic stubbing doesn’t cover. + +By leveraging automatic Ivy mocks, you can significantly streamline your Angular unit tests. Instead of spending time writing dummy implementations of components or worrying about Angular internals in your mocks, you let `jest-preset-angular` handle the heavy lifting. Focus on your test assertions, and enjoy fewer setup headaches when dealing with Angular entities! diff --git a/website/src/pages/index.tsx b/website/src/pages/index.tsx index 83d818d0c5..bb9fefb6f9 100644 --- a/website/src/pages/index.tsx +++ b/website/src/pages/index.tsx @@ -3,7 +3,6 @@ import { translate } from '@docusaurus/Translate'; import useBaseUrl from '@docusaurus/useBaseUrl'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import Layout from '@theme/Layout'; -// eslint-disable-next-line import/no-named-as-default import clsx from 'clsx'; import React from 'react';