From face36ccdc619c8e1faa18cec9a32eba5b4abadd Mon Sep 17 00:00:00 2001 From: omermorad Date: Wed, 11 Nov 2026 00:12:28 +0200 Subject: [PATCH 1/6] revert: defer unreleased v4 package additions --- .../jest/injection-js/composition.e2e.test.ts | 190 --------- .../injection-js/token-exposure.e2e.test.ts | 138 ------- e2e/esm/jest/nestjs/composition.e2e.test.ts | 190 --------- e2e/esm/jest/nestjs/getters.e2e.test.ts | 62 --- .../jest/nestjs/token-exposure.e2e.test.ts | 152 -------- .../injection-js/composition.e2e.test.ts | 190 --------- .../injection-js/token-exposure.e2e.test.ts | 138 ------- e2e/esm/vitest/nestjs/composition.e2e.test.ts | 190 --------- e2e/esm/vitest/nestjs/getters.e2e.test.ts | 62 --- .../vitest/nestjs/token-exposure.e2e.test.ts | 152 -------- e2e/jest/injection-js/composition.e2e.test.ts | 190 --------- .../injection-js/token-exposure.e2e.test.ts | 138 ------- e2e/jest/inversify/composition.e2e.test.ts | 190 --------- .../qualified-dependencies.e2e.test.ts | 89 ----- e2e/jest/inversify/token-exposure.e2e.test.ts | 183 --------- e2e/jest/nestjs/composition.e2e.test.ts | 190 --------- e2e/jest/nestjs/getters.e2e.test.ts | 62 --- e2e/jest/nestjs/legacy-compiler.ts | 36 -- e2e/jest/nestjs/package.json | 5 +- e2e/jest/nestjs/token-exposure.e2e.test.ts | 152 -------- e2e/jest/nestjs/tsconfig.legacy.json | 12 - .../injection-js/composition.e2e.test.ts | 190 --------- .../injection-js/token-exposure.e2e.test.ts | 138 ------- e2e/vitest/inversify/composition.e2e.test.ts | 190 --------- .../qualified-dependencies.e2e.test.ts | 89 ----- .../inversify/token-exposure.e2e.test.ts | 183 --------- e2e/vitest/nestjs/composition.e2e.test.ts | 190 --------- e2e/vitest/nestjs/getters.e2e.test.ts | 62 --- e2e/vitest/nestjs/token-exposure.e2e.test.ts | 152 -------- packages/core/CHANGELOG.md | 15 - .../assets/injectable-registry.fixture.ts | 8 +- .../sociable/assets/integration.assets.ts | 2 - ...ciable-testbed-builder.integration.test.ts | 4 - packages/core/package.json | 2 +- packages/core/src/adapter-provider.ts | 7 - packages/core/src/index.ts | 3 - .../builders/sociable-unit-builder.ts | 146 +------ .../builders/solitary-unit-builder.ts | 14 +- .../src/services/builders/testbed-builder.ts | 52 +-- .../core/src/services/composition.spec.ts | 359 ------------------ .../core/src/services/dependency-container.ts | 18 +- .../core/src/services/dependency-map.spec.ts | 58 --- packages/core/src/services/dependency-map.ts | 18 +- .../src/services/dependency-resolver.spec.ts | 143 ------- .../core/src/services/dependency-resolver.ts | 126 ++---- .../core/src/services/token-exposure.spec.ts | 278 -------------- .../core/src/services/unit-mocker.spec.ts | 47 --- packages/core/src/services/unit-mocker.ts | 36 +- .../core/src/services/unit-reference.spec.ts | 81 ---- packages/core/src/services/unit-reference.ts | 44 +-- packages/core/src/types.ts | 14 +- packages/doubles/jest/CHANGELOG.md | 11 - packages/doubles/jest/package.json | 4 +- packages/doubles/jest/src/mock.spec.ts | 157 -------- packages/doubles/jest/src/mock.static.ts | 7 +- packages/doubles/jest/unit.d.ts | 6 +- packages/doubles/vitest/CHANGELOG.md | 11 - packages/doubles/vitest/package.json | 4 +- packages/doubles/vitest/src/mock.spec.ts | 157 -------- packages/doubles/vitest/src/mock.static.ts | 7 +- packages/doubles/vitest/unit.d.ts | 6 +- packages/unit/CHANGELOG.md | 9 - packages/unit/README.md | 57 --- packages/unit/package.json | 4 +- packages/unit/src/index.ts | 1 - packages/unit/src/testbed.ts | 8 +- packages/unit/src/types.ts | 63 +-- pnpm-lock.yaml | 6 +- 68 files changed, 138 insertions(+), 5760 deletions(-) delete mode 100644 e2e/esm/jest/injection-js/composition.e2e.test.ts delete mode 100644 e2e/esm/jest/injection-js/token-exposure.e2e.test.ts delete mode 100644 e2e/esm/jest/nestjs/composition.e2e.test.ts delete mode 100644 e2e/esm/jest/nestjs/getters.e2e.test.ts delete mode 100644 e2e/esm/jest/nestjs/token-exposure.e2e.test.ts delete mode 100644 e2e/esm/vitest/injection-js/composition.e2e.test.ts delete mode 100644 e2e/esm/vitest/injection-js/token-exposure.e2e.test.ts delete mode 100644 e2e/esm/vitest/nestjs/composition.e2e.test.ts delete mode 100644 e2e/esm/vitest/nestjs/getters.e2e.test.ts delete mode 100644 e2e/esm/vitest/nestjs/token-exposure.e2e.test.ts delete mode 100644 e2e/jest/injection-js/composition.e2e.test.ts delete mode 100644 e2e/jest/injection-js/token-exposure.e2e.test.ts delete mode 100644 e2e/jest/inversify/composition.e2e.test.ts delete mode 100644 e2e/jest/inversify/qualified-dependencies.e2e.test.ts delete mode 100644 e2e/jest/inversify/token-exposure.e2e.test.ts delete mode 100644 e2e/jest/nestjs/composition.e2e.test.ts delete mode 100644 e2e/jest/nestjs/getters.e2e.test.ts delete mode 100644 e2e/jest/nestjs/legacy-compiler.ts delete mode 100644 e2e/jest/nestjs/token-exposure.e2e.test.ts delete mode 100644 e2e/jest/nestjs/tsconfig.legacy.json delete mode 100644 e2e/vitest/injection-js/composition.e2e.test.ts delete mode 100644 e2e/vitest/injection-js/token-exposure.e2e.test.ts delete mode 100644 e2e/vitest/inversify/composition.e2e.test.ts delete mode 100644 e2e/vitest/inversify/qualified-dependencies.e2e.test.ts delete mode 100644 e2e/vitest/inversify/token-exposure.e2e.test.ts delete mode 100644 e2e/vitest/nestjs/composition.e2e.test.ts delete mode 100644 e2e/vitest/nestjs/getters.e2e.test.ts delete mode 100644 e2e/vitest/nestjs/token-exposure.e2e.test.ts delete mode 100644 packages/core/src/adapter-provider.ts delete mode 100644 packages/core/src/services/composition.spec.ts delete mode 100644 packages/core/src/services/dependency-map.spec.ts delete mode 100644 packages/core/src/services/dependency-resolver.spec.ts delete mode 100644 packages/core/src/services/token-exposure.spec.ts diff --git a/e2e/esm/jest/injection-js/composition.e2e.test.ts b/e2e/esm/jest/injection-js/composition.e2e.test.ts deleted file mode 100644 index bb8bd845..00000000 --- a/e2e/esm/jest/injection-js/composition.e2e.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import 'reflect-metadata'; -import { describe, it, expect } from '@jest/globals'; -import { Injectable } from 'injection-js'; -import { TestBed } from '@suites/unit'; - -abstract class Host { - abstract region(): string; -} -@Injectable() -class Leaf { - prefix() { - return 'cache:'; - } -} -@Injectable() -class Repository { - constructor( - readonly host: Host, - readonly leaf: Leaf - ) {} - read() { - return this.leaf.prefix() + this.host.region(); - } -} -@Injectable() -class Service { - constructor(readonly repository: Repository) {} - read() { - return this.repository.read(); - } -} - -describe('opt-in composition', () => { - it('constructs the transitive graph and mocks excluded abstract tokens', async () => { - const { unit, unitRef } = await TestBed.sociable(Service).compose().exclude(Host).compile(); - const host = unitRef.get(Host); - host.region.mockReturnValue('singapore'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).toBeInstanceOf(Leaf); - expect(unit.read()).toBe('cache:singapore'); - expect(() => unitRef.get(Repository)).toThrow(); - expect(() => unitRef.get(Leaf)).toThrow(); - const invalid = () => { - // @ts-expect-error Composition preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Mock inference remains specific after composition. - host.region.mockReturnValue(42); - // @ts-expect-error Boundaries require at least one identifier. - TestBed.sociable(Service).compose().exclude(); - // @ts-expect-error Pass tokens as separate arguments or spread a tuple. - TestBed.sociable(Service).compose().exclude([Host, Leaf]); - // @ts-expect-error Exclusions are available only in composition mode. - TestBed.sociable(Service).exclude(Host); - // @ts-expect-error Selective exposure stays concrete-only. - TestBed.sociable(Service).expose(Host); - // @ts-expect-error Composition and selective exposure are separate strategies. - TestBed.sociable(Service).compose().expose(Repository); - }; - expect(invalid).toBeDefined(); - }); - - it('mocks every variadic boundary and accepts readonly tuples', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .exclude(Host, Leaf) - .compile(); - unitRef.get(Host).region.mockReturnValue('singapore'); - unitRef.get(Leaf).prefix.mockReturnValue('mock:'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).not.toBeInstanceOf(Leaf); - expect(unit.read()).toBe('mock:singapore'); - - const boundaries = [Host, Leaf] as const; - const { unit: spread, unitRef: spreadRef } = await TestBed.sociable(Service) - .compose() - .exclude(...boundaries) - .compile(); - spreadRef.get(Host).region.mockReturnValue('tokyo'); - spreadRef.get(Leaf).prefix.mockReturnValue('spread:'); - expect(spread.read()).toBe('spread:tokyo'); - }); - - it('accumulates distinct boundaries across consecutive exclusions', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .exclude(Host) - .exclude(Leaf) - .compile(); - const host = unitRef.get(Host); - const leaf = unitRef.get(Leaf); - host.region.mockReturnValue('tokyo'); - leaf.prefix.mockReturnValue('mock:'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.host).toBe(host); - expect(unit.repository.leaf).toBe(leaf); - expect(unit.read()).toBe('mock:tokyo'); - expect(host.region).toHaveBeenCalledTimes(1); - expect(leaf.prefix).toHaveBeenCalledTimes(1); - }); - - it('uses explicit automatic mocks as composition boundaries', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .mock(Leaf) - .auto() - .exclude(Host) - .compile(); - const leaf = unitRef.get(Leaf); - expect(leaf.prefix()).toBeUndefined(); - leaf.prefix.mockReturnValue('auto:'); - unitRef.get(Host).region.mockReturnValue('tokyo'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).not.toBeInstanceOf(Leaf); - expect(unit.read()).toBe('auto:tokyo'); - const invalid = () => { - // @ts-expect-error Automatic mock completion preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Automatic mocks preserve method return types. - leaf.prefix.mockReturnValue(42); - // @ts-expect-error A mock selector requires auto, impl, or final. - TestBed.solitary(Service).mock(Repository).compile(); - }; - expect(invalid).toBeDefined(); - }); - - it('preserves selective and solitary chains after automatic mock declarations', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .expose(Leaf) - .mock(Host) - .auto() - .expose(Repository) - .compile(); - unitRef.get(Host).region.mockReturnValue('singapore'); - expect(unit.repository.leaf).toBeInstanceOf(Leaf); - expect(unit.read()).toBe('cache:singapore'); - - const { unit: isolated, unitRef: isolatedRef } = await TestBed.solitary(Service) - .mock(Repository) - .auto() - .compile(); - isolatedRef.get(Repository).read.mockReturnValue('isolated'); - expect(isolated.read()).toBe('isolated'); - }); - - it('preserves the subject type through implementation overrides', async () => { - const { unit } = await TestBed.sociable(Service) - .compose() - .mock(Host) - .impl((stub) => ({ region: stub().mockReturnValue('tokyo') })) - .compile(); - expect(unit.read()).toBe('cache:tokyo'); - const invalid = () => { - // @ts-expect-error Implementation overrides preserve the subject type. - unit.nonexistentMethod(); - }; - expect(invalid).toBeDefined(); - }); - - it('preserves mode methods through typed implementation and final overrides', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .mock(Host) - .impl((stub) => ({ region: stub().mockReturnValue('tokyo') })) - .exclude(Host) - .mock(Leaf) - .final({ prefix: () => 'fake:' }) - .exclude(Host) - .compile(); - const invalid = () => { - // @ts-expect-error Final overrides preserve the subject type. - unit.nonexistentMethod(); - }; - expect(invalid).toBeDefined(); - expect(unit.read()).toBe('fake:tokyo'); - expect(unitRef.get(Host).region).toHaveBeenCalledTimes(1); - expect(() => unitRef.get(Leaf)).toThrow(); - }); - - it('preserves existing V4 selective mock chains and implicit mocking', async () => { - const { unit } = await TestBed.sociable(Service) - .expose(Repository) - .mock(Repository) - .final({ read: () => 'legacy' }) - .compile(); - expect(unit.read()).toBe('legacy'); - const { unit: partial } = await TestBed.sociable(Service).expose(Repository).compile(); - expect(partial.repository).toBeInstanceOf(Repository); - expect(partial.repository.leaf).not.toBeInstanceOf(Leaf); - }); -}); diff --git a/e2e/esm/jest/injection-js/token-exposure.e2e.test.ts b/e2e/esm/jest/injection-js/token-exposure.e2e.test.ts deleted file mode 100644 index 439751a9..00000000 --- a/e2e/esm/jest/injection-js/token-exposure.e2e.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import 'reflect-metadata'; -import { describe, it, expect } from '@jest/globals'; -import { Injectable as injectable, Inject as inject } from 'injection-js'; -import { TestBed } from '@suites/unit'; - -const PRIMARY = Symbol('provider'); -const SECONDARY = 'provider'; -abstract class Port { - abstract region(): string; -} -@injectable() -class Cache { - read(): string { - return 'real'; - } -} -@injectable() -class Provider extends Port { - constructor(@inject(Cache) readonly cache: Cache) { - super(); - } - region(): string { - return this.cache.read(); - } -} -@injectable() -class Root { - constructor( - @inject(PRIMARY) readonly primary: Port, - @inject(PRIMARY) readonly repeated: Port, - @inject(SECONDARY) readonly secondary: Port, - @inject(Provider) readonly direct: Provider, - @inject(Port) readonly abstract: Port - ) {} -} -@injectable() -class Single { - constructor(@inject(PRIMARY) readonly provider: Port) {} -} - -describe('token exposure', () => { - it('constructs string, symbol and abstract bindings without aliasing token identities', async () => { - const { unit, unitRef } = await TestBed.sociable(Root) - .expose(PRIMARY, Provider) - .expose(SECONDARY, Provider) - .expose(Provider) - .expose(Port, Provider) - .mock(Cache) - .impl((stub) => ({ read: stub().mockReturnValue('configured') })) - .compile(); - expect(unit.primary).toBeInstanceOf(Provider); - expect(unit.primary).toBe(unit.repeated); - expect(unit.primary).not.toBe(unit.secondary); - expect(unit.primary).not.toBe(unit.direct); - expect(unit.primary).not.toBe(unit.abstract); - expect(unit.secondary).toBeInstanceOf(Provider); - expect(unit.abstract).toBeInstanceOf(Provider); - expect(unit.primary.region()).toBe('configured'); - expect(unit.secondary.region()).toBe('configured'); - expect(unit.abstract.region()).toBe('configured'); - expect(unit.direct.cache).toBe(unitRef.get(Cache)); - expect(() => unitRef.get(PRIMARY)).toThrow(/exposed dependency/); - expect(() => unitRef.get(SECONDARY)).toThrow(/exposed dependency/); - expect(() => unitRef.get(Port)).toThrow(/exposed dependency/); - const checkTypes = () => { - // @ts-expect-error Exposure preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Tokens require a concrete implementation. - TestBed.sociable(Single).expose(PRIMARY); - // @ts-expect-error Abstract classes cannot be constructed as implementations. - TestBed.sociable(Single).expose(PRIMARY, Port); - // @ts-expect-error Implementations must satisfy the abstract token's instance type. - TestBed.sociable(Root).expose(Port, Cache); - // @ts-expect-error Implementations must satisfy concrete token instance types too. - TestBed.sociable(Root).expose(Provider, Cache); - // @ts-expect-error Token exposure remains selective, not a composition operation. - TestBed.sociable(Single).compose().expose(PRIMARY, Provider); - }; - expect(checkTypes).toBeDefined(); - }); - - it('keeps normal v4 auto-mocking below the exposed implementation', async () => { - const { unit, unitRef } = await TestBed.sociable(Single).expose(PRIMARY, Provider).compile(); - expect(unit.provider).toBeInstanceOf(Provider); - expect(unit.provider.region()).toBeUndefined(); - unitRef.get(Cache).read.mockReturnValue('configured later'); - expect(unit.provider.region()).toBe('configured later'); - }); - - it('lets automatic, implementation and final overrides replace exposed tokens', async () => { - const automatic = await TestBed.sociable(Single) - .expose(PRIMARY, Provider) - .mock(PRIMARY) - .auto() - .compile(); - automatic.unitRef.get(PRIMARY).region.mockReturnValue('automatic'); - expect(automatic.unit.provider).toBe(automatic.unitRef.get(PRIMARY)); - expect(automatic.unit.provider.region()).toBe('automatic'); - expect(automatic.unit.provider).not.toBeInstanceOf(Provider); - - const implementation = await TestBed.sociable(Single) - .expose(Cache) - .mock(PRIMARY) - .impl((stub) => ({ region: stub().mockReturnValue('implementation') })) - .expose(PRIMARY, Provider) - .compile(); - expect(implementation.unit.provider).toBe(implementation.unitRef.get(PRIMARY)); - expect(implementation.unit.provider.region()).toBe('implementation'); - - const supplied = { region: () => 'final' }; - const final = await TestBed.sociable(Single) - .expose(PRIMARY, Provider) - .mock(PRIMARY) - .final(supplied) - .compile(); - expect(final.unit.provider).toBe(supplied); - expect(() => final.unitRef.get(PRIMARY)).toThrow(/faked dependency/); - }); - - it('resolves nested token bindings', async () => { - const INNER = Symbol('inner'); - @injectable() - class Nested extends Port { - constructor(@inject(INNER) readonly cache: Cache) { - super(); - } - region(): string { - return this.cache.read(); - } - } - const { unit } = await TestBed.sociable(Single) - .expose(PRIMARY, Nested) - .expose(INNER, Cache) - .compile(); - expect(unit.provider).toBeInstanceOf(Nested); - expect(unit.provider.region()).toBe('real'); - }); -}); diff --git a/e2e/esm/jest/nestjs/composition.e2e.test.ts b/e2e/esm/jest/nestjs/composition.e2e.test.ts deleted file mode 100644 index 1f393938..00000000 --- a/e2e/esm/jest/nestjs/composition.e2e.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import 'reflect-metadata'; -import { describe, it, expect } from '@jest/globals'; -import { Injectable } from '@nestjs/common'; -import { TestBed } from '@suites/unit'; - -abstract class Host { - abstract region(): string; -} -@Injectable() -class Leaf { - prefix() { - return 'cache:'; - } -} -@Injectable() -class Repository { - constructor( - readonly host: Host, - readonly leaf: Leaf - ) {} - read() { - return this.leaf.prefix() + this.host.region(); - } -} -@Injectable() -class Service { - constructor(readonly repository: Repository) {} - read() { - return this.repository.read(); - } -} - -describe('opt-in composition', () => { - it('constructs the transitive graph and mocks excluded abstract tokens', async () => { - const { unit, unitRef } = await TestBed.sociable(Service).compose().exclude(Host).compile(); - const host = unitRef.get(Host); - host.region.mockReturnValue('singapore'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).toBeInstanceOf(Leaf); - expect(unit.read()).toBe('cache:singapore'); - expect(() => unitRef.get(Repository)).toThrow(); - expect(() => unitRef.get(Leaf)).toThrow(); - const invalid = () => { - // @ts-expect-error Composition preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Mock inference remains specific after composition. - host.region.mockReturnValue(42); - // @ts-expect-error Boundaries require at least one identifier. - TestBed.sociable(Service).compose().exclude(); - // @ts-expect-error Pass tokens as separate arguments or spread a tuple. - TestBed.sociable(Service).compose().exclude([Host, Leaf]); - // @ts-expect-error Exclusions are available only in composition mode. - TestBed.sociable(Service).exclude(Host); - // @ts-expect-error Selective exposure stays concrete-only. - TestBed.sociable(Service).expose(Host); - // @ts-expect-error Composition and selective exposure are separate strategies. - TestBed.sociable(Service).compose().expose(Repository); - }; - expect(invalid).toBeDefined(); - }); - - it('mocks every variadic boundary and accepts readonly tuples', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .exclude(Host, Leaf) - .compile(); - unitRef.get(Host).region.mockReturnValue('singapore'); - unitRef.get(Leaf).prefix.mockReturnValue('mock:'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).not.toBeInstanceOf(Leaf); - expect(unit.read()).toBe('mock:singapore'); - - const boundaries = [Host, Leaf] as const; - const { unit: spread, unitRef: spreadRef } = await TestBed.sociable(Service) - .compose() - .exclude(...boundaries) - .compile(); - spreadRef.get(Host).region.mockReturnValue('tokyo'); - spreadRef.get(Leaf).prefix.mockReturnValue('spread:'); - expect(spread.read()).toBe('spread:tokyo'); - }); - - it('accumulates distinct boundaries across consecutive exclusions', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .exclude(Host) - .exclude(Leaf) - .compile(); - const host = unitRef.get(Host); - const leaf = unitRef.get(Leaf); - host.region.mockReturnValue('tokyo'); - leaf.prefix.mockReturnValue('mock:'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.host).toBe(host); - expect(unit.repository.leaf).toBe(leaf); - expect(unit.read()).toBe('mock:tokyo'); - expect(host.region).toHaveBeenCalledTimes(1); - expect(leaf.prefix).toHaveBeenCalledTimes(1); - }); - - it('uses explicit automatic mocks as composition boundaries', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .mock(Leaf) - .auto() - .exclude(Host) - .compile(); - const leaf = unitRef.get(Leaf); - expect(leaf.prefix()).toBeUndefined(); - leaf.prefix.mockReturnValue('auto:'); - unitRef.get(Host).region.mockReturnValue('tokyo'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).not.toBeInstanceOf(Leaf); - expect(unit.read()).toBe('auto:tokyo'); - const invalid = () => { - // @ts-expect-error Automatic mock completion preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Automatic mocks preserve method return types. - leaf.prefix.mockReturnValue(42); - // @ts-expect-error A mock selector requires auto, impl, or final. - TestBed.solitary(Service).mock(Repository).compile(); - }; - expect(invalid).toBeDefined(); - }); - - it('preserves selective and solitary chains after automatic mock declarations', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .expose(Leaf) - .mock(Host) - .auto() - .expose(Repository) - .compile(); - unitRef.get(Host).region.mockReturnValue('singapore'); - expect(unit.repository.leaf).toBeInstanceOf(Leaf); - expect(unit.read()).toBe('cache:singapore'); - - const { unit: isolated, unitRef: isolatedRef } = await TestBed.solitary(Service) - .mock(Repository) - .auto() - .compile(); - isolatedRef.get(Repository).read.mockReturnValue('isolated'); - expect(isolated.read()).toBe('isolated'); - }); - - it('preserves the subject type through implementation overrides', async () => { - const { unit } = await TestBed.sociable(Service) - .compose() - .mock(Host) - .impl((stub) => ({ region: stub().mockReturnValue('tokyo') })) - .compile(); - expect(unit.read()).toBe('cache:tokyo'); - const invalid = () => { - // @ts-expect-error Implementation overrides preserve the subject type. - unit.nonexistentMethod(); - }; - expect(invalid).toBeDefined(); - }); - - it('preserves mode methods through typed implementation and final overrides', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .mock(Host) - .impl((stub) => ({ region: stub().mockReturnValue('tokyo') })) - .exclude(Host) - .mock(Leaf) - .final({ prefix: () => 'fake:' }) - .exclude(Host) - .compile(); - const invalid = () => { - // @ts-expect-error Final overrides preserve the subject type. - unit.nonexistentMethod(); - }; - expect(invalid).toBeDefined(); - expect(unit.read()).toBe('fake:tokyo'); - expect(unitRef.get(Host).region).toHaveBeenCalledTimes(1); - expect(() => unitRef.get(Leaf)).toThrow(); - }); - - it('preserves existing V4 selective mock chains and implicit mocking', async () => { - const { unit } = await TestBed.sociable(Service) - .expose(Repository) - .mock(Repository) - .final({ read: () => 'legacy' }) - .compile(); - expect(unit.read()).toBe('legacy'); - const { unit: partial } = await TestBed.sociable(Service).expose(Repository).compile(); - expect(partial.repository).toBeInstanceOf(Repository); - expect(partial.repository.leaf).not.toBeInstanceOf(Leaf); - }); -}); diff --git a/e2e/esm/jest/nestjs/getters.e2e.test.ts b/e2e/esm/jest/nestjs/getters.e2e.test.ts deleted file mode 100644 index d6f78f06..00000000 --- a/e2e/esm/jest/nestjs/getters.e2e.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { jest } from '@jest/globals'; -import 'reflect-metadata'; -import { Injectable } from '@nestjs/common'; -import { TestBed } from '@suites/unit'; -import { mock } from '@suites/doubles.jest'; - -@Injectable() -class ConfigService { - get isCli(): boolean { - throw new Error('The real getter must not run'); - } -} - -@Injectable() -class Application { - constructor(public readonly config: ConfigService) {} - - isCli(): boolean { - return this.config.isCli; - } -} - -describe('Supplied getter implementations', () => { - it('configures a readonly getter through TestBed without assignment', async () => { - let reads = 0; - const { unit, unitRef } = await TestBed.solitary(Application) - .mock(ConfigService) - .impl(() => ({ - get isCli() { - reads += 1; - return false; - }, - })) - .compile(); - - expect(reads).toBe(0); - expect(unit.isCli()).toBe(false); - expect(reads).toBe(1); - const config = unitRef.get(ConfigService); - jest.spyOn(config, 'isCli', 'get').mockReturnValue(true); - expect(unit.isCli()).toBe(true); - }); - - it('supports a standalone configurable getter and a plain false value', () => { - const config = mock({ - get isCli() { - return false; - }, - }); - expect(config.isCli).toBe(false); - jest.spyOn(config, 'isCli', 'get').mockReturnValue(true); - expect(config.isCli).toBe(true); - expect(mock({ isCli: false }).isCli).toBe(false); - }); - - it('retains lazy defaults and allows defining a value on an existing mock', () => { - const config = mock(); - expect(config.isCli).toBeTruthy(); - Object.defineProperty(config, 'isCli', { value: false }); - expect(config.isCli).toBe(false); - }); -}); diff --git a/e2e/esm/jest/nestjs/token-exposure.e2e.test.ts b/e2e/esm/jest/nestjs/token-exposure.e2e.test.ts deleted file mode 100644 index b2b203a4..00000000 --- a/e2e/esm/jest/nestjs/token-exposure.e2e.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import 'reflect-metadata'; -import { describe, it, expect } from '@jest/globals'; -import { Injectable as injectable, Inject as inject } from '@nestjs/common'; -import { TestBed } from '@suites/unit'; - -const PRIMARY = Symbol('provider'); -const SECONDARY = 'provider'; -abstract class Port { - abstract region(): string; -} -@injectable() -class Cache { - read(): string { - return 'real'; - } -} -@injectable() -class Provider extends Port { - constructor(@inject(Cache) readonly cache: Cache) { - super(); - } - region(): string { - return this.cache.read(); - } -} -@injectable() -class Root { - constructor( - @inject(PRIMARY) readonly primary: Port, - @inject(PRIMARY) readonly repeated: Port, - @inject(SECONDARY) readonly secondary: Port, - @inject(Provider) readonly direct: Provider, - @inject(Port) readonly abstract: Port - ) {} -} -@injectable() -class Single { - constructor(@inject(PRIMARY) readonly provider: Port) {} -} - -describe('token exposure', () => { - it('constructs string, symbol and abstract bindings without aliasing token identities', async () => { - const { unit, unitRef } = await TestBed.sociable(Root) - .expose(PRIMARY, Provider) - .expose(SECONDARY, Provider) - .expose(Provider) - .expose(Port, Provider) - .mock(Cache) - .impl((stub) => ({ read: stub().mockReturnValue('configured') })) - .compile(); - expect(unit.primary).toBeInstanceOf(Provider); - expect(unit.primary).toBe(unit.repeated); - expect(unit.primary).not.toBe(unit.secondary); - expect(unit.primary).not.toBe(unit.direct); - expect(unit.primary).not.toBe(unit.abstract); - expect(unit.secondary).toBeInstanceOf(Provider); - expect(unit.abstract).toBeInstanceOf(Provider); - expect(unit.primary.region()).toBe('configured'); - expect(unit.secondary.region()).toBe('configured'); - expect(unit.abstract.region()).toBe('configured'); - expect(unit.direct.cache).toBe(unitRef.get(Cache)); - expect(() => unitRef.get(PRIMARY)).toThrow(/exposed dependency/); - expect(() => unitRef.get(SECONDARY)).toThrow(/exposed dependency/); - expect(() => unitRef.get(Port)).toThrow(/exposed dependency/); - const checkTypes = () => { - // @ts-expect-error Exposure preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Tokens require a concrete implementation. - TestBed.sociable(Single).expose(PRIMARY); - // @ts-expect-error Abstract classes cannot be constructed as implementations. - TestBed.sociable(Single).expose(PRIMARY, Port); - // @ts-expect-error Implementations must satisfy the abstract token's instance type. - TestBed.sociable(Root).expose(Port, Cache); - // @ts-expect-error Implementations must satisfy concrete token instance types too. - TestBed.sociable(Root).expose(Provider, Cache); - // @ts-expect-error Token exposure remains selective, not a composition operation. - TestBed.sociable(Single).compose().expose(PRIMARY, Provider); - }; - expect(checkTypes).toBeDefined(); - }); - - it('keeps normal v4 auto-mocking below the exposed implementation', async () => { - const { unit, unitRef } = await TestBed.sociable(Single).expose(PRIMARY, Provider).compile(); - expect(unit.provider).toBeInstanceOf(Provider); - expect(unit.provider.region()).toBeUndefined(); - unitRef.get(Cache).read.mockReturnValue('configured later'); - expect(unit.provider.region()).toBe('configured later'); - }); - - it('lets automatic, implementation and final overrides replace exposed tokens', async () => { - const automatic = await TestBed.sociable(Single) - .expose(PRIMARY, Provider) - .mock(PRIMARY) - .auto() - .compile(); - automatic.unitRef.get(PRIMARY).region.mockReturnValue('automatic'); - expect(automatic.unit.provider).toBe(automatic.unitRef.get(PRIMARY)); - expect(automatic.unit.provider.region()).toBe('automatic'); - expect(automatic.unit.provider).not.toBeInstanceOf(Provider); - - const implementation = await TestBed.sociable(Single) - .expose(Cache) - .mock(PRIMARY) - .impl((stub) => ({ region: stub().mockReturnValue('implementation') })) - .expose(PRIMARY, Provider) - .compile(); - expect(implementation.unit.provider).toBe(implementation.unitRef.get(PRIMARY)); - expect(implementation.unit.provider.region()).toBe('implementation'); - - const supplied = { region: () => 'final' }; - const final = await TestBed.sociable(Single) - .expose(PRIMARY, Provider) - .mock(PRIMARY) - .final(supplied) - .compile(); - expect(final.unit.provider).toBe(supplied); - expect(() => final.unitRef.get(PRIMARY)).toThrow(/faked dependency/); - }); - - it('resolves nested token bindings', async () => { - const INNER = Symbol('inner'); - @injectable() - class Nested extends Port { - constructor(@inject(INNER) readonly cache: Cache) { - super(); - } - region(): string { - return this.cache.read(); - } - } - const { unit } = await TestBed.sociable(Single) - .expose(PRIMARY, Nested) - .expose(INNER, Cache) - .compile(); - expect(unit.provider).toBeInstanceOf(Nested); - expect(unit.provider.region()).toBe('real'); - }); -}); - -@injectable() -class PropertyRoot { - @inject(PRIMARY) readonly provider!: Port; -} -it('injects a real token implementation into a property', async () => { - const { unit, unitRef } = await TestBed.sociable(PropertyRoot) - .expose(PRIMARY, Provider) - .expose(Cache) - .compile(); - expect(unit.provider).toBeInstanceOf(Provider); - expect(unit.provider.region()).toBe('real'); - expect(() => unitRef.get(PRIMARY)).toThrow(/exposed dependency/); -}); diff --git a/e2e/esm/vitest/injection-js/composition.e2e.test.ts b/e2e/esm/vitest/injection-js/composition.e2e.test.ts deleted file mode 100644 index d1c383ae..00000000 --- a/e2e/esm/vitest/injection-js/composition.e2e.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import 'reflect-metadata'; -import { describe, it, expect } from 'vitest'; -import { Injectable } from 'injection-js'; -import { TestBed } from '@suites/unit'; - -abstract class Host { - abstract region(): string; -} -@Injectable() -class Leaf { - prefix() { - return 'cache:'; - } -} -@Injectable() -class Repository { - constructor( - readonly host: Host, - readonly leaf: Leaf - ) {} - read() { - return this.leaf.prefix() + this.host.region(); - } -} -@Injectable() -class Service { - constructor(readonly repository: Repository) {} - read() { - return this.repository.read(); - } -} - -describe('opt-in composition', () => { - it('constructs the transitive graph and mocks excluded abstract tokens', async () => { - const { unit, unitRef } = await TestBed.sociable(Service).compose().exclude(Host).compile(); - const host = unitRef.get(Host); - host.region.mockReturnValue('singapore'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).toBeInstanceOf(Leaf); - expect(unit.read()).toBe('cache:singapore'); - expect(() => unitRef.get(Repository)).toThrow(); - expect(() => unitRef.get(Leaf)).toThrow(); - const invalid = () => { - // @ts-expect-error Composition preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Mock inference remains specific after composition. - host.region.mockReturnValue(42); - // @ts-expect-error Boundaries require at least one identifier. - TestBed.sociable(Service).compose().exclude(); - // @ts-expect-error Pass tokens as separate arguments or spread a tuple. - TestBed.sociable(Service).compose().exclude([Host, Leaf]); - // @ts-expect-error Exclusions are available only in composition mode. - TestBed.sociable(Service).exclude(Host); - // @ts-expect-error Selective exposure stays concrete-only. - TestBed.sociable(Service).expose(Host); - // @ts-expect-error Composition and selective exposure are separate strategies. - TestBed.sociable(Service).compose().expose(Repository); - }; - expect(invalid).toBeDefined(); - }); - - it('mocks every variadic boundary and accepts readonly tuples', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .exclude(Host, Leaf) - .compile(); - unitRef.get(Host).region.mockReturnValue('singapore'); - unitRef.get(Leaf).prefix.mockReturnValue('mock:'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).not.toBeInstanceOf(Leaf); - expect(unit.read()).toBe('mock:singapore'); - - const boundaries = [Host, Leaf] as const; - const { unit: spread, unitRef: spreadRef } = await TestBed.sociable(Service) - .compose() - .exclude(...boundaries) - .compile(); - spreadRef.get(Host).region.mockReturnValue('tokyo'); - spreadRef.get(Leaf).prefix.mockReturnValue('spread:'); - expect(spread.read()).toBe('spread:tokyo'); - }); - - it('accumulates distinct boundaries across consecutive exclusions', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .exclude(Host) - .exclude(Leaf) - .compile(); - const host = unitRef.get(Host); - const leaf = unitRef.get(Leaf); - host.region.mockReturnValue('tokyo'); - leaf.prefix.mockReturnValue('mock:'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.host).toBe(host); - expect(unit.repository.leaf).toBe(leaf); - expect(unit.read()).toBe('mock:tokyo'); - expect(host.region).toHaveBeenCalledTimes(1); - expect(leaf.prefix).toHaveBeenCalledTimes(1); - }); - - it('uses explicit automatic mocks as composition boundaries', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .mock(Leaf) - .auto() - .exclude(Host) - .compile(); - const leaf = unitRef.get(Leaf); - expect(leaf.prefix()).toBeUndefined(); - leaf.prefix.mockReturnValue('auto:'); - unitRef.get(Host).region.mockReturnValue('tokyo'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).not.toBeInstanceOf(Leaf); - expect(unit.read()).toBe('auto:tokyo'); - const invalid = () => { - // @ts-expect-error Automatic mock completion preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Automatic mocks preserve method return types. - leaf.prefix.mockReturnValue(42); - // @ts-expect-error A mock selector requires auto, impl, or final. - TestBed.solitary(Service).mock(Repository).compile(); - }; - expect(invalid).toBeDefined(); - }); - - it('preserves selective and solitary chains after automatic mock declarations', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .expose(Leaf) - .mock(Host) - .auto() - .expose(Repository) - .compile(); - unitRef.get(Host).region.mockReturnValue('singapore'); - expect(unit.repository.leaf).toBeInstanceOf(Leaf); - expect(unit.read()).toBe('cache:singapore'); - - const { unit: isolated, unitRef: isolatedRef } = await TestBed.solitary(Service) - .mock(Repository) - .auto() - .compile(); - isolatedRef.get(Repository).read.mockReturnValue('isolated'); - expect(isolated.read()).toBe('isolated'); - }); - - it('preserves the subject type through implementation overrides', async () => { - const { unit } = await TestBed.sociable(Service) - .compose() - .mock(Host) - .impl((stub) => ({ region: stub().mockReturnValue('tokyo') })) - .compile(); - expect(unit.read()).toBe('cache:tokyo'); - const invalid = () => { - // @ts-expect-error Implementation overrides preserve the subject type. - unit.nonexistentMethod(); - }; - expect(invalid).toBeDefined(); - }); - - it('preserves mode methods through typed implementation and final overrides', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .mock(Host) - .impl((stub) => ({ region: stub().mockReturnValue('tokyo') })) - .exclude(Host) - .mock(Leaf) - .final({ prefix: () => 'fake:' }) - .exclude(Host) - .compile(); - const invalid = () => { - // @ts-expect-error Final overrides preserve the subject type. - unit.nonexistentMethod(); - }; - expect(invalid).toBeDefined(); - expect(unit.read()).toBe('fake:tokyo'); - expect(unitRef.get(Host).region).toHaveBeenCalledTimes(1); - expect(() => unitRef.get(Leaf)).toThrow(); - }); - - it('preserves existing V4 selective mock chains and implicit mocking', async () => { - const { unit } = await TestBed.sociable(Service) - .expose(Repository) - .mock(Repository) - .final({ read: () => 'legacy' }) - .compile(); - expect(unit.read()).toBe('legacy'); - const { unit: partial } = await TestBed.sociable(Service).expose(Repository).compile(); - expect(partial.repository).toBeInstanceOf(Repository); - expect(partial.repository.leaf).not.toBeInstanceOf(Leaf); - }); -}); diff --git a/e2e/esm/vitest/injection-js/token-exposure.e2e.test.ts b/e2e/esm/vitest/injection-js/token-exposure.e2e.test.ts deleted file mode 100644 index 0e8dd7e8..00000000 --- a/e2e/esm/vitest/injection-js/token-exposure.e2e.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import 'reflect-metadata'; -import { describe, it, expect } from 'vitest'; -import { Injectable as injectable, Inject as inject } from 'injection-js'; -import { TestBed } from '@suites/unit'; - -const PRIMARY = Symbol('provider'); -const SECONDARY = 'provider'; -abstract class Port { - abstract region(): string; -} -@injectable() -class Cache { - read(): string { - return 'real'; - } -} -@injectable() -class Provider extends Port { - constructor(@inject(Cache) readonly cache: Cache) { - super(); - } - region(): string { - return this.cache.read(); - } -} -@injectable() -class Root { - constructor( - @inject(PRIMARY) readonly primary: Port, - @inject(PRIMARY) readonly repeated: Port, - @inject(SECONDARY) readonly secondary: Port, - @inject(Provider) readonly direct: Provider, - @inject(Port) readonly abstract: Port - ) {} -} -@injectable() -class Single { - constructor(@inject(PRIMARY) readonly provider: Port) {} -} - -describe('token exposure', () => { - it('constructs string, symbol and abstract bindings without aliasing token identities', async () => { - const { unit, unitRef } = await TestBed.sociable(Root) - .expose(PRIMARY, Provider) - .expose(SECONDARY, Provider) - .expose(Provider) - .expose(Port, Provider) - .mock(Cache) - .impl((stub) => ({ read: stub().mockReturnValue('configured') })) - .compile(); - expect(unit.primary).toBeInstanceOf(Provider); - expect(unit.primary).toBe(unit.repeated); - expect(unit.primary).not.toBe(unit.secondary); - expect(unit.primary).not.toBe(unit.direct); - expect(unit.primary).not.toBe(unit.abstract); - expect(unit.secondary).toBeInstanceOf(Provider); - expect(unit.abstract).toBeInstanceOf(Provider); - expect(unit.primary.region()).toBe('configured'); - expect(unit.secondary.region()).toBe('configured'); - expect(unit.abstract.region()).toBe('configured'); - expect(unit.direct.cache).toBe(unitRef.get(Cache)); - expect(() => unitRef.get(PRIMARY)).toThrow(/exposed dependency/); - expect(() => unitRef.get(SECONDARY)).toThrow(/exposed dependency/); - expect(() => unitRef.get(Port)).toThrow(/exposed dependency/); - const checkTypes = () => { - // @ts-expect-error Exposure preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Tokens require a concrete implementation. - TestBed.sociable(Single).expose(PRIMARY); - // @ts-expect-error Abstract classes cannot be constructed as implementations. - TestBed.sociable(Single).expose(PRIMARY, Port); - // @ts-expect-error Implementations must satisfy the abstract token's instance type. - TestBed.sociable(Root).expose(Port, Cache); - // @ts-expect-error Implementations must satisfy concrete token instance types too. - TestBed.sociable(Root).expose(Provider, Cache); - // @ts-expect-error Token exposure remains selective, not a composition operation. - TestBed.sociable(Single).compose().expose(PRIMARY, Provider); - }; - expect(checkTypes).toBeDefined(); - }); - - it('keeps normal v4 auto-mocking below the exposed implementation', async () => { - const { unit, unitRef } = await TestBed.sociable(Single).expose(PRIMARY, Provider).compile(); - expect(unit.provider).toBeInstanceOf(Provider); - expect(unit.provider.region()).toBeUndefined(); - unitRef.get(Cache).read.mockReturnValue('configured later'); - expect(unit.provider.region()).toBe('configured later'); - }); - - it('lets automatic, implementation and final overrides replace exposed tokens', async () => { - const automatic = await TestBed.sociable(Single) - .expose(PRIMARY, Provider) - .mock(PRIMARY) - .auto() - .compile(); - automatic.unitRef.get(PRIMARY).region.mockReturnValue('automatic'); - expect(automatic.unit.provider).toBe(automatic.unitRef.get(PRIMARY)); - expect(automatic.unit.provider.region()).toBe('automatic'); - expect(automatic.unit.provider).not.toBeInstanceOf(Provider); - - const implementation = await TestBed.sociable(Single) - .expose(Cache) - .mock(PRIMARY) - .impl((stub) => ({ region: stub().mockReturnValue('implementation') })) - .expose(PRIMARY, Provider) - .compile(); - expect(implementation.unit.provider).toBe(implementation.unitRef.get(PRIMARY)); - expect(implementation.unit.provider.region()).toBe('implementation'); - - const supplied = { region: () => 'final' }; - const final = await TestBed.sociable(Single) - .expose(PRIMARY, Provider) - .mock(PRIMARY) - .final(supplied) - .compile(); - expect(final.unit.provider).toBe(supplied); - expect(() => final.unitRef.get(PRIMARY)).toThrow(/faked dependency/); - }); - - it('resolves nested token bindings', async () => { - const INNER = Symbol('inner'); - @injectable() - class Nested extends Port { - constructor(@inject(INNER) readonly cache: Cache) { - super(); - } - region(): string { - return this.cache.read(); - } - } - const { unit } = await TestBed.sociable(Single) - .expose(PRIMARY, Nested) - .expose(INNER, Cache) - .compile(); - expect(unit.provider).toBeInstanceOf(Nested); - expect(unit.provider.region()).toBe('real'); - }); -}); diff --git a/e2e/esm/vitest/nestjs/composition.e2e.test.ts b/e2e/esm/vitest/nestjs/composition.e2e.test.ts deleted file mode 100644 index a753a662..00000000 --- a/e2e/esm/vitest/nestjs/composition.e2e.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import 'reflect-metadata'; -import { describe, it, expect } from 'vitest'; -import { Injectable } from '@nestjs/common'; -import { TestBed } from '@suites/unit'; - -abstract class Host { - abstract region(): string; -} -@Injectable() -class Leaf { - prefix() { - return 'cache:'; - } -} -@Injectable() -class Repository { - constructor( - readonly host: Host, - readonly leaf: Leaf - ) {} - read() { - return this.leaf.prefix() + this.host.region(); - } -} -@Injectable() -class Service { - constructor(readonly repository: Repository) {} - read() { - return this.repository.read(); - } -} - -describe('opt-in composition', () => { - it('constructs the transitive graph and mocks excluded abstract tokens', async () => { - const { unit, unitRef } = await TestBed.sociable(Service).compose().exclude(Host).compile(); - const host = unitRef.get(Host); - host.region.mockReturnValue('singapore'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).toBeInstanceOf(Leaf); - expect(unit.read()).toBe('cache:singapore'); - expect(() => unitRef.get(Repository)).toThrow(); - expect(() => unitRef.get(Leaf)).toThrow(); - const invalid = () => { - // @ts-expect-error Composition preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Mock inference remains specific after composition. - host.region.mockReturnValue(42); - // @ts-expect-error Boundaries require at least one identifier. - TestBed.sociable(Service).compose().exclude(); - // @ts-expect-error Pass tokens as separate arguments or spread a tuple. - TestBed.sociable(Service).compose().exclude([Host, Leaf]); - // @ts-expect-error Exclusions are available only in composition mode. - TestBed.sociable(Service).exclude(Host); - // @ts-expect-error Selective exposure stays concrete-only. - TestBed.sociable(Service).expose(Host); - // @ts-expect-error Composition and selective exposure are separate strategies. - TestBed.sociable(Service).compose().expose(Repository); - }; - expect(invalid).toBeDefined(); - }); - - it('mocks every variadic boundary and accepts readonly tuples', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .exclude(Host, Leaf) - .compile(); - unitRef.get(Host).region.mockReturnValue('singapore'); - unitRef.get(Leaf).prefix.mockReturnValue('mock:'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).not.toBeInstanceOf(Leaf); - expect(unit.read()).toBe('mock:singapore'); - - const boundaries = [Host, Leaf] as const; - const { unit: spread, unitRef: spreadRef } = await TestBed.sociable(Service) - .compose() - .exclude(...boundaries) - .compile(); - spreadRef.get(Host).region.mockReturnValue('tokyo'); - spreadRef.get(Leaf).prefix.mockReturnValue('spread:'); - expect(spread.read()).toBe('spread:tokyo'); - }); - - it('accumulates distinct boundaries across consecutive exclusions', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .exclude(Host) - .exclude(Leaf) - .compile(); - const host = unitRef.get(Host); - const leaf = unitRef.get(Leaf); - host.region.mockReturnValue('tokyo'); - leaf.prefix.mockReturnValue('mock:'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.host).toBe(host); - expect(unit.repository.leaf).toBe(leaf); - expect(unit.read()).toBe('mock:tokyo'); - expect(host.region).toHaveBeenCalledTimes(1); - expect(leaf.prefix).toHaveBeenCalledTimes(1); - }); - - it('uses explicit automatic mocks as composition boundaries', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .mock(Leaf) - .auto() - .exclude(Host) - .compile(); - const leaf = unitRef.get(Leaf); - expect(leaf.prefix()).toBeUndefined(); - leaf.prefix.mockReturnValue('auto:'); - unitRef.get(Host).region.mockReturnValue('tokyo'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).not.toBeInstanceOf(Leaf); - expect(unit.read()).toBe('auto:tokyo'); - const invalid = () => { - // @ts-expect-error Automatic mock completion preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Automatic mocks preserve method return types. - leaf.prefix.mockReturnValue(42); - // @ts-expect-error A mock selector requires auto, impl, or final. - TestBed.solitary(Service).mock(Repository).compile(); - }; - expect(invalid).toBeDefined(); - }); - - it('preserves selective and solitary chains after automatic mock declarations', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .expose(Leaf) - .mock(Host) - .auto() - .expose(Repository) - .compile(); - unitRef.get(Host).region.mockReturnValue('singapore'); - expect(unit.repository.leaf).toBeInstanceOf(Leaf); - expect(unit.read()).toBe('cache:singapore'); - - const { unit: isolated, unitRef: isolatedRef } = await TestBed.solitary(Service) - .mock(Repository) - .auto() - .compile(); - isolatedRef.get(Repository).read.mockReturnValue('isolated'); - expect(isolated.read()).toBe('isolated'); - }); - - it('preserves the subject type through implementation overrides', async () => { - const { unit } = await TestBed.sociable(Service) - .compose() - .mock(Host) - .impl((stub) => ({ region: stub().mockReturnValue('tokyo') })) - .compile(); - expect(unit.read()).toBe('cache:tokyo'); - const invalid = () => { - // @ts-expect-error Implementation overrides preserve the subject type. - unit.nonexistentMethod(); - }; - expect(invalid).toBeDefined(); - }); - - it('preserves mode methods through typed implementation and final overrides', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .mock(Host) - .impl((stub) => ({ region: stub().mockReturnValue('tokyo') })) - .exclude(Host) - .mock(Leaf) - .final({ prefix: () => 'fake:' }) - .exclude(Host) - .compile(); - const invalid = () => { - // @ts-expect-error Final overrides preserve the subject type. - unit.nonexistentMethod(); - }; - expect(invalid).toBeDefined(); - expect(unit.read()).toBe('fake:tokyo'); - expect(unitRef.get(Host).region).toHaveBeenCalledTimes(1); - expect(() => unitRef.get(Leaf)).toThrow(); - }); - - it('preserves existing V4 selective mock chains and implicit mocking', async () => { - const { unit } = await TestBed.sociable(Service) - .expose(Repository) - .mock(Repository) - .final({ read: () => 'legacy' }) - .compile(); - expect(unit.read()).toBe('legacy'); - const { unit: partial } = await TestBed.sociable(Service).expose(Repository).compile(); - expect(partial.repository).toBeInstanceOf(Repository); - expect(partial.repository.leaf).not.toBeInstanceOf(Leaf); - }); -}); diff --git a/e2e/esm/vitest/nestjs/getters.e2e.test.ts b/e2e/esm/vitest/nestjs/getters.e2e.test.ts deleted file mode 100644 index 65562d32..00000000 --- a/e2e/esm/vitest/nestjs/getters.e2e.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import 'reflect-metadata'; -import { Injectable } from '@nestjs/common'; -import { TestBed } from '@suites/unit'; -import { mock } from '@suites/doubles.vitest'; - -@Injectable() -class ConfigService { - get isCli(): boolean { - throw new Error('The real getter must not run'); - } -} - -@Injectable() -class Application { - constructor(public readonly config: ConfigService) {} - - isCli(): boolean { - return this.config.isCli; - } -} - -describe('Supplied getter implementations', () => { - it('configures a readonly getter through TestBed without assignment', async () => { - let reads = 0; - const { unit, unitRef } = await TestBed.solitary(Application) - .mock(ConfigService) - .impl(() => ({ - get isCli() { - reads += 1; - return false; - }, - })) - .compile(); - - expect(reads).toBe(0); - expect(unit.isCli()).toBe(false); - expect(reads).toBe(1); - const config = unitRef.get(ConfigService); - vi.spyOn(config, 'isCli', 'get').mockReturnValue(true); - expect(unit.isCli()).toBe(true); - }); - - it('supports a standalone configurable getter and a plain false value', () => { - const config = mock({ - get isCli() { - return false; - }, - }); - expect(config.isCli).toBe(false); - vi.spyOn(config, 'isCli', 'get').mockReturnValue(true); - expect(config.isCli).toBe(true); - expect(mock({ isCli: false }).isCli).toBe(false); - }); - - it('retains lazy defaults and allows defining a value on an existing mock', () => { - const config = mock(); - expect(config.isCli).toBeTruthy(); - Object.defineProperty(config, 'isCli', { value: false }); - expect(config.isCli).toBe(false); - }); -}); diff --git a/e2e/esm/vitest/nestjs/token-exposure.e2e.test.ts b/e2e/esm/vitest/nestjs/token-exposure.e2e.test.ts deleted file mode 100644 index 1061e1b1..00000000 --- a/e2e/esm/vitest/nestjs/token-exposure.e2e.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import 'reflect-metadata'; -import { describe, it, expect } from 'vitest'; -import { Injectable as injectable, Inject as inject } from '@nestjs/common'; -import { TestBed } from '@suites/unit'; - -const PRIMARY = Symbol('provider'); -const SECONDARY = 'provider'; -abstract class Port { - abstract region(): string; -} -@injectable() -class Cache { - read(): string { - return 'real'; - } -} -@injectable() -class Provider extends Port { - constructor(@inject(Cache) readonly cache: Cache) { - super(); - } - region(): string { - return this.cache.read(); - } -} -@injectable() -class Root { - constructor( - @inject(PRIMARY) readonly primary: Port, - @inject(PRIMARY) readonly repeated: Port, - @inject(SECONDARY) readonly secondary: Port, - @inject(Provider) readonly direct: Provider, - @inject(Port) readonly abstract: Port - ) {} -} -@injectable() -class Single { - constructor(@inject(PRIMARY) readonly provider: Port) {} -} - -describe('token exposure', () => { - it('constructs string, symbol and abstract bindings without aliasing token identities', async () => { - const { unit, unitRef } = await TestBed.sociable(Root) - .expose(PRIMARY, Provider) - .expose(SECONDARY, Provider) - .expose(Provider) - .expose(Port, Provider) - .mock(Cache) - .impl((stub) => ({ read: stub().mockReturnValue('configured') })) - .compile(); - expect(unit.primary).toBeInstanceOf(Provider); - expect(unit.primary).toBe(unit.repeated); - expect(unit.primary).not.toBe(unit.secondary); - expect(unit.primary).not.toBe(unit.direct); - expect(unit.primary).not.toBe(unit.abstract); - expect(unit.secondary).toBeInstanceOf(Provider); - expect(unit.abstract).toBeInstanceOf(Provider); - expect(unit.primary.region()).toBe('configured'); - expect(unit.secondary.region()).toBe('configured'); - expect(unit.abstract.region()).toBe('configured'); - expect(unit.direct.cache).toBe(unitRef.get(Cache)); - expect(() => unitRef.get(PRIMARY)).toThrow(/exposed dependency/); - expect(() => unitRef.get(SECONDARY)).toThrow(/exposed dependency/); - expect(() => unitRef.get(Port)).toThrow(/exposed dependency/); - const checkTypes = () => { - // @ts-expect-error Exposure preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Tokens require a concrete implementation. - TestBed.sociable(Single).expose(PRIMARY); - // @ts-expect-error Abstract classes cannot be constructed as implementations. - TestBed.sociable(Single).expose(PRIMARY, Port); - // @ts-expect-error Implementations must satisfy the abstract token's instance type. - TestBed.sociable(Root).expose(Port, Cache); - // @ts-expect-error Implementations must satisfy concrete token instance types too. - TestBed.sociable(Root).expose(Provider, Cache); - // @ts-expect-error Token exposure remains selective, not a composition operation. - TestBed.sociable(Single).compose().expose(PRIMARY, Provider); - }; - expect(checkTypes).toBeDefined(); - }); - - it('keeps normal v4 auto-mocking below the exposed implementation', async () => { - const { unit, unitRef } = await TestBed.sociable(Single).expose(PRIMARY, Provider).compile(); - expect(unit.provider).toBeInstanceOf(Provider); - expect(unit.provider.region()).toBeUndefined(); - unitRef.get(Cache).read.mockReturnValue('configured later'); - expect(unit.provider.region()).toBe('configured later'); - }); - - it('lets automatic, implementation and final overrides replace exposed tokens', async () => { - const automatic = await TestBed.sociable(Single) - .expose(PRIMARY, Provider) - .mock(PRIMARY) - .auto() - .compile(); - automatic.unitRef.get(PRIMARY).region.mockReturnValue('automatic'); - expect(automatic.unit.provider).toBe(automatic.unitRef.get(PRIMARY)); - expect(automatic.unit.provider.region()).toBe('automatic'); - expect(automatic.unit.provider).not.toBeInstanceOf(Provider); - - const implementation = await TestBed.sociable(Single) - .expose(Cache) - .mock(PRIMARY) - .impl((stub) => ({ region: stub().mockReturnValue('implementation') })) - .expose(PRIMARY, Provider) - .compile(); - expect(implementation.unit.provider).toBe(implementation.unitRef.get(PRIMARY)); - expect(implementation.unit.provider.region()).toBe('implementation'); - - const supplied = { region: () => 'final' }; - const final = await TestBed.sociable(Single) - .expose(PRIMARY, Provider) - .mock(PRIMARY) - .final(supplied) - .compile(); - expect(final.unit.provider).toBe(supplied); - expect(() => final.unitRef.get(PRIMARY)).toThrow(/faked dependency/); - }); - - it('resolves nested token bindings', async () => { - const INNER = Symbol('inner'); - @injectable() - class Nested extends Port { - constructor(@inject(INNER) readonly cache: Cache) { - super(); - } - region(): string { - return this.cache.read(); - } - } - const { unit } = await TestBed.sociable(Single) - .expose(PRIMARY, Nested) - .expose(INNER, Cache) - .compile(); - expect(unit.provider).toBeInstanceOf(Nested); - expect(unit.provider.region()).toBe('real'); - }); -}); - -@injectable() -class PropertyRoot { - @inject(PRIMARY) readonly provider!: Port; -} -it('injects a real token implementation into a property', async () => { - const { unit, unitRef } = await TestBed.sociable(PropertyRoot) - .expose(PRIMARY, Provider) - .expose(Cache) - .compile(); - expect(unit.provider).toBeInstanceOf(Provider); - expect(unit.provider.region()).toBe('real'); - expect(() => unitRef.get(PRIMARY)).toThrow(/exposed dependency/); -}); diff --git a/e2e/jest/injection-js/composition.e2e.test.ts b/e2e/jest/injection-js/composition.e2e.test.ts deleted file mode 100644 index bb8bd845..00000000 --- a/e2e/jest/injection-js/composition.e2e.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import 'reflect-metadata'; -import { describe, it, expect } from '@jest/globals'; -import { Injectable } from 'injection-js'; -import { TestBed } from '@suites/unit'; - -abstract class Host { - abstract region(): string; -} -@Injectable() -class Leaf { - prefix() { - return 'cache:'; - } -} -@Injectable() -class Repository { - constructor( - readonly host: Host, - readonly leaf: Leaf - ) {} - read() { - return this.leaf.prefix() + this.host.region(); - } -} -@Injectable() -class Service { - constructor(readonly repository: Repository) {} - read() { - return this.repository.read(); - } -} - -describe('opt-in composition', () => { - it('constructs the transitive graph and mocks excluded abstract tokens', async () => { - const { unit, unitRef } = await TestBed.sociable(Service).compose().exclude(Host).compile(); - const host = unitRef.get(Host); - host.region.mockReturnValue('singapore'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).toBeInstanceOf(Leaf); - expect(unit.read()).toBe('cache:singapore'); - expect(() => unitRef.get(Repository)).toThrow(); - expect(() => unitRef.get(Leaf)).toThrow(); - const invalid = () => { - // @ts-expect-error Composition preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Mock inference remains specific after composition. - host.region.mockReturnValue(42); - // @ts-expect-error Boundaries require at least one identifier. - TestBed.sociable(Service).compose().exclude(); - // @ts-expect-error Pass tokens as separate arguments or spread a tuple. - TestBed.sociable(Service).compose().exclude([Host, Leaf]); - // @ts-expect-error Exclusions are available only in composition mode. - TestBed.sociable(Service).exclude(Host); - // @ts-expect-error Selective exposure stays concrete-only. - TestBed.sociable(Service).expose(Host); - // @ts-expect-error Composition and selective exposure are separate strategies. - TestBed.sociable(Service).compose().expose(Repository); - }; - expect(invalid).toBeDefined(); - }); - - it('mocks every variadic boundary and accepts readonly tuples', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .exclude(Host, Leaf) - .compile(); - unitRef.get(Host).region.mockReturnValue('singapore'); - unitRef.get(Leaf).prefix.mockReturnValue('mock:'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).not.toBeInstanceOf(Leaf); - expect(unit.read()).toBe('mock:singapore'); - - const boundaries = [Host, Leaf] as const; - const { unit: spread, unitRef: spreadRef } = await TestBed.sociable(Service) - .compose() - .exclude(...boundaries) - .compile(); - spreadRef.get(Host).region.mockReturnValue('tokyo'); - spreadRef.get(Leaf).prefix.mockReturnValue('spread:'); - expect(spread.read()).toBe('spread:tokyo'); - }); - - it('accumulates distinct boundaries across consecutive exclusions', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .exclude(Host) - .exclude(Leaf) - .compile(); - const host = unitRef.get(Host); - const leaf = unitRef.get(Leaf); - host.region.mockReturnValue('tokyo'); - leaf.prefix.mockReturnValue('mock:'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.host).toBe(host); - expect(unit.repository.leaf).toBe(leaf); - expect(unit.read()).toBe('mock:tokyo'); - expect(host.region).toHaveBeenCalledTimes(1); - expect(leaf.prefix).toHaveBeenCalledTimes(1); - }); - - it('uses explicit automatic mocks as composition boundaries', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .mock(Leaf) - .auto() - .exclude(Host) - .compile(); - const leaf = unitRef.get(Leaf); - expect(leaf.prefix()).toBeUndefined(); - leaf.prefix.mockReturnValue('auto:'); - unitRef.get(Host).region.mockReturnValue('tokyo'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).not.toBeInstanceOf(Leaf); - expect(unit.read()).toBe('auto:tokyo'); - const invalid = () => { - // @ts-expect-error Automatic mock completion preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Automatic mocks preserve method return types. - leaf.prefix.mockReturnValue(42); - // @ts-expect-error A mock selector requires auto, impl, or final. - TestBed.solitary(Service).mock(Repository).compile(); - }; - expect(invalid).toBeDefined(); - }); - - it('preserves selective and solitary chains after automatic mock declarations', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .expose(Leaf) - .mock(Host) - .auto() - .expose(Repository) - .compile(); - unitRef.get(Host).region.mockReturnValue('singapore'); - expect(unit.repository.leaf).toBeInstanceOf(Leaf); - expect(unit.read()).toBe('cache:singapore'); - - const { unit: isolated, unitRef: isolatedRef } = await TestBed.solitary(Service) - .mock(Repository) - .auto() - .compile(); - isolatedRef.get(Repository).read.mockReturnValue('isolated'); - expect(isolated.read()).toBe('isolated'); - }); - - it('preserves the subject type through implementation overrides', async () => { - const { unit } = await TestBed.sociable(Service) - .compose() - .mock(Host) - .impl((stub) => ({ region: stub().mockReturnValue('tokyo') })) - .compile(); - expect(unit.read()).toBe('cache:tokyo'); - const invalid = () => { - // @ts-expect-error Implementation overrides preserve the subject type. - unit.nonexistentMethod(); - }; - expect(invalid).toBeDefined(); - }); - - it('preserves mode methods through typed implementation and final overrides', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .mock(Host) - .impl((stub) => ({ region: stub().mockReturnValue('tokyo') })) - .exclude(Host) - .mock(Leaf) - .final({ prefix: () => 'fake:' }) - .exclude(Host) - .compile(); - const invalid = () => { - // @ts-expect-error Final overrides preserve the subject type. - unit.nonexistentMethod(); - }; - expect(invalid).toBeDefined(); - expect(unit.read()).toBe('fake:tokyo'); - expect(unitRef.get(Host).region).toHaveBeenCalledTimes(1); - expect(() => unitRef.get(Leaf)).toThrow(); - }); - - it('preserves existing V4 selective mock chains and implicit mocking', async () => { - const { unit } = await TestBed.sociable(Service) - .expose(Repository) - .mock(Repository) - .final({ read: () => 'legacy' }) - .compile(); - expect(unit.read()).toBe('legacy'); - const { unit: partial } = await TestBed.sociable(Service).expose(Repository).compile(); - expect(partial.repository).toBeInstanceOf(Repository); - expect(partial.repository.leaf).not.toBeInstanceOf(Leaf); - }); -}); diff --git a/e2e/jest/injection-js/token-exposure.e2e.test.ts b/e2e/jest/injection-js/token-exposure.e2e.test.ts deleted file mode 100644 index 439751a9..00000000 --- a/e2e/jest/injection-js/token-exposure.e2e.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import 'reflect-metadata'; -import { describe, it, expect } from '@jest/globals'; -import { Injectable as injectable, Inject as inject } from 'injection-js'; -import { TestBed } from '@suites/unit'; - -const PRIMARY = Symbol('provider'); -const SECONDARY = 'provider'; -abstract class Port { - abstract region(): string; -} -@injectable() -class Cache { - read(): string { - return 'real'; - } -} -@injectable() -class Provider extends Port { - constructor(@inject(Cache) readonly cache: Cache) { - super(); - } - region(): string { - return this.cache.read(); - } -} -@injectable() -class Root { - constructor( - @inject(PRIMARY) readonly primary: Port, - @inject(PRIMARY) readonly repeated: Port, - @inject(SECONDARY) readonly secondary: Port, - @inject(Provider) readonly direct: Provider, - @inject(Port) readonly abstract: Port - ) {} -} -@injectable() -class Single { - constructor(@inject(PRIMARY) readonly provider: Port) {} -} - -describe('token exposure', () => { - it('constructs string, symbol and abstract bindings without aliasing token identities', async () => { - const { unit, unitRef } = await TestBed.sociable(Root) - .expose(PRIMARY, Provider) - .expose(SECONDARY, Provider) - .expose(Provider) - .expose(Port, Provider) - .mock(Cache) - .impl((stub) => ({ read: stub().mockReturnValue('configured') })) - .compile(); - expect(unit.primary).toBeInstanceOf(Provider); - expect(unit.primary).toBe(unit.repeated); - expect(unit.primary).not.toBe(unit.secondary); - expect(unit.primary).not.toBe(unit.direct); - expect(unit.primary).not.toBe(unit.abstract); - expect(unit.secondary).toBeInstanceOf(Provider); - expect(unit.abstract).toBeInstanceOf(Provider); - expect(unit.primary.region()).toBe('configured'); - expect(unit.secondary.region()).toBe('configured'); - expect(unit.abstract.region()).toBe('configured'); - expect(unit.direct.cache).toBe(unitRef.get(Cache)); - expect(() => unitRef.get(PRIMARY)).toThrow(/exposed dependency/); - expect(() => unitRef.get(SECONDARY)).toThrow(/exposed dependency/); - expect(() => unitRef.get(Port)).toThrow(/exposed dependency/); - const checkTypes = () => { - // @ts-expect-error Exposure preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Tokens require a concrete implementation. - TestBed.sociable(Single).expose(PRIMARY); - // @ts-expect-error Abstract classes cannot be constructed as implementations. - TestBed.sociable(Single).expose(PRIMARY, Port); - // @ts-expect-error Implementations must satisfy the abstract token's instance type. - TestBed.sociable(Root).expose(Port, Cache); - // @ts-expect-error Implementations must satisfy concrete token instance types too. - TestBed.sociable(Root).expose(Provider, Cache); - // @ts-expect-error Token exposure remains selective, not a composition operation. - TestBed.sociable(Single).compose().expose(PRIMARY, Provider); - }; - expect(checkTypes).toBeDefined(); - }); - - it('keeps normal v4 auto-mocking below the exposed implementation', async () => { - const { unit, unitRef } = await TestBed.sociable(Single).expose(PRIMARY, Provider).compile(); - expect(unit.provider).toBeInstanceOf(Provider); - expect(unit.provider.region()).toBeUndefined(); - unitRef.get(Cache).read.mockReturnValue('configured later'); - expect(unit.provider.region()).toBe('configured later'); - }); - - it('lets automatic, implementation and final overrides replace exposed tokens', async () => { - const automatic = await TestBed.sociable(Single) - .expose(PRIMARY, Provider) - .mock(PRIMARY) - .auto() - .compile(); - automatic.unitRef.get(PRIMARY).region.mockReturnValue('automatic'); - expect(automatic.unit.provider).toBe(automatic.unitRef.get(PRIMARY)); - expect(automatic.unit.provider.region()).toBe('automatic'); - expect(automatic.unit.provider).not.toBeInstanceOf(Provider); - - const implementation = await TestBed.sociable(Single) - .expose(Cache) - .mock(PRIMARY) - .impl((stub) => ({ region: stub().mockReturnValue('implementation') })) - .expose(PRIMARY, Provider) - .compile(); - expect(implementation.unit.provider).toBe(implementation.unitRef.get(PRIMARY)); - expect(implementation.unit.provider.region()).toBe('implementation'); - - const supplied = { region: () => 'final' }; - const final = await TestBed.sociable(Single) - .expose(PRIMARY, Provider) - .mock(PRIMARY) - .final(supplied) - .compile(); - expect(final.unit.provider).toBe(supplied); - expect(() => final.unitRef.get(PRIMARY)).toThrow(/faked dependency/); - }); - - it('resolves nested token bindings', async () => { - const INNER = Symbol('inner'); - @injectable() - class Nested extends Port { - constructor(@inject(INNER) readonly cache: Cache) { - super(); - } - region(): string { - return this.cache.read(); - } - } - const { unit } = await TestBed.sociable(Single) - .expose(PRIMARY, Nested) - .expose(INNER, Cache) - .compile(); - expect(unit.provider).toBeInstanceOf(Nested); - expect(unit.provider.region()).toBe('real'); - }); -}); diff --git a/e2e/jest/inversify/composition.e2e.test.ts b/e2e/jest/inversify/composition.e2e.test.ts deleted file mode 100644 index d7755765..00000000 --- a/e2e/jest/inversify/composition.e2e.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import 'reflect-metadata'; -import { describe, it, expect } from '@jest/globals'; -import { inject, injectable } from 'inversify'; -import { TestBed } from '@suites/unit'; - -abstract class Host { - abstract region(): string; -} -@injectable() -class Leaf { - prefix() { - return 'cache:'; - } -} -@injectable() -class Repository { - constructor( - @inject(Host) readonly host: Host, - @inject(Leaf) readonly leaf: Leaf - ) {} - read() { - return this.leaf.prefix() + this.host.region(); - } -} -@injectable() -class Service { - constructor(@inject(Repository) readonly repository: Repository) {} - read() { - return this.repository.read(); - } -} - -describe('opt-in composition', () => { - it('constructs the transitive graph and mocks excluded abstract tokens', async () => { - const { unit, unitRef } = await TestBed.sociable(Service).compose().exclude(Host).compile(); - const host = unitRef.get(Host); - host.region.mockReturnValue('singapore'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).toBeInstanceOf(Leaf); - expect(unit.read()).toBe('cache:singapore'); - expect(() => unitRef.get(Repository)).toThrow(); - expect(() => unitRef.get(Leaf)).toThrow(); - const invalid = () => { - // @ts-expect-error Composition preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Mock inference remains specific after composition. - host.region.mockReturnValue(42); - // @ts-expect-error Boundaries require at least one identifier. - TestBed.sociable(Service).compose().exclude(); - // @ts-expect-error Pass tokens as separate arguments or spread a tuple. - TestBed.sociable(Service).compose().exclude([Host, Leaf]); - // @ts-expect-error Exclusions are available only in composition mode. - TestBed.sociable(Service).exclude(Host); - // @ts-expect-error Selective exposure stays concrete-only. - TestBed.sociable(Service).expose(Host); - // @ts-expect-error Composition and selective exposure are separate strategies. - TestBed.sociable(Service).compose().expose(Repository); - }; - expect(invalid).toBeDefined(); - }); - - it('mocks every variadic boundary and accepts readonly tuples', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .exclude(Host, Leaf) - .compile(); - unitRef.get(Host).region.mockReturnValue('singapore'); - unitRef.get(Leaf).prefix.mockReturnValue('mock:'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).not.toBeInstanceOf(Leaf); - expect(unit.read()).toBe('mock:singapore'); - - const boundaries = [Host, Leaf] as const; - const { unit: spread, unitRef: spreadRef } = await TestBed.sociable(Service) - .compose() - .exclude(...boundaries) - .compile(); - spreadRef.get(Host).region.mockReturnValue('tokyo'); - spreadRef.get(Leaf).prefix.mockReturnValue('spread:'); - expect(spread.read()).toBe('spread:tokyo'); - }); - - it('accumulates distinct boundaries across consecutive exclusions', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .exclude(Host) - .exclude(Leaf) - .compile(); - const host = unitRef.get(Host); - const leaf = unitRef.get(Leaf); - host.region.mockReturnValue('tokyo'); - leaf.prefix.mockReturnValue('mock:'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.host).toBe(host); - expect(unit.repository.leaf).toBe(leaf); - expect(unit.read()).toBe('mock:tokyo'); - expect(host.region).toHaveBeenCalledTimes(1); - expect(leaf.prefix).toHaveBeenCalledTimes(1); - }); - - it('uses explicit automatic mocks as composition boundaries', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .mock(Leaf) - .auto() - .exclude(Host) - .compile(); - const leaf = unitRef.get(Leaf); - expect(leaf.prefix()).toBeUndefined(); - leaf.prefix.mockReturnValue('auto:'); - unitRef.get(Host).region.mockReturnValue('tokyo'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).not.toBeInstanceOf(Leaf); - expect(unit.read()).toBe('auto:tokyo'); - const invalid = () => { - // @ts-expect-error Automatic mock completion preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Automatic mocks preserve method return types. - leaf.prefix.mockReturnValue(42); - // @ts-expect-error A mock selector requires auto, impl, or final. - TestBed.solitary(Service).mock(Repository).compile(); - }; - expect(invalid).toBeDefined(); - }); - - it('preserves selective and solitary chains after automatic mock declarations', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .expose(Leaf) - .mock(Host) - .auto() - .expose(Repository) - .compile(); - unitRef.get(Host).region.mockReturnValue('singapore'); - expect(unit.repository.leaf).toBeInstanceOf(Leaf); - expect(unit.read()).toBe('cache:singapore'); - - const { unit: isolated, unitRef: isolatedRef } = await TestBed.solitary(Service) - .mock(Repository) - .auto() - .compile(); - isolatedRef.get(Repository).read.mockReturnValue('isolated'); - expect(isolated.read()).toBe('isolated'); - }); - - it('preserves the subject type through implementation overrides', async () => { - const { unit } = await TestBed.sociable(Service) - .compose() - .mock(Host) - .impl((stub) => ({ region: stub().mockReturnValue('tokyo') })) - .compile(); - expect(unit.read()).toBe('cache:tokyo'); - const invalid = () => { - // @ts-expect-error Implementation overrides preserve the subject type. - unit.nonexistentMethod(); - }; - expect(invalid).toBeDefined(); - }); - - it('preserves mode methods through typed implementation and final overrides', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .mock(Host) - .impl((stub) => ({ region: stub().mockReturnValue('tokyo') })) - .exclude(Host) - .mock(Leaf) - .final({ prefix: () => 'fake:' }) - .exclude(Host) - .compile(); - const invalid = () => { - // @ts-expect-error Final overrides preserve the subject type. - unit.nonexistentMethod(); - }; - expect(invalid).toBeDefined(); - expect(unit.read()).toBe('fake:tokyo'); - expect(unitRef.get(Host).region).toHaveBeenCalledTimes(1); - expect(() => unitRef.get(Leaf)).toThrow(); - }); - - it('preserves existing V4 selective mock chains and implicit mocking', async () => { - const { unit } = await TestBed.sociable(Service) - .expose(Repository) - .mock(Repository) - .final({ read: () => 'legacy' }) - .compile(); - expect(unit.read()).toBe('legacy'); - const { unit: partial } = await TestBed.sociable(Service).expose(Repository).compile(); - expect(partial.repository).toBeInstanceOf(Repository); - expect(partial.repository.leaf).not.toBeInstanceOf(Leaf); - }); -}); diff --git a/e2e/jest/inversify/qualified-dependencies.e2e.test.ts b/e2e/jest/inversify/qualified-dependencies.e2e.test.ts deleted file mode 100644 index 9895c417..00000000 --- a/e2e/jest/inversify/qualified-dependencies.e2e.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import 'reflect-metadata'; -import { expect, it } from '@jest/globals'; -import { inject, injectable, named } from 'inversify'; -import { TestBed } from '@suites/unit'; - -@injectable() -class Provider { - label(): string { - return 'real'; - } -} - -@injectable() -class SingleConsumer { - constructor(@inject(Provider) @named('primary') readonly provider: Provider) {} -} - -it('rejects final retrieval through both qualified and unique bare-token lookups', async () => { - const supplied = { label: () => 'final' }; - const { unit, unitRef } = await TestBed.solitary(SingleConsumer) - .mock(Provider, { named: 'primary' }) - .final(supplied) - .compile(); - - expect(unit.provider).toBe(supplied); - expect(unit.provider.label()).toBe('final'); - expect(() => unitRef.get(Provider, { named: 'primary' })).toThrow(/faked dependency/); - expect(() => unitRef.get(Provider)).toThrow(/faked dependency/); -}); - -@injectable() -class DoubleConsumer { - constructor( - @inject(Provider) @named('primary') readonly primary: Provider, - @inject(Provider) @named('secondary') readonly secondary: Provider - ) {} -} - -it('keeps named final and implementation overrides distinct for the same class', async () => { - const supplied = { label: () => 'final' }; - const { unit, unitRef } = await TestBed.solitary(DoubleConsumer) - .mock(Provider, { named: 'primary' }) - .final(supplied) - .mock(Provider, { named: 'secondary' }) - .impl((stub) => ({ label: stub().mockReturnValue('mock') })) - .compile(); - - const mocked = unitRef.get(Provider, { named: 'secondary' }); - expect(unit.primary).toBe(supplied); - expect(unit.secondary).toBe(mocked); - expect(unit.primary.label()).toBe('final'); - expect(unit.secondary.label()).toBe('mock'); - expect(unit.primary).not.toBe(unit.secondary); - mocked.label.mockReturnValue('updated'); - expect(unit.secondary.label()).toBe('updated'); - expect(unit.primary.label()).toBe('final'); - expect(() => unitRef.get(Provider, { named: 'primary' })).toThrow(/faked dependency/); -}); - -it('creates independent automatic mocks for differently named occurrences', async () => { - const { unit, unitRef } = await TestBed.solitary(DoubleConsumer).compile(); - const primary = unitRef.get(Provider, { named: 'primary' }); - const secondary = unitRef.get(Provider, { named: 'secondary' }); - primary.label.mockReturnValue('primary'); - secondary.label.mockReturnValue('secondary'); - - expect(primary).not.toBe(secondary); - expect(unit.primary).toBe(primary); - expect(unit.secondary).toBe(secondary); - expect(unit.primary.label()).toBe('primary'); - expect(unit.secondary.label()).toBe('secondary'); - expect(() => unitRef.get(Provider)).toThrow(/could not be located/); -}); - -it('keeps automatic mock selectors bound to their named dependency', async () => { - const builder = TestBed.sociable(DoubleConsumer).compose(); - const primary = builder.mock(Provider, { named: 'primary' }); - const secondary = builder.mock(Provider, { named: 'secondary' }); - secondary.final({ label: () => 'supplied' }); - primary.auto(); - const { unit, unitRef } = await builder.compile(); - const mocked = unitRef.get(Provider, { named: 'primary' }); - mocked.label.mockReturnValue('automatic'); - expect(unit.primary).toBe(mocked); - expect(unit.primary.label()).toBe('automatic'); - expect(unit.secondary.label()).toBe('supplied'); - expect(unit.primary).not.toBe(unit.secondary); - expect(() => unitRef.get(Provider, { named: 'secondary' })).toThrow(/faked dependency/); -}); diff --git a/e2e/jest/inversify/token-exposure.e2e.test.ts b/e2e/jest/inversify/token-exposure.e2e.test.ts deleted file mode 100644 index 2a37ed24..00000000 --- a/e2e/jest/inversify/token-exposure.e2e.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import 'reflect-metadata'; -import { describe, it, expect } from '@jest/globals'; -import { injectable, inject, named } from 'inversify'; -import { TestBed } from '@suites/unit'; - -const PRIMARY = Symbol('provider'); -const SECONDARY = 'provider'; -abstract class Port { - abstract region(): string; -} -@injectable() -class Cache { - read(): string { - return 'real'; - } -} -@injectable() -class Provider extends Port { - constructor(@inject(Cache) readonly cache: Cache) { - super(); - } - region(): string { - return this.cache.read(); - } -} -@injectable() -class Root { - constructor( - @inject(PRIMARY) readonly primary: Port, - @inject(PRIMARY) readonly repeated: Port, - @inject(SECONDARY) readonly secondary: Port, - @inject(Provider) readonly direct: Provider, - @inject(Port) readonly abstract: Port - ) {} -} -@injectable() -class Single { - constructor(@inject(PRIMARY) readonly provider: Port) {} -} - -describe('token exposure', () => { - it('constructs string, symbol and abstract bindings without aliasing token identities', async () => { - const { unit, unitRef } = await TestBed.sociable(Root) - .expose(PRIMARY, Provider) - .expose(SECONDARY, Provider) - .expose(Provider) - .expose(Port, Provider) - .mock(Cache) - .impl((stub) => ({ read: stub().mockReturnValue('configured') })) - .compile(); - expect(unit.primary).toBeInstanceOf(Provider); - expect(unit.primary).toBe(unit.repeated); - expect(unit.primary).not.toBe(unit.secondary); - expect(unit.primary).not.toBe(unit.direct); - expect(unit.primary).not.toBe(unit.abstract); - expect(unit.secondary).toBeInstanceOf(Provider); - expect(unit.abstract).toBeInstanceOf(Provider); - expect(unit.primary.region()).toBe('configured'); - expect(unit.secondary.region()).toBe('configured'); - expect(unit.abstract.region()).toBe('configured'); - expect(unit.direct.cache).toBe(unitRef.get(Cache)); - expect(() => unitRef.get(PRIMARY)).toThrow(/exposed dependency/); - expect(() => unitRef.get(SECONDARY)).toThrow(/exposed dependency/); - expect(() => unitRef.get(Port)).toThrow(/exposed dependency/); - const checkTypes = () => { - // @ts-expect-error Exposure preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Tokens require a concrete implementation. - TestBed.sociable(Single).expose(PRIMARY); - // @ts-expect-error Abstract classes cannot be constructed as implementations. - TestBed.sociable(Single).expose(PRIMARY, Port); - // @ts-expect-error Implementations must satisfy the abstract token's instance type. - TestBed.sociable(Root).expose(Port, Cache); - // @ts-expect-error Implementations must satisfy concrete token instance types too. - TestBed.sociable(Root).expose(Provider, Cache); - // @ts-expect-error Token exposure remains selective, not a composition operation. - TestBed.sociable(Single).compose().expose(PRIMARY, Provider); - }; - expect(checkTypes).toBeDefined(); - }); - - it('keeps normal v4 auto-mocking below the exposed implementation', async () => { - const { unit, unitRef } = await TestBed.sociable(Single).expose(PRIMARY, Provider).compile(); - expect(unit.provider).toBeInstanceOf(Provider); - expect(unit.provider.region()).toBeUndefined(); - unitRef.get(Cache).read.mockReturnValue('configured later'); - expect(unit.provider.region()).toBe('configured later'); - }); - - it('lets automatic, implementation and final overrides replace exposed tokens', async () => { - const automatic = await TestBed.sociable(Single) - .expose(PRIMARY, Provider) - .mock(PRIMARY) - .auto() - .compile(); - automatic.unitRef.get(PRIMARY).region.mockReturnValue('automatic'); - expect(automatic.unit.provider).toBe(automatic.unitRef.get(PRIMARY)); - expect(automatic.unit.provider.region()).toBe('automatic'); - expect(automatic.unit.provider).not.toBeInstanceOf(Provider); - - const implementation = await TestBed.sociable(Single) - .expose(Cache) - .mock(PRIMARY) - .impl((stub) => ({ region: stub().mockReturnValue('implementation') })) - .expose(PRIMARY, Provider) - .compile(); - expect(implementation.unit.provider).toBe(implementation.unitRef.get(PRIMARY)); - expect(implementation.unit.provider.region()).toBe('implementation'); - - const supplied = { region: () => 'final' }; - const final = await TestBed.sociable(Single) - .expose(PRIMARY, Provider) - .mock(PRIMARY) - .final(supplied) - .compile(); - expect(final.unit.provider).toBe(supplied); - expect(() => final.unitRef.get(PRIMARY)).toThrow(/faked dependency/); - }); - - it('resolves nested token bindings', async () => { - const INNER = Symbol('inner'); - @injectable() - class Nested extends Port { - constructor(@inject(INNER) readonly cache: Cache) { - super(); - } - region(): string { - return this.cache.read(); - } - } - const { unit } = await TestBed.sociable(Single) - .expose(PRIMARY, Nested) - .expose(INNER, Cache) - .compile(); - expect(unit.provider).toBeInstanceOf(Nested); - expect(unit.provider.region()).toBe('real'); - }); -}); - -@injectable() -class PropertyRoot { - @inject(PRIMARY) readonly provider!: Port; -} -it('injects a real token implementation into a property', async () => { - const { unit, unitRef } = await TestBed.sociable(PropertyRoot) - .expose(PRIMARY, Provider) - .expose(Cache) - .compile(); - expect(unit.provider).toBeInstanceOf(Provider); - expect(unit.provider.region()).toBe('real'); - expect(() => unitRef.get(PRIMARY)).toThrow(/exposed dependency/); -}); - -@injectable() -class QualifiedRoot { - constructor( - @inject(PRIMARY) @named('primary') readonly primary: Port, - @inject(PRIMARY) @named('primary') readonly repeated: Port, - @inject(PRIMARY) @named('secondary') readonly secondary: Port - ) {} -} -it('preserves qualified binding identity and allows a mock for one qualifier', async () => { - const real = await TestBed.sociable(QualifiedRoot) - .expose(PRIMARY, Provider) - .expose(Cache) - .compile(); - expect(real.unit.primary).toBeInstanceOf(Provider); - expect(real.unit.primary).toBe(real.unit.repeated); - expect(real.unit.primary).not.toBe(real.unit.secondary); - expect(() => real.unitRef.get(PRIMARY, { named: 'primary' })).toThrow(/exposed dependency/); - expect(() => real.unitRef.get(PRIMARY, { named: 'secondary' })).toThrow(/exposed dependency/); - - const mixed = await TestBed.sociable(QualifiedRoot) - .expose(PRIMARY, Provider) - .expose(Cache) - .mock(PRIMARY, { named: 'secondary' }) - .auto() - .compile(); - mixed.unitRef.get(PRIMARY, { named: 'secondary' }).region.mockReturnValue('mock'); - expect(mixed.unit.primary.region()).toBe('real'); - expect(mixed.unit.secondary.region()).toBe('mock'); - expect(mixed.unit.secondary).toBe(mixed.unitRef.get(PRIMARY, { named: 'secondary' })); -}); diff --git a/e2e/jest/nestjs/composition.e2e.test.ts b/e2e/jest/nestjs/composition.e2e.test.ts deleted file mode 100644 index 1f393938..00000000 --- a/e2e/jest/nestjs/composition.e2e.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import 'reflect-metadata'; -import { describe, it, expect } from '@jest/globals'; -import { Injectable } from '@nestjs/common'; -import { TestBed } from '@suites/unit'; - -abstract class Host { - abstract region(): string; -} -@Injectable() -class Leaf { - prefix() { - return 'cache:'; - } -} -@Injectable() -class Repository { - constructor( - readonly host: Host, - readonly leaf: Leaf - ) {} - read() { - return this.leaf.prefix() + this.host.region(); - } -} -@Injectable() -class Service { - constructor(readonly repository: Repository) {} - read() { - return this.repository.read(); - } -} - -describe('opt-in composition', () => { - it('constructs the transitive graph and mocks excluded abstract tokens', async () => { - const { unit, unitRef } = await TestBed.sociable(Service).compose().exclude(Host).compile(); - const host = unitRef.get(Host); - host.region.mockReturnValue('singapore'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).toBeInstanceOf(Leaf); - expect(unit.read()).toBe('cache:singapore'); - expect(() => unitRef.get(Repository)).toThrow(); - expect(() => unitRef.get(Leaf)).toThrow(); - const invalid = () => { - // @ts-expect-error Composition preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Mock inference remains specific after composition. - host.region.mockReturnValue(42); - // @ts-expect-error Boundaries require at least one identifier. - TestBed.sociable(Service).compose().exclude(); - // @ts-expect-error Pass tokens as separate arguments or spread a tuple. - TestBed.sociable(Service).compose().exclude([Host, Leaf]); - // @ts-expect-error Exclusions are available only in composition mode. - TestBed.sociable(Service).exclude(Host); - // @ts-expect-error Selective exposure stays concrete-only. - TestBed.sociable(Service).expose(Host); - // @ts-expect-error Composition and selective exposure are separate strategies. - TestBed.sociable(Service).compose().expose(Repository); - }; - expect(invalid).toBeDefined(); - }); - - it('mocks every variadic boundary and accepts readonly tuples', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .exclude(Host, Leaf) - .compile(); - unitRef.get(Host).region.mockReturnValue('singapore'); - unitRef.get(Leaf).prefix.mockReturnValue('mock:'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).not.toBeInstanceOf(Leaf); - expect(unit.read()).toBe('mock:singapore'); - - const boundaries = [Host, Leaf] as const; - const { unit: spread, unitRef: spreadRef } = await TestBed.sociable(Service) - .compose() - .exclude(...boundaries) - .compile(); - spreadRef.get(Host).region.mockReturnValue('tokyo'); - spreadRef.get(Leaf).prefix.mockReturnValue('spread:'); - expect(spread.read()).toBe('spread:tokyo'); - }); - - it('accumulates distinct boundaries across consecutive exclusions', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .exclude(Host) - .exclude(Leaf) - .compile(); - const host = unitRef.get(Host); - const leaf = unitRef.get(Leaf); - host.region.mockReturnValue('tokyo'); - leaf.prefix.mockReturnValue('mock:'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.host).toBe(host); - expect(unit.repository.leaf).toBe(leaf); - expect(unit.read()).toBe('mock:tokyo'); - expect(host.region).toHaveBeenCalledTimes(1); - expect(leaf.prefix).toHaveBeenCalledTimes(1); - }); - - it('uses explicit automatic mocks as composition boundaries', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .mock(Leaf) - .auto() - .exclude(Host) - .compile(); - const leaf = unitRef.get(Leaf); - expect(leaf.prefix()).toBeUndefined(); - leaf.prefix.mockReturnValue('auto:'); - unitRef.get(Host).region.mockReturnValue('tokyo'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).not.toBeInstanceOf(Leaf); - expect(unit.read()).toBe('auto:tokyo'); - const invalid = () => { - // @ts-expect-error Automatic mock completion preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Automatic mocks preserve method return types. - leaf.prefix.mockReturnValue(42); - // @ts-expect-error A mock selector requires auto, impl, or final. - TestBed.solitary(Service).mock(Repository).compile(); - }; - expect(invalid).toBeDefined(); - }); - - it('preserves selective and solitary chains after automatic mock declarations', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .expose(Leaf) - .mock(Host) - .auto() - .expose(Repository) - .compile(); - unitRef.get(Host).region.mockReturnValue('singapore'); - expect(unit.repository.leaf).toBeInstanceOf(Leaf); - expect(unit.read()).toBe('cache:singapore'); - - const { unit: isolated, unitRef: isolatedRef } = await TestBed.solitary(Service) - .mock(Repository) - .auto() - .compile(); - isolatedRef.get(Repository).read.mockReturnValue('isolated'); - expect(isolated.read()).toBe('isolated'); - }); - - it('preserves the subject type through implementation overrides', async () => { - const { unit } = await TestBed.sociable(Service) - .compose() - .mock(Host) - .impl((stub) => ({ region: stub().mockReturnValue('tokyo') })) - .compile(); - expect(unit.read()).toBe('cache:tokyo'); - const invalid = () => { - // @ts-expect-error Implementation overrides preserve the subject type. - unit.nonexistentMethod(); - }; - expect(invalid).toBeDefined(); - }); - - it('preserves mode methods through typed implementation and final overrides', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .mock(Host) - .impl((stub) => ({ region: stub().mockReturnValue('tokyo') })) - .exclude(Host) - .mock(Leaf) - .final({ prefix: () => 'fake:' }) - .exclude(Host) - .compile(); - const invalid = () => { - // @ts-expect-error Final overrides preserve the subject type. - unit.nonexistentMethod(); - }; - expect(invalid).toBeDefined(); - expect(unit.read()).toBe('fake:tokyo'); - expect(unitRef.get(Host).region).toHaveBeenCalledTimes(1); - expect(() => unitRef.get(Leaf)).toThrow(); - }); - - it('preserves existing V4 selective mock chains and implicit mocking', async () => { - const { unit } = await TestBed.sociable(Service) - .expose(Repository) - .mock(Repository) - .final({ read: () => 'legacy' }) - .compile(); - expect(unit.read()).toBe('legacy'); - const { unit: partial } = await TestBed.sociable(Service).expose(Repository).compile(); - expect(partial.repository).toBeInstanceOf(Repository); - expect(partial.repository.leaf).not.toBeInstanceOf(Leaf); - }); -}); diff --git a/e2e/jest/nestjs/getters.e2e.test.ts b/e2e/jest/nestjs/getters.e2e.test.ts deleted file mode 100644 index d6f78f06..00000000 --- a/e2e/jest/nestjs/getters.e2e.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { jest } from '@jest/globals'; -import 'reflect-metadata'; -import { Injectable } from '@nestjs/common'; -import { TestBed } from '@suites/unit'; -import { mock } from '@suites/doubles.jest'; - -@Injectable() -class ConfigService { - get isCli(): boolean { - throw new Error('The real getter must not run'); - } -} - -@Injectable() -class Application { - constructor(public readonly config: ConfigService) {} - - isCli(): boolean { - return this.config.isCli; - } -} - -describe('Supplied getter implementations', () => { - it('configures a readonly getter through TestBed without assignment', async () => { - let reads = 0; - const { unit, unitRef } = await TestBed.solitary(Application) - .mock(ConfigService) - .impl(() => ({ - get isCli() { - reads += 1; - return false; - }, - })) - .compile(); - - expect(reads).toBe(0); - expect(unit.isCli()).toBe(false); - expect(reads).toBe(1); - const config = unitRef.get(ConfigService); - jest.spyOn(config, 'isCli', 'get').mockReturnValue(true); - expect(unit.isCli()).toBe(true); - }); - - it('supports a standalone configurable getter and a plain false value', () => { - const config = mock({ - get isCli() { - return false; - }, - }); - expect(config.isCli).toBe(false); - jest.spyOn(config, 'isCli', 'get').mockReturnValue(true); - expect(config.isCli).toBe(true); - expect(mock({ isCli: false }).isCli).toBe(false); - }); - - it('retains lazy defaults and allows defining a value on an existing mock', () => { - const config = mock(); - expect(config.isCli).toBeTruthy(); - Object.defineProperty(config, 'isCli', { value: false }); - expect(config.isCli).toBe(false); - }); -}); diff --git a/e2e/jest/nestjs/legacy-compiler.ts b/e2e/jest/nestjs/legacy-compiler.ts deleted file mode 100644 index 57e67aa7..00000000 --- a/e2e/jest/nestjs/legacy-compiler.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { SociableTestBedBuilder } from '@suites/unit'; - -declare const builder: SociableTestBedBuilder<{ run(): void }>; -class Logger { - log(): void {} -} -abstract class Port { - abstract read(): string; -} -class Implementation { - read(): string { - return 'real'; - } -} -class Incompatible { - read(): number { - return 42; - } -} - -builder.expose(Logger).compile(); -builder.expose('port', Implementation).compile(); -builder.expose(Port, Implementation).compile(); - -// @ts-expect-error The implementation must match the token's instance type. -builder.expose(Port, Incompatible); -// @ts-expect-error An abstract implementation cannot be constructed. -builder.expose(Port, Port); - -async function preservesSubject() { - const { unit } = await builder.expose(Port, Implementation).compile(); - unit.run(); - // @ts-expect-error Exposure retains the subject type. - unit.nonexistentMethod(); -} -void preservesSubject; diff --git a/e2e/jest/nestjs/package.json b/e2e/jest/nestjs/package.json index 4ebbd67d..2c7a5504 100644 --- a/e2e/jest/nestjs/package.json +++ b/e2e/jest/nestjs/package.json @@ -20,11 +20,10 @@ "@swc/core": "^1.16.21", "@jest/globals": "^30.5.2", "@types/node": "^26.6.6", - "@suites/types.common": "e2e", - "typescript-legacy": "npm:typescript@5.2.2" + "@suites/types.common": "e2e" }, "scripts": { - "test": "tsc --noEmit && tsc -p tsconfig.skipLibCheck.json --noEmit && node node_modules/typescript-legacy/bin/tsc -p tsconfig.legacy.json && jest", + "test": "tsc --noEmit && tsc -p tsconfig.skipLibCheck.json --noEmit && jest", "lint": "eslint '**/*.ts'" }, "engines": { diff --git a/e2e/jest/nestjs/token-exposure.e2e.test.ts b/e2e/jest/nestjs/token-exposure.e2e.test.ts deleted file mode 100644 index b2b203a4..00000000 --- a/e2e/jest/nestjs/token-exposure.e2e.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import 'reflect-metadata'; -import { describe, it, expect } from '@jest/globals'; -import { Injectable as injectable, Inject as inject } from '@nestjs/common'; -import { TestBed } from '@suites/unit'; - -const PRIMARY = Symbol('provider'); -const SECONDARY = 'provider'; -abstract class Port { - abstract region(): string; -} -@injectable() -class Cache { - read(): string { - return 'real'; - } -} -@injectable() -class Provider extends Port { - constructor(@inject(Cache) readonly cache: Cache) { - super(); - } - region(): string { - return this.cache.read(); - } -} -@injectable() -class Root { - constructor( - @inject(PRIMARY) readonly primary: Port, - @inject(PRIMARY) readonly repeated: Port, - @inject(SECONDARY) readonly secondary: Port, - @inject(Provider) readonly direct: Provider, - @inject(Port) readonly abstract: Port - ) {} -} -@injectable() -class Single { - constructor(@inject(PRIMARY) readonly provider: Port) {} -} - -describe('token exposure', () => { - it('constructs string, symbol and abstract bindings without aliasing token identities', async () => { - const { unit, unitRef } = await TestBed.sociable(Root) - .expose(PRIMARY, Provider) - .expose(SECONDARY, Provider) - .expose(Provider) - .expose(Port, Provider) - .mock(Cache) - .impl((stub) => ({ read: stub().mockReturnValue('configured') })) - .compile(); - expect(unit.primary).toBeInstanceOf(Provider); - expect(unit.primary).toBe(unit.repeated); - expect(unit.primary).not.toBe(unit.secondary); - expect(unit.primary).not.toBe(unit.direct); - expect(unit.primary).not.toBe(unit.abstract); - expect(unit.secondary).toBeInstanceOf(Provider); - expect(unit.abstract).toBeInstanceOf(Provider); - expect(unit.primary.region()).toBe('configured'); - expect(unit.secondary.region()).toBe('configured'); - expect(unit.abstract.region()).toBe('configured'); - expect(unit.direct.cache).toBe(unitRef.get(Cache)); - expect(() => unitRef.get(PRIMARY)).toThrow(/exposed dependency/); - expect(() => unitRef.get(SECONDARY)).toThrow(/exposed dependency/); - expect(() => unitRef.get(Port)).toThrow(/exposed dependency/); - const checkTypes = () => { - // @ts-expect-error Exposure preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Tokens require a concrete implementation. - TestBed.sociable(Single).expose(PRIMARY); - // @ts-expect-error Abstract classes cannot be constructed as implementations. - TestBed.sociable(Single).expose(PRIMARY, Port); - // @ts-expect-error Implementations must satisfy the abstract token's instance type. - TestBed.sociable(Root).expose(Port, Cache); - // @ts-expect-error Implementations must satisfy concrete token instance types too. - TestBed.sociable(Root).expose(Provider, Cache); - // @ts-expect-error Token exposure remains selective, not a composition operation. - TestBed.sociable(Single).compose().expose(PRIMARY, Provider); - }; - expect(checkTypes).toBeDefined(); - }); - - it('keeps normal v4 auto-mocking below the exposed implementation', async () => { - const { unit, unitRef } = await TestBed.sociable(Single).expose(PRIMARY, Provider).compile(); - expect(unit.provider).toBeInstanceOf(Provider); - expect(unit.provider.region()).toBeUndefined(); - unitRef.get(Cache).read.mockReturnValue('configured later'); - expect(unit.provider.region()).toBe('configured later'); - }); - - it('lets automatic, implementation and final overrides replace exposed tokens', async () => { - const automatic = await TestBed.sociable(Single) - .expose(PRIMARY, Provider) - .mock(PRIMARY) - .auto() - .compile(); - automatic.unitRef.get(PRIMARY).region.mockReturnValue('automatic'); - expect(automatic.unit.provider).toBe(automatic.unitRef.get(PRIMARY)); - expect(automatic.unit.provider.region()).toBe('automatic'); - expect(automatic.unit.provider).not.toBeInstanceOf(Provider); - - const implementation = await TestBed.sociable(Single) - .expose(Cache) - .mock(PRIMARY) - .impl((stub) => ({ region: stub().mockReturnValue('implementation') })) - .expose(PRIMARY, Provider) - .compile(); - expect(implementation.unit.provider).toBe(implementation.unitRef.get(PRIMARY)); - expect(implementation.unit.provider.region()).toBe('implementation'); - - const supplied = { region: () => 'final' }; - const final = await TestBed.sociable(Single) - .expose(PRIMARY, Provider) - .mock(PRIMARY) - .final(supplied) - .compile(); - expect(final.unit.provider).toBe(supplied); - expect(() => final.unitRef.get(PRIMARY)).toThrow(/faked dependency/); - }); - - it('resolves nested token bindings', async () => { - const INNER = Symbol('inner'); - @injectable() - class Nested extends Port { - constructor(@inject(INNER) readonly cache: Cache) { - super(); - } - region(): string { - return this.cache.read(); - } - } - const { unit } = await TestBed.sociable(Single) - .expose(PRIMARY, Nested) - .expose(INNER, Cache) - .compile(); - expect(unit.provider).toBeInstanceOf(Nested); - expect(unit.provider.region()).toBe('real'); - }); -}); - -@injectable() -class PropertyRoot { - @inject(PRIMARY) readonly provider!: Port; -} -it('injects a real token implementation into a property', async () => { - const { unit, unitRef } = await TestBed.sociable(PropertyRoot) - .expose(PRIMARY, Provider) - .expose(Cache) - .compile(); - expect(unit.provider).toBeInstanceOf(Provider); - expect(unit.provider.region()).toBe('real'); - expect(() => unitRef.get(PRIMARY)).toThrow(/exposed dependency/); -}); diff --git a/e2e/jest/nestjs/tsconfig.legacy.json b/e2e/jest/nestjs/tsconfig.legacy.json deleted file mode 100644 index 7430e6e6..00000000 --- a/e2e/jest/nestjs/tsconfig.legacy.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "skipLibCheck": false, - "noEmit": true, - "types": [] - }, - "files": ["legacy-compiler.ts"] -} diff --git a/e2e/vitest/injection-js/composition.e2e.test.ts b/e2e/vitest/injection-js/composition.e2e.test.ts deleted file mode 100644 index d1c383ae..00000000 --- a/e2e/vitest/injection-js/composition.e2e.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import 'reflect-metadata'; -import { describe, it, expect } from 'vitest'; -import { Injectable } from 'injection-js'; -import { TestBed } from '@suites/unit'; - -abstract class Host { - abstract region(): string; -} -@Injectable() -class Leaf { - prefix() { - return 'cache:'; - } -} -@Injectable() -class Repository { - constructor( - readonly host: Host, - readonly leaf: Leaf - ) {} - read() { - return this.leaf.prefix() + this.host.region(); - } -} -@Injectable() -class Service { - constructor(readonly repository: Repository) {} - read() { - return this.repository.read(); - } -} - -describe('opt-in composition', () => { - it('constructs the transitive graph and mocks excluded abstract tokens', async () => { - const { unit, unitRef } = await TestBed.sociable(Service).compose().exclude(Host).compile(); - const host = unitRef.get(Host); - host.region.mockReturnValue('singapore'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).toBeInstanceOf(Leaf); - expect(unit.read()).toBe('cache:singapore'); - expect(() => unitRef.get(Repository)).toThrow(); - expect(() => unitRef.get(Leaf)).toThrow(); - const invalid = () => { - // @ts-expect-error Composition preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Mock inference remains specific after composition. - host.region.mockReturnValue(42); - // @ts-expect-error Boundaries require at least one identifier. - TestBed.sociable(Service).compose().exclude(); - // @ts-expect-error Pass tokens as separate arguments or spread a tuple. - TestBed.sociable(Service).compose().exclude([Host, Leaf]); - // @ts-expect-error Exclusions are available only in composition mode. - TestBed.sociable(Service).exclude(Host); - // @ts-expect-error Selective exposure stays concrete-only. - TestBed.sociable(Service).expose(Host); - // @ts-expect-error Composition and selective exposure are separate strategies. - TestBed.sociable(Service).compose().expose(Repository); - }; - expect(invalid).toBeDefined(); - }); - - it('mocks every variadic boundary and accepts readonly tuples', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .exclude(Host, Leaf) - .compile(); - unitRef.get(Host).region.mockReturnValue('singapore'); - unitRef.get(Leaf).prefix.mockReturnValue('mock:'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).not.toBeInstanceOf(Leaf); - expect(unit.read()).toBe('mock:singapore'); - - const boundaries = [Host, Leaf] as const; - const { unit: spread, unitRef: spreadRef } = await TestBed.sociable(Service) - .compose() - .exclude(...boundaries) - .compile(); - spreadRef.get(Host).region.mockReturnValue('tokyo'); - spreadRef.get(Leaf).prefix.mockReturnValue('spread:'); - expect(spread.read()).toBe('spread:tokyo'); - }); - - it('accumulates distinct boundaries across consecutive exclusions', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .exclude(Host) - .exclude(Leaf) - .compile(); - const host = unitRef.get(Host); - const leaf = unitRef.get(Leaf); - host.region.mockReturnValue('tokyo'); - leaf.prefix.mockReturnValue('mock:'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.host).toBe(host); - expect(unit.repository.leaf).toBe(leaf); - expect(unit.read()).toBe('mock:tokyo'); - expect(host.region).toHaveBeenCalledTimes(1); - expect(leaf.prefix).toHaveBeenCalledTimes(1); - }); - - it('uses explicit automatic mocks as composition boundaries', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .mock(Leaf) - .auto() - .exclude(Host) - .compile(); - const leaf = unitRef.get(Leaf); - expect(leaf.prefix()).toBeUndefined(); - leaf.prefix.mockReturnValue('auto:'); - unitRef.get(Host).region.mockReturnValue('tokyo'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).not.toBeInstanceOf(Leaf); - expect(unit.read()).toBe('auto:tokyo'); - const invalid = () => { - // @ts-expect-error Automatic mock completion preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Automatic mocks preserve method return types. - leaf.prefix.mockReturnValue(42); - // @ts-expect-error A mock selector requires auto, impl, or final. - TestBed.solitary(Service).mock(Repository).compile(); - }; - expect(invalid).toBeDefined(); - }); - - it('preserves selective and solitary chains after automatic mock declarations', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .expose(Leaf) - .mock(Host) - .auto() - .expose(Repository) - .compile(); - unitRef.get(Host).region.mockReturnValue('singapore'); - expect(unit.repository.leaf).toBeInstanceOf(Leaf); - expect(unit.read()).toBe('cache:singapore'); - - const { unit: isolated, unitRef: isolatedRef } = await TestBed.solitary(Service) - .mock(Repository) - .auto() - .compile(); - isolatedRef.get(Repository).read.mockReturnValue('isolated'); - expect(isolated.read()).toBe('isolated'); - }); - - it('preserves the subject type through implementation overrides', async () => { - const { unit } = await TestBed.sociable(Service) - .compose() - .mock(Host) - .impl((stub) => ({ region: stub().mockReturnValue('tokyo') })) - .compile(); - expect(unit.read()).toBe('cache:tokyo'); - const invalid = () => { - // @ts-expect-error Implementation overrides preserve the subject type. - unit.nonexistentMethod(); - }; - expect(invalid).toBeDefined(); - }); - - it('preserves mode methods through typed implementation and final overrides', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .mock(Host) - .impl((stub) => ({ region: stub().mockReturnValue('tokyo') })) - .exclude(Host) - .mock(Leaf) - .final({ prefix: () => 'fake:' }) - .exclude(Host) - .compile(); - const invalid = () => { - // @ts-expect-error Final overrides preserve the subject type. - unit.nonexistentMethod(); - }; - expect(invalid).toBeDefined(); - expect(unit.read()).toBe('fake:tokyo'); - expect(unitRef.get(Host).region).toHaveBeenCalledTimes(1); - expect(() => unitRef.get(Leaf)).toThrow(); - }); - - it('preserves existing V4 selective mock chains and implicit mocking', async () => { - const { unit } = await TestBed.sociable(Service) - .expose(Repository) - .mock(Repository) - .final({ read: () => 'legacy' }) - .compile(); - expect(unit.read()).toBe('legacy'); - const { unit: partial } = await TestBed.sociable(Service).expose(Repository).compile(); - expect(partial.repository).toBeInstanceOf(Repository); - expect(partial.repository.leaf).not.toBeInstanceOf(Leaf); - }); -}); diff --git a/e2e/vitest/injection-js/token-exposure.e2e.test.ts b/e2e/vitest/injection-js/token-exposure.e2e.test.ts deleted file mode 100644 index 0e8dd7e8..00000000 --- a/e2e/vitest/injection-js/token-exposure.e2e.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import 'reflect-metadata'; -import { describe, it, expect } from 'vitest'; -import { Injectable as injectable, Inject as inject } from 'injection-js'; -import { TestBed } from '@suites/unit'; - -const PRIMARY = Symbol('provider'); -const SECONDARY = 'provider'; -abstract class Port { - abstract region(): string; -} -@injectable() -class Cache { - read(): string { - return 'real'; - } -} -@injectable() -class Provider extends Port { - constructor(@inject(Cache) readonly cache: Cache) { - super(); - } - region(): string { - return this.cache.read(); - } -} -@injectable() -class Root { - constructor( - @inject(PRIMARY) readonly primary: Port, - @inject(PRIMARY) readonly repeated: Port, - @inject(SECONDARY) readonly secondary: Port, - @inject(Provider) readonly direct: Provider, - @inject(Port) readonly abstract: Port - ) {} -} -@injectable() -class Single { - constructor(@inject(PRIMARY) readonly provider: Port) {} -} - -describe('token exposure', () => { - it('constructs string, symbol and abstract bindings without aliasing token identities', async () => { - const { unit, unitRef } = await TestBed.sociable(Root) - .expose(PRIMARY, Provider) - .expose(SECONDARY, Provider) - .expose(Provider) - .expose(Port, Provider) - .mock(Cache) - .impl((stub) => ({ read: stub().mockReturnValue('configured') })) - .compile(); - expect(unit.primary).toBeInstanceOf(Provider); - expect(unit.primary).toBe(unit.repeated); - expect(unit.primary).not.toBe(unit.secondary); - expect(unit.primary).not.toBe(unit.direct); - expect(unit.primary).not.toBe(unit.abstract); - expect(unit.secondary).toBeInstanceOf(Provider); - expect(unit.abstract).toBeInstanceOf(Provider); - expect(unit.primary.region()).toBe('configured'); - expect(unit.secondary.region()).toBe('configured'); - expect(unit.abstract.region()).toBe('configured'); - expect(unit.direct.cache).toBe(unitRef.get(Cache)); - expect(() => unitRef.get(PRIMARY)).toThrow(/exposed dependency/); - expect(() => unitRef.get(SECONDARY)).toThrow(/exposed dependency/); - expect(() => unitRef.get(Port)).toThrow(/exposed dependency/); - const checkTypes = () => { - // @ts-expect-error Exposure preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Tokens require a concrete implementation. - TestBed.sociable(Single).expose(PRIMARY); - // @ts-expect-error Abstract classes cannot be constructed as implementations. - TestBed.sociable(Single).expose(PRIMARY, Port); - // @ts-expect-error Implementations must satisfy the abstract token's instance type. - TestBed.sociable(Root).expose(Port, Cache); - // @ts-expect-error Implementations must satisfy concrete token instance types too. - TestBed.sociable(Root).expose(Provider, Cache); - // @ts-expect-error Token exposure remains selective, not a composition operation. - TestBed.sociable(Single).compose().expose(PRIMARY, Provider); - }; - expect(checkTypes).toBeDefined(); - }); - - it('keeps normal v4 auto-mocking below the exposed implementation', async () => { - const { unit, unitRef } = await TestBed.sociable(Single).expose(PRIMARY, Provider).compile(); - expect(unit.provider).toBeInstanceOf(Provider); - expect(unit.provider.region()).toBeUndefined(); - unitRef.get(Cache).read.mockReturnValue('configured later'); - expect(unit.provider.region()).toBe('configured later'); - }); - - it('lets automatic, implementation and final overrides replace exposed tokens', async () => { - const automatic = await TestBed.sociable(Single) - .expose(PRIMARY, Provider) - .mock(PRIMARY) - .auto() - .compile(); - automatic.unitRef.get(PRIMARY).region.mockReturnValue('automatic'); - expect(automatic.unit.provider).toBe(automatic.unitRef.get(PRIMARY)); - expect(automatic.unit.provider.region()).toBe('automatic'); - expect(automatic.unit.provider).not.toBeInstanceOf(Provider); - - const implementation = await TestBed.sociable(Single) - .expose(Cache) - .mock(PRIMARY) - .impl((stub) => ({ region: stub().mockReturnValue('implementation') })) - .expose(PRIMARY, Provider) - .compile(); - expect(implementation.unit.provider).toBe(implementation.unitRef.get(PRIMARY)); - expect(implementation.unit.provider.region()).toBe('implementation'); - - const supplied = { region: () => 'final' }; - const final = await TestBed.sociable(Single) - .expose(PRIMARY, Provider) - .mock(PRIMARY) - .final(supplied) - .compile(); - expect(final.unit.provider).toBe(supplied); - expect(() => final.unitRef.get(PRIMARY)).toThrow(/faked dependency/); - }); - - it('resolves nested token bindings', async () => { - const INNER = Symbol('inner'); - @injectable() - class Nested extends Port { - constructor(@inject(INNER) readonly cache: Cache) { - super(); - } - region(): string { - return this.cache.read(); - } - } - const { unit } = await TestBed.sociable(Single) - .expose(PRIMARY, Nested) - .expose(INNER, Cache) - .compile(); - expect(unit.provider).toBeInstanceOf(Nested); - expect(unit.provider.region()).toBe('real'); - }); -}); diff --git a/e2e/vitest/inversify/composition.e2e.test.ts b/e2e/vitest/inversify/composition.e2e.test.ts deleted file mode 100644 index 04c5f02f..00000000 --- a/e2e/vitest/inversify/composition.e2e.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import 'reflect-metadata'; -import { describe, it, expect } from 'vitest'; -import { inject, injectable } from 'inversify'; -import { TestBed } from '@suites/unit'; - -abstract class Host { - abstract region(): string; -} -@injectable() -class Leaf { - prefix() { - return 'cache:'; - } -} -@injectable() -class Repository { - constructor( - @inject(Host) readonly host: Host, - @inject(Leaf) readonly leaf: Leaf - ) {} - read() { - return this.leaf.prefix() + this.host.region(); - } -} -@injectable() -class Service { - constructor(@inject(Repository) readonly repository: Repository) {} - read() { - return this.repository.read(); - } -} - -describe('opt-in composition', () => { - it('constructs the transitive graph and mocks excluded abstract tokens', async () => { - const { unit, unitRef } = await TestBed.sociable(Service).compose().exclude(Host).compile(); - const host = unitRef.get(Host); - host.region.mockReturnValue('singapore'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).toBeInstanceOf(Leaf); - expect(unit.read()).toBe('cache:singapore'); - expect(() => unitRef.get(Repository)).toThrow(); - expect(() => unitRef.get(Leaf)).toThrow(); - const invalid = () => { - // @ts-expect-error Composition preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Mock inference remains specific after composition. - host.region.mockReturnValue(42); - // @ts-expect-error Boundaries require at least one identifier. - TestBed.sociable(Service).compose().exclude(); - // @ts-expect-error Pass tokens as separate arguments or spread a tuple. - TestBed.sociable(Service).compose().exclude([Host, Leaf]); - // @ts-expect-error Exclusions are available only in composition mode. - TestBed.sociable(Service).exclude(Host); - // @ts-expect-error Selective exposure stays concrete-only. - TestBed.sociable(Service).expose(Host); - // @ts-expect-error Composition and selective exposure are separate strategies. - TestBed.sociable(Service).compose().expose(Repository); - }; - expect(invalid).toBeDefined(); - }); - - it('mocks every variadic boundary and accepts readonly tuples', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .exclude(Host, Leaf) - .compile(); - unitRef.get(Host).region.mockReturnValue('singapore'); - unitRef.get(Leaf).prefix.mockReturnValue('mock:'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).not.toBeInstanceOf(Leaf); - expect(unit.read()).toBe('mock:singapore'); - - const boundaries = [Host, Leaf] as const; - const { unit: spread, unitRef: spreadRef } = await TestBed.sociable(Service) - .compose() - .exclude(...boundaries) - .compile(); - spreadRef.get(Host).region.mockReturnValue('tokyo'); - spreadRef.get(Leaf).prefix.mockReturnValue('spread:'); - expect(spread.read()).toBe('spread:tokyo'); - }); - - it('accumulates distinct boundaries across consecutive exclusions', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .exclude(Host) - .exclude(Leaf) - .compile(); - const host = unitRef.get(Host); - const leaf = unitRef.get(Leaf); - host.region.mockReturnValue('tokyo'); - leaf.prefix.mockReturnValue('mock:'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.host).toBe(host); - expect(unit.repository.leaf).toBe(leaf); - expect(unit.read()).toBe('mock:tokyo'); - expect(host.region).toHaveBeenCalledTimes(1); - expect(leaf.prefix).toHaveBeenCalledTimes(1); - }); - - it('uses explicit automatic mocks as composition boundaries', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .mock(Leaf) - .auto() - .exclude(Host) - .compile(); - const leaf = unitRef.get(Leaf); - expect(leaf.prefix()).toBeUndefined(); - leaf.prefix.mockReturnValue('auto:'); - unitRef.get(Host).region.mockReturnValue('tokyo'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).not.toBeInstanceOf(Leaf); - expect(unit.read()).toBe('auto:tokyo'); - const invalid = () => { - // @ts-expect-error Automatic mock completion preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Automatic mocks preserve method return types. - leaf.prefix.mockReturnValue(42); - // @ts-expect-error A mock selector requires auto, impl, or final. - TestBed.solitary(Service).mock(Repository).compile(); - }; - expect(invalid).toBeDefined(); - }); - - it('preserves selective and solitary chains after automatic mock declarations', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .expose(Leaf) - .mock(Host) - .auto() - .expose(Repository) - .compile(); - unitRef.get(Host).region.mockReturnValue('singapore'); - expect(unit.repository.leaf).toBeInstanceOf(Leaf); - expect(unit.read()).toBe('cache:singapore'); - - const { unit: isolated, unitRef: isolatedRef } = await TestBed.solitary(Service) - .mock(Repository) - .auto() - .compile(); - isolatedRef.get(Repository).read.mockReturnValue('isolated'); - expect(isolated.read()).toBe('isolated'); - }); - - it('preserves the subject type through implementation overrides', async () => { - const { unit } = await TestBed.sociable(Service) - .compose() - .mock(Host) - .impl((stub) => ({ region: stub().mockReturnValue('tokyo') })) - .compile(); - expect(unit.read()).toBe('cache:tokyo'); - const invalid = () => { - // @ts-expect-error Implementation overrides preserve the subject type. - unit.nonexistentMethod(); - }; - expect(invalid).toBeDefined(); - }); - - it('preserves mode methods through typed implementation and final overrides', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .mock(Host) - .impl((stub) => ({ region: stub().mockReturnValue('tokyo') })) - .exclude(Host) - .mock(Leaf) - .final({ prefix: () => 'fake:' }) - .exclude(Host) - .compile(); - const invalid = () => { - // @ts-expect-error Final overrides preserve the subject type. - unit.nonexistentMethod(); - }; - expect(invalid).toBeDefined(); - expect(unit.read()).toBe('fake:tokyo'); - expect(unitRef.get(Host).region).toHaveBeenCalledTimes(1); - expect(() => unitRef.get(Leaf)).toThrow(); - }); - - it('preserves existing V4 selective mock chains and implicit mocking', async () => { - const { unit } = await TestBed.sociable(Service) - .expose(Repository) - .mock(Repository) - .final({ read: () => 'legacy' }) - .compile(); - expect(unit.read()).toBe('legacy'); - const { unit: partial } = await TestBed.sociable(Service).expose(Repository).compile(); - expect(partial.repository).toBeInstanceOf(Repository); - expect(partial.repository.leaf).not.toBeInstanceOf(Leaf); - }); -}); diff --git a/e2e/vitest/inversify/qualified-dependencies.e2e.test.ts b/e2e/vitest/inversify/qualified-dependencies.e2e.test.ts deleted file mode 100644 index e3f210c9..00000000 --- a/e2e/vitest/inversify/qualified-dependencies.e2e.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import 'reflect-metadata'; -import { expect, it } from 'vitest'; -import { inject, injectable, named } from 'inversify'; -import { TestBed } from '@suites/unit'; - -@injectable() -class Provider { - label(): string { - return 'real'; - } -} - -@injectable() -class SingleConsumer { - constructor(@inject(Provider) @named('primary') readonly provider: Provider) {} -} - -it('rejects final retrieval through both qualified and unique bare-token lookups', async () => { - const supplied = { label: () => 'final' }; - const { unit, unitRef } = await TestBed.solitary(SingleConsumer) - .mock(Provider, { named: 'primary' }) - .final(supplied) - .compile(); - - expect(unit.provider).toBe(supplied); - expect(unit.provider.label()).toBe('final'); - expect(() => unitRef.get(Provider, { named: 'primary' })).toThrow(/faked dependency/); - expect(() => unitRef.get(Provider)).toThrow(/faked dependency/); -}); - -@injectable() -class DoubleConsumer { - constructor( - @inject(Provider) @named('primary') readonly primary: Provider, - @inject(Provider) @named('secondary') readonly secondary: Provider - ) {} -} - -it('keeps named final and implementation overrides distinct for the same class', async () => { - const supplied = { label: () => 'final' }; - const { unit, unitRef } = await TestBed.solitary(DoubleConsumer) - .mock(Provider, { named: 'primary' }) - .final(supplied) - .mock(Provider, { named: 'secondary' }) - .impl((stub) => ({ label: stub().mockReturnValue('mock') })) - .compile(); - - const mocked = unitRef.get(Provider, { named: 'secondary' }); - expect(unit.primary).toBe(supplied); - expect(unit.secondary).toBe(mocked); - expect(unit.primary.label()).toBe('final'); - expect(unit.secondary.label()).toBe('mock'); - expect(unit.primary).not.toBe(unit.secondary); - mocked.label.mockReturnValue('updated'); - expect(unit.secondary.label()).toBe('updated'); - expect(unit.primary.label()).toBe('final'); - expect(() => unitRef.get(Provider, { named: 'primary' })).toThrow(/faked dependency/); -}); - -it('creates independent automatic mocks for differently named occurrences', async () => { - const { unit, unitRef } = await TestBed.solitary(DoubleConsumer).compile(); - const primary = unitRef.get(Provider, { named: 'primary' }); - const secondary = unitRef.get(Provider, { named: 'secondary' }); - primary.label.mockReturnValue('primary'); - secondary.label.mockReturnValue('secondary'); - - expect(primary).not.toBe(secondary); - expect(unit.primary).toBe(primary); - expect(unit.secondary).toBe(secondary); - expect(unit.primary.label()).toBe('primary'); - expect(unit.secondary.label()).toBe('secondary'); - expect(() => unitRef.get(Provider)).toThrow(/could not be located/); -}); - -it('keeps automatic mock selectors bound to their named dependency', async () => { - const builder = TestBed.sociable(DoubleConsumer).compose(); - const primary = builder.mock(Provider, { named: 'primary' }); - const secondary = builder.mock(Provider, { named: 'secondary' }); - secondary.final({ label: () => 'supplied' }); - primary.auto(); - const { unit, unitRef } = await builder.compile(); - const mocked = unitRef.get(Provider, { named: 'primary' }); - mocked.label.mockReturnValue('automatic'); - expect(unit.primary).toBe(mocked); - expect(unit.primary.label()).toBe('automatic'); - expect(unit.secondary.label()).toBe('supplied'); - expect(unit.primary).not.toBe(unit.secondary); - expect(() => unitRef.get(Provider, { named: 'secondary' })).toThrow(/faked dependency/); -}); diff --git a/e2e/vitest/inversify/token-exposure.e2e.test.ts b/e2e/vitest/inversify/token-exposure.e2e.test.ts deleted file mode 100644 index b7a25c55..00000000 --- a/e2e/vitest/inversify/token-exposure.e2e.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import 'reflect-metadata'; -import { describe, it, expect } from 'vitest'; -import { injectable, inject, named } from 'inversify'; -import { TestBed } from '@suites/unit'; - -const PRIMARY = Symbol('provider'); -const SECONDARY = 'provider'; -abstract class Port { - abstract region(): string; -} -@injectable() -class Cache { - read(): string { - return 'real'; - } -} -@injectable() -class Provider extends Port { - constructor(@inject(Cache) readonly cache: Cache) { - super(); - } - region(): string { - return this.cache.read(); - } -} -@injectable() -class Root { - constructor( - @inject(PRIMARY) readonly primary: Port, - @inject(PRIMARY) readonly repeated: Port, - @inject(SECONDARY) readonly secondary: Port, - @inject(Provider) readonly direct: Provider, - @inject(Port) readonly abstract: Port - ) {} -} -@injectable() -class Single { - constructor(@inject(PRIMARY) readonly provider: Port) {} -} - -describe('token exposure', () => { - it('constructs string, symbol and abstract bindings without aliasing token identities', async () => { - const { unit, unitRef } = await TestBed.sociable(Root) - .expose(PRIMARY, Provider) - .expose(SECONDARY, Provider) - .expose(Provider) - .expose(Port, Provider) - .mock(Cache) - .impl((stub) => ({ read: stub().mockReturnValue('configured') })) - .compile(); - expect(unit.primary).toBeInstanceOf(Provider); - expect(unit.primary).toBe(unit.repeated); - expect(unit.primary).not.toBe(unit.secondary); - expect(unit.primary).not.toBe(unit.direct); - expect(unit.primary).not.toBe(unit.abstract); - expect(unit.secondary).toBeInstanceOf(Provider); - expect(unit.abstract).toBeInstanceOf(Provider); - expect(unit.primary.region()).toBe('configured'); - expect(unit.secondary.region()).toBe('configured'); - expect(unit.abstract.region()).toBe('configured'); - expect(unit.direct.cache).toBe(unitRef.get(Cache)); - expect(() => unitRef.get(PRIMARY)).toThrow(/exposed dependency/); - expect(() => unitRef.get(SECONDARY)).toThrow(/exposed dependency/); - expect(() => unitRef.get(Port)).toThrow(/exposed dependency/); - const checkTypes = () => { - // @ts-expect-error Exposure preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Tokens require a concrete implementation. - TestBed.sociable(Single).expose(PRIMARY); - // @ts-expect-error Abstract classes cannot be constructed as implementations. - TestBed.sociable(Single).expose(PRIMARY, Port); - // @ts-expect-error Implementations must satisfy the abstract token's instance type. - TestBed.sociable(Root).expose(Port, Cache); - // @ts-expect-error Implementations must satisfy concrete token instance types too. - TestBed.sociable(Root).expose(Provider, Cache); - // @ts-expect-error Token exposure remains selective, not a composition operation. - TestBed.sociable(Single).compose().expose(PRIMARY, Provider); - }; - expect(checkTypes).toBeDefined(); - }); - - it('keeps normal v4 auto-mocking below the exposed implementation', async () => { - const { unit, unitRef } = await TestBed.sociable(Single).expose(PRIMARY, Provider).compile(); - expect(unit.provider).toBeInstanceOf(Provider); - expect(unit.provider.region()).toBeUndefined(); - unitRef.get(Cache).read.mockReturnValue('configured later'); - expect(unit.provider.region()).toBe('configured later'); - }); - - it('lets automatic, implementation and final overrides replace exposed tokens', async () => { - const automatic = await TestBed.sociable(Single) - .expose(PRIMARY, Provider) - .mock(PRIMARY) - .auto() - .compile(); - automatic.unitRef.get(PRIMARY).region.mockReturnValue('automatic'); - expect(automatic.unit.provider).toBe(automatic.unitRef.get(PRIMARY)); - expect(automatic.unit.provider.region()).toBe('automatic'); - expect(automatic.unit.provider).not.toBeInstanceOf(Provider); - - const implementation = await TestBed.sociable(Single) - .expose(Cache) - .mock(PRIMARY) - .impl((stub) => ({ region: stub().mockReturnValue('implementation') })) - .expose(PRIMARY, Provider) - .compile(); - expect(implementation.unit.provider).toBe(implementation.unitRef.get(PRIMARY)); - expect(implementation.unit.provider.region()).toBe('implementation'); - - const supplied = { region: () => 'final' }; - const final = await TestBed.sociable(Single) - .expose(PRIMARY, Provider) - .mock(PRIMARY) - .final(supplied) - .compile(); - expect(final.unit.provider).toBe(supplied); - expect(() => final.unitRef.get(PRIMARY)).toThrow(/faked dependency/); - }); - - it('resolves nested token bindings', async () => { - const INNER = Symbol('inner'); - @injectable() - class Nested extends Port { - constructor(@inject(INNER) readonly cache: Cache) { - super(); - } - region(): string { - return this.cache.read(); - } - } - const { unit } = await TestBed.sociable(Single) - .expose(PRIMARY, Nested) - .expose(INNER, Cache) - .compile(); - expect(unit.provider).toBeInstanceOf(Nested); - expect(unit.provider.region()).toBe('real'); - }); -}); - -@injectable() -class PropertyRoot { - @inject(PRIMARY) readonly provider!: Port; -} -it('injects a real token implementation into a property', async () => { - const { unit, unitRef } = await TestBed.sociable(PropertyRoot) - .expose(PRIMARY, Provider) - .expose(Cache) - .compile(); - expect(unit.provider).toBeInstanceOf(Provider); - expect(unit.provider.region()).toBe('real'); - expect(() => unitRef.get(PRIMARY)).toThrow(/exposed dependency/); -}); - -@injectable() -class QualifiedRoot { - constructor( - @inject(PRIMARY) @named('primary') readonly primary: Port, - @inject(PRIMARY) @named('primary') readonly repeated: Port, - @inject(PRIMARY) @named('secondary') readonly secondary: Port - ) {} -} -it('preserves qualified binding identity and allows a mock for one qualifier', async () => { - const real = await TestBed.sociable(QualifiedRoot) - .expose(PRIMARY, Provider) - .expose(Cache) - .compile(); - expect(real.unit.primary).toBeInstanceOf(Provider); - expect(real.unit.primary).toBe(real.unit.repeated); - expect(real.unit.primary).not.toBe(real.unit.secondary); - expect(() => real.unitRef.get(PRIMARY, { named: 'primary' })).toThrow(/exposed dependency/); - expect(() => real.unitRef.get(PRIMARY, { named: 'secondary' })).toThrow(/exposed dependency/); - - const mixed = await TestBed.sociable(QualifiedRoot) - .expose(PRIMARY, Provider) - .expose(Cache) - .mock(PRIMARY, { named: 'secondary' }) - .auto() - .compile(); - mixed.unitRef.get(PRIMARY, { named: 'secondary' }).region.mockReturnValue('mock'); - expect(mixed.unit.primary.region()).toBe('real'); - expect(mixed.unit.secondary.region()).toBe('mock'); - expect(mixed.unit.secondary).toBe(mixed.unitRef.get(PRIMARY, { named: 'secondary' })); -}); diff --git a/e2e/vitest/nestjs/composition.e2e.test.ts b/e2e/vitest/nestjs/composition.e2e.test.ts deleted file mode 100644 index a753a662..00000000 --- a/e2e/vitest/nestjs/composition.e2e.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import 'reflect-metadata'; -import { describe, it, expect } from 'vitest'; -import { Injectable } from '@nestjs/common'; -import { TestBed } from '@suites/unit'; - -abstract class Host { - abstract region(): string; -} -@Injectable() -class Leaf { - prefix() { - return 'cache:'; - } -} -@Injectable() -class Repository { - constructor( - readonly host: Host, - readonly leaf: Leaf - ) {} - read() { - return this.leaf.prefix() + this.host.region(); - } -} -@Injectable() -class Service { - constructor(readonly repository: Repository) {} - read() { - return this.repository.read(); - } -} - -describe('opt-in composition', () => { - it('constructs the transitive graph and mocks excluded abstract tokens', async () => { - const { unit, unitRef } = await TestBed.sociable(Service).compose().exclude(Host).compile(); - const host = unitRef.get(Host); - host.region.mockReturnValue('singapore'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).toBeInstanceOf(Leaf); - expect(unit.read()).toBe('cache:singapore'); - expect(() => unitRef.get(Repository)).toThrow(); - expect(() => unitRef.get(Leaf)).toThrow(); - const invalid = () => { - // @ts-expect-error Composition preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Mock inference remains specific after composition. - host.region.mockReturnValue(42); - // @ts-expect-error Boundaries require at least one identifier. - TestBed.sociable(Service).compose().exclude(); - // @ts-expect-error Pass tokens as separate arguments or spread a tuple. - TestBed.sociable(Service).compose().exclude([Host, Leaf]); - // @ts-expect-error Exclusions are available only in composition mode. - TestBed.sociable(Service).exclude(Host); - // @ts-expect-error Selective exposure stays concrete-only. - TestBed.sociable(Service).expose(Host); - // @ts-expect-error Composition and selective exposure are separate strategies. - TestBed.sociable(Service).compose().expose(Repository); - }; - expect(invalid).toBeDefined(); - }); - - it('mocks every variadic boundary and accepts readonly tuples', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .exclude(Host, Leaf) - .compile(); - unitRef.get(Host).region.mockReturnValue('singapore'); - unitRef.get(Leaf).prefix.mockReturnValue('mock:'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).not.toBeInstanceOf(Leaf); - expect(unit.read()).toBe('mock:singapore'); - - const boundaries = [Host, Leaf] as const; - const { unit: spread, unitRef: spreadRef } = await TestBed.sociable(Service) - .compose() - .exclude(...boundaries) - .compile(); - spreadRef.get(Host).region.mockReturnValue('tokyo'); - spreadRef.get(Leaf).prefix.mockReturnValue('spread:'); - expect(spread.read()).toBe('spread:tokyo'); - }); - - it('accumulates distinct boundaries across consecutive exclusions', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .exclude(Host) - .exclude(Leaf) - .compile(); - const host = unitRef.get(Host); - const leaf = unitRef.get(Leaf); - host.region.mockReturnValue('tokyo'); - leaf.prefix.mockReturnValue('mock:'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.host).toBe(host); - expect(unit.repository.leaf).toBe(leaf); - expect(unit.read()).toBe('mock:tokyo'); - expect(host.region).toHaveBeenCalledTimes(1); - expect(leaf.prefix).toHaveBeenCalledTimes(1); - }); - - it('uses explicit automatic mocks as composition boundaries', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .mock(Leaf) - .auto() - .exclude(Host) - .compile(); - const leaf = unitRef.get(Leaf); - expect(leaf.prefix()).toBeUndefined(); - leaf.prefix.mockReturnValue('auto:'); - unitRef.get(Host).region.mockReturnValue('tokyo'); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.leaf).not.toBeInstanceOf(Leaf); - expect(unit.read()).toBe('auto:tokyo'); - const invalid = () => { - // @ts-expect-error Automatic mock completion preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Automatic mocks preserve method return types. - leaf.prefix.mockReturnValue(42); - // @ts-expect-error A mock selector requires auto, impl, or final. - TestBed.solitary(Service).mock(Repository).compile(); - }; - expect(invalid).toBeDefined(); - }); - - it('preserves selective and solitary chains after automatic mock declarations', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .expose(Leaf) - .mock(Host) - .auto() - .expose(Repository) - .compile(); - unitRef.get(Host).region.mockReturnValue('singapore'); - expect(unit.repository.leaf).toBeInstanceOf(Leaf); - expect(unit.read()).toBe('cache:singapore'); - - const { unit: isolated, unitRef: isolatedRef } = await TestBed.solitary(Service) - .mock(Repository) - .auto() - .compile(); - isolatedRef.get(Repository).read.mockReturnValue('isolated'); - expect(isolated.read()).toBe('isolated'); - }); - - it('preserves the subject type through implementation overrides', async () => { - const { unit } = await TestBed.sociable(Service) - .compose() - .mock(Host) - .impl((stub) => ({ region: stub().mockReturnValue('tokyo') })) - .compile(); - expect(unit.read()).toBe('cache:tokyo'); - const invalid = () => { - // @ts-expect-error Implementation overrides preserve the subject type. - unit.nonexistentMethod(); - }; - expect(invalid).toBeDefined(); - }); - - it('preserves mode methods through typed implementation and final overrides', async () => { - const { unit, unitRef } = await TestBed.sociable(Service) - .compose() - .mock(Host) - .impl((stub) => ({ region: stub().mockReturnValue('tokyo') })) - .exclude(Host) - .mock(Leaf) - .final({ prefix: () => 'fake:' }) - .exclude(Host) - .compile(); - const invalid = () => { - // @ts-expect-error Final overrides preserve the subject type. - unit.nonexistentMethod(); - }; - expect(invalid).toBeDefined(); - expect(unit.read()).toBe('fake:tokyo'); - expect(unitRef.get(Host).region).toHaveBeenCalledTimes(1); - expect(() => unitRef.get(Leaf)).toThrow(); - }); - - it('preserves existing V4 selective mock chains and implicit mocking', async () => { - const { unit } = await TestBed.sociable(Service) - .expose(Repository) - .mock(Repository) - .final({ read: () => 'legacy' }) - .compile(); - expect(unit.read()).toBe('legacy'); - const { unit: partial } = await TestBed.sociable(Service).expose(Repository).compile(); - expect(partial.repository).toBeInstanceOf(Repository); - expect(partial.repository.leaf).not.toBeInstanceOf(Leaf); - }); -}); diff --git a/e2e/vitest/nestjs/getters.e2e.test.ts b/e2e/vitest/nestjs/getters.e2e.test.ts deleted file mode 100644 index 65562d32..00000000 --- a/e2e/vitest/nestjs/getters.e2e.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import 'reflect-metadata'; -import { Injectable } from '@nestjs/common'; -import { TestBed } from '@suites/unit'; -import { mock } from '@suites/doubles.vitest'; - -@Injectable() -class ConfigService { - get isCli(): boolean { - throw new Error('The real getter must not run'); - } -} - -@Injectable() -class Application { - constructor(public readonly config: ConfigService) {} - - isCli(): boolean { - return this.config.isCli; - } -} - -describe('Supplied getter implementations', () => { - it('configures a readonly getter through TestBed without assignment', async () => { - let reads = 0; - const { unit, unitRef } = await TestBed.solitary(Application) - .mock(ConfigService) - .impl(() => ({ - get isCli() { - reads += 1; - return false; - }, - })) - .compile(); - - expect(reads).toBe(0); - expect(unit.isCli()).toBe(false); - expect(reads).toBe(1); - const config = unitRef.get(ConfigService); - vi.spyOn(config, 'isCli', 'get').mockReturnValue(true); - expect(unit.isCli()).toBe(true); - }); - - it('supports a standalone configurable getter and a plain false value', () => { - const config = mock({ - get isCli() { - return false; - }, - }); - expect(config.isCli).toBe(false); - vi.spyOn(config, 'isCli', 'get').mockReturnValue(true); - expect(config.isCli).toBe(true); - expect(mock({ isCli: false }).isCli).toBe(false); - }); - - it('retains lazy defaults and allows defining a value on an existing mock', () => { - const config = mock(); - expect(config.isCli).toBeTruthy(); - Object.defineProperty(config, 'isCli', { value: false }); - expect(config.isCli).toBe(false); - }); -}); diff --git a/e2e/vitest/nestjs/token-exposure.e2e.test.ts b/e2e/vitest/nestjs/token-exposure.e2e.test.ts deleted file mode 100644 index 1061e1b1..00000000 --- a/e2e/vitest/nestjs/token-exposure.e2e.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import 'reflect-metadata'; -import { describe, it, expect } from 'vitest'; -import { Injectable as injectable, Inject as inject } from '@nestjs/common'; -import { TestBed } from '@suites/unit'; - -const PRIMARY = Symbol('provider'); -const SECONDARY = 'provider'; -abstract class Port { - abstract region(): string; -} -@injectable() -class Cache { - read(): string { - return 'real'; - } -} -@injectable() -class Provider extends Port { - constructor(@inject(Cache) readonly cache: Cache) { - super(); - } - region(): string { - return this.cache.read(); - } -} -@injectable() -class Root { - constructor( - @inject(PRIMARY) readonly primary: Port, - @inject(PRIMARY) readonly repeated: Port, - @inject(SECONDARY) readonly secondary: Port, - @inject(Provider) readonly direct: Provider, - @inject(Port) readonly abstract: Port - ) {} -} -@injectable() -class Single { - constructor(@inject(PRIMARY) readonly provider: Port) {} -} - -describe('token exposure', () => { - it('constructs string, symbol and abstract bindings without aliasing token identities', async () => { - const { unit, unitRef } = await TestBed.sociable(Root) - .expose(PRIMARY, Provider) - .expose(SECONDARY, Provider) - .expose(Provider) - .expose(Port, Provider) - .mock(Cache) - .impl((stub) => ({ read: stub().mockReturnValue('configured') })) - .compile(); - expect(unit.primary).toBeInstanceOf(Provider); - expect(unit.primary).toBe(unit.repeated); - expect(unit.primary).not.toBe(unit.secondary); - expect(unit.primary).not.toBe(unit.direct); - expect(unit.primary).not.toBe(unit.abstract); - expect(unit.secondary).toBeInstanceOf(Provider); - expect(unit.abstract).toBeInstanceOf(Provider); - expect(unit.primary.region()).toBe('configured'); - expect(unit.secondary.region()).toBe('configured'); - expect(unit.abstract.region()).toBe('configured'); - expect(unit.direct.cache).toBe(unitRef.get(Cache)); - expect(() => unitRef.get(PRIMARY)).toThrow(/exposed dependency/); - expect(() => unitRef.get(SECONDARY)).toThrow(/exposed dependency/); - expect(() => unitRef.get(Port)).toThrow(/exposed dependency/); - const checkTypes = () => { - // @ts-expect-error Exposure preserves the subject type. - unit.nonexistentMethod(); - // @ts-expect-error Tokens require a concrete implementation. - TestBed.sociable(Single).expose(PRIMARY); - // @ts-expect-error Abstract classes cannot be constructed as implementations. - TestBed.sociable(Single).expose(PRIMARY, Port); - // @ts-expect-error Implementations must satisfy the abstract token's instance type. - TestBed.sociable(Root).expose(Port, Cache); - // @ts-expect-error Implementations must satisfy concrete token instance types too. - TestBed.sociable(Root).expose(Provider, Cache); - // @ts-expect-error Token exposure remains selective, not a composition operation. - TestBed.sociable(Single).compose().expose(PRIMARY, Provider); - }; - expect(checkTypes).toBeDefined(); - }); - - it('keeps normal v4 auto-mocking below the exposed implementation', async () => { - const { unit, unitRef } = await TestBed.sociable(Single).expose(PRIMARY, Provider).compile(); - expect(unit.provider).toBeInstanceOf(Provider); - expect(unit.provider.region()).toBeUndefined(); - unitRef.get(Cache).read.mockReturnValue('configured later'); - expect(unit.provider.region()).toBe('configured later'); - }); - - it('lets automatic, implementation and final overrides replace exposed tokens', async () => { - const automatic = await TestBed.sociable(Single) - .expose(PRIMARY, Provider) - .mock(PRIMARY) - .auto() - .compile(); - automatic.unitRef.get(PRIMARY).region.mockReturnValue('automatic'); - expect(automatic.unit.provider).toBe(automatic.unitRef.get(PRIMARY)); - expect(automatic.unit.provider.region()).toBe('automatic'); - expect(automatic.unit.provider).not.toBeInstanceOf(Provider); - - const implementation = await TestBed.sociable(Single) - .expose(Cache) - .mock(PRIMARY) - .impl((stub) => ({ region: stub().mockReturnValue('implementation') })) - .expose(PRIMARY, Provider) - .compile(); - expect(implementation.unit.provider).toBe(implementation.unitRef.get(PRIMARY)); - expect(implementation.unit.provider.region()).toBe('implementation'); - - const supplied = { region: () => 'final' }; - const final = await TestBed.sociable(Single) - .expose(PRIMARY, Provider) - .mock(PRIMARY) - .final(supplied) - .compile(); - expect(final.unit.provider).toBe(supplied); - expect(() => final.unitRef.get(PRIMARY)).toThrow(/faked dependency/); - }); - - it('resolves nested token bindings', async () => { - const INNER = Symbol('inner'); - @injectable() - class Nested extends Port { - constructor(@inject(INNER) readonly cache: Cache) { - super(); - } - region(): string { - return this.cache.read(); - } - } - const { unit } = await TestBed.sociable(Single) - .expose(PRIMARY, Nested) - .expose(INNER, Cache) - .compile(); - expect(unit.provider).toBeInstanceOf(Nested); - expect(unit.provider.region()).toBe('real'); - }); -}); - -@injectable() -class PropertyRoot { - @inject(PRIMARY) readonly provider!: Port; -} -it('injects a real token implementation into a property', async () => { - const { unit, unitRef } = await TestBed.sociable(PropertyRoot) - .expose(PRIMARY, Provider) - .expose(Cache) - .compile(); - expect(unit.provider).toBeInstanceOf(Provider); - expect(unit.provider.region()).toBe('real'); - expect(() => unitRef.get(PRIMARY)).toThrow(/exposed dependency/); -}); diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 235c1eea..41c8ef83 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -3,21 +3,6 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. -# [4.2.0](https://github.com/suites-dev/unit/compare/%40suites%2Fcore.unit%404.1.2...%40suites%2Fcore.unit%404.2.0) (2026-11-09) - -### Bug Fixes - -* **core.unit:** guard final values selected by qualified token fallback ([fccd11e](https://github.com/suites-dev/unit/commit/fccd11ec4c895cdc38503d3b62a46b7a2b9a86fe)) -* **core.unit:** preserve metadata identity in dependency caches ([33892d1](https://github.com/suites-dev/unit/commit/33892d1c1bca24c3a7993d49739a0e7d891c48e0)) - -### Features - -* **core.unit:** add recursive composition with explicit mock boundaries ([ac10e62](https://github.com/suites-dev/unit/commit/ac10e6254d1f35a339474fbea826fc494f472c49)) -* **core.unit:** construct exposed implementations for dependency tokens ([477ae5c](https://github.com/suites-dev/unit/commit/477ae5c4b48f64d8d89b365b8add4c02161eef42)), closes [#465](https://github.com/suites-dev/unit/issues/465) -* **core.unit:** support explicit automatic mock declarations ([e4e95f1](https://github.com/suites-dev/unit/commit/e4e95f1a9c815eb5f5948c35a3cdb4b6397ea8f4)) -* **core.unit:** validate token bindings and support abstract classes ([99fd7ad](https://github.com/suites-dev/unit/commit/99fd7ade1b9084be07532db3ee8a29ce5c260428)) - - ## [4.1.2](https://github.com/suites-dev/unit/compare/%40suites%2Fcore.unit%404.1.1...%40suites%2Fcore.unit%404.1.2) (2026-11-05) **Note:** Version bump only for package @suites/core.unit diff --git a/packages/core/__test__/sociable/assets/injectable-registry.fixture.ts b/packages/core/__test__/sociable/assets/injectable-registry.fixture.ts index eadbcbe0..05bad5fd 100644 --- a/packages/core/__test__/sociable/assets/injectable-registry.fixture.ts +++ b/packages/core/__test__/sociable/assets/injectable-registry.fixture.ts @@ -77,16 +77,11 @@ export class UserDigestService { } } -export class DataMapper { - static readonly token = Symbol('DATA_MAPPER'); -} - export class UserApiService { public constructor( private readonly userVerificationService: UserVerificationService, private readonly apiService: ApiService, - private readonly userDigestService: UserDigestService, - @FakeInject(DataMapper.token) readonly dataMapper: DataMapper + private readonly userDigestService: UserDigestService ) {} public async getUserData(userId: string): Promise { @@ -187,7 +182,6 @@ export const userApiServiceRegistry: InjectableRegistry = { { identifier: UserVerificationService, value: UserVerificationService, type: 'PARAM' }, { identifier: ApiService, value: ApiService, type: 'PARAM' }, { identifier: UserDigestService, value: UserDigestService, type: 'PARAM' }, - { identifier: DataMapper.token, value: DataMapper, type: 'PARAM' }, ]; }, resolve: () => undefined, diff --git a/packages/core/__test__/sociable/assets/integration.assets.ts b/packages/core/__test__/sociable/assets/integration.assets.ts index 70abe21e..5c1d4218 100644 --- a/packages/core/__test__/sociable/assets/integration.assets.ts +++ b/packages/core/__test__/sociable/assets/integration.assets.ts @@ -9,7 +9,6 @@ import type { import type { Type } from '@suites/types.common'; import { normalizeIdentifier } from '../../../src/normalize-identifier.static.js'; import { - DataMapper, apiServiceRegistry, databaseServiceRegistry, userApiServiceRegistry, @@ -30,7 +29,6 @@ import { } from './injectable-registry.fixture.js'; const registryToClass: Map = new Map([ - [DataMapper, emptyRegistry], [HttpClient, emptyRegistry], [TestLogger, emptyRegistry], [UserVerificationService, emptyRegistry], diff --git a/packages/core/__test__/sociable/sociable-testbed-builder.integration.test.ts b/packages/core/__test__/sociable/sociable-testbed-builder.integration.test.ts index 365767ac..80507504 100644 --- a/packages/core/__test__/sociable/sociable-testbed-builder.integration.test.ts +++ b/packages/core/__test__/sociable/sociable-testbed-builder.integration.test.ts @@ -2,7 +2,6 @@ import type { StubbedInstance } from '@suites/types.doubles'; import { FakeAdapter } from './assets/integration.assets.js'; import { mock } from '../mock.static.js'; import type { Repository, User } from './assets/injectable-registry.fixture.js'; -import { DataMapper } from './assets/injectable-registry.fixture.js'; import { Axios } from './assets/injectable-registry.fixture.js'; import { HttpClient } from './assets/injectable-registry.fixture.js'; import { @@ -41,7 +40,6 @@ describe('Social TestBed Builder Integration Tests', () => { .expose(UserDal) .expose(HttpClient) .expose(DatabaseService) - .expose(DataMapper.token, DataMapper) .mock(TestLogger) .impl((stubFn: Mock) => ({ log: stubFn().mockReturnValue('overridden') })) .mock(Axios) @@ -77,8 +75,6 @@ describe('Social TestBed Builder Integration Tests', () => { }); it('should throw an error indicating the dependencies cannot be retrieved as they were exposed in the testbed', () => { - expect(() => unitRef.get(DataMapper.token)).toThrow(/exposed dependency/); - expect(() => unitRef.get(DataMapper)).toThrow(); expect(() => unitRef.get(DatabaseService)).toThrow(); expect(() => unitRef.get(UserDal)).toThrow(); expect(() => unitRef.get(UserApiService)).toThrow(); diff --git a/packages/core/package.json b/packages/core/package.json index 9039ef4a..d5937d70 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@suites/core.unit", "private": false, - "version": "4.2.0", + "version": "4.1.2", "license": "Apache-2.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", diff --git a/packages/core/src/adapter-provider.ts b/packages/core/src/adapter-provider.ts deleted file mode 100644 index 41e45ecf..00000000 --- a/packages/core/src/adapter-provider.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** An adapter may be supplied directly or resolved when compilation begins. */ -export type AdapterProvider = Promise | (() => Promise); - -/** @internal Resolves a provider without starting discovery during builder creation. */ -export function resolveAdapter(provider: AdapterProvider): Promise { - return typeof provider === 'function' ? provider() : provider; -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 50927762..5e9298b2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,3 @@ export * from './services/builders/sociable-unit-builder.js'; export type { UnitReference } from './services/unit-reference.js'; export type { MockOverride, UnitTestBed } from './types.js'; export type { TestBedBuilder } from './services/builders/testbed-builder.js'; - -export { resolveAdapter } from './adapter-provider.js'; -export type { AdapterProvider } from './adapter-provider.js'; diff --git a/packages/core/src/services/builders/sociable-unit-builder.ts b/packages/core/src/services/builders/sociable-unit-builder.ts index 5c5f3f60..7f975f80 100644 --- a/packages/core/src/services/builders/sociable-unit-builder.ts +++ b/packages/core/src/services/builders/sociable-unit-builder.ts @@ -1,11 +1,7 @@ import type { DoublesAdapter } from '@suites/types.doubles'; -import { resolveAdapter } from '../../adapter-provider.js'; -import type { AdapterProvider } from '../../adapter-provider.js'; -import type { InjectableIdentifier } from '@suites/types.di'; -import type { AbstractType, Type } from '@suites/types.common'; +import type { Type } from '@suites/types.common'; import { UnitReference } from '../unit-reference.js'; import type { UnitMocker } from '../unit-mocker.js'; -import type { ResolverOptions } from '../dependency-resolver.js'; import type { IdentifierToMockOrFinal } from '../dependency-container.js'; import { DependencyContainer } from '../dependency-container.js'; import type { UnitTestBed } from '../../types.js'; @@ -13,41 +9,28 @@ import { TestBedBuilder } from './testbed-builder.js'; export interface SociableTestBedBuilder { expose(dependency: Type): SociableTestBedBuilder; - expose(dependency: string | symbol, implementation: Type): SociableTestBedBuilder; - expose( - dependency: TToken, - implementation: Type> - ): SociableTestBedBuilder; } -/** A sociable graph with explicitly selected mocked boundaries. */ -export interface ComposedTestBedBuilder extends TestBedBuilder { - exclude(...identifiers: [InjectableIdentifier, ...InjectableIdentifier[]]): this; -} - -abstract class SociableBuilderBase extends TestBedBuilder { - protected readonly classesToExpose: Type[] = []; - protected readonly exposedBindings = new Map(); - protected abstract get resolutionOptions(): ResolverOptions; +export class SociableTestBedBuilder extends TestBedBuilder { + private readonly classesToExpose: Type[] = []; public constructor( - protected readonly doublesAdapter: AdapterProvider, - protected readonly unitMocker: UnitMocker, - protected readonly targetClass: Type, - protected readonly logger: Console + private readonly doublesAdapter: Promise, + private readonly unitMocker: UnitMocker, + private readonly targetClass: Type, + private readonly logger: Console ) { super(); } - protected copyMockConfigurationTo(builder: SociableBuilderBase): void { - builder.identifiersToBeMocked.push(...this.identifiersToBeMocked); - builder.identifiersToBeFinalized.push(...this.identifiersToBeFinalized); + public expose(dependency: Type): SociableTestBedBuilder & TestBedBuilder { + this.classesToExpose.push(dependency); + return this; } public async compile(): Promise> { - const doublesAdapter = await resolveAdapter(this.doublesAdapter); - const mockFn = doublesAdapter.mock; - const stubCb = doublesAdapter.stub; + const mockFn = await this.doublesAdapter.then((adapter) => adapter.mock); + const stubCb = await this.doublesAdapter.then((adapter) => adapter.stub); const identifiersToMocks: IdentifierToMockOrFinal[] = this.identifiersToBeMocked.map( ([identifier, mockImplCallback]) => { @@ -61,15 +44,11 @@ abstract class SociableBuilderBase extends TestBedBuilder { } ); - const { container, instance, resolution, composedDependencies, exposedDependencies } = - await this.unitMocker.constructUnit( - this.targetClass, - this.classesToExpose, - new DependencyContainer([...identifiersToMocks, ...identifiersToFinal]), - { - resolution: this.resolutionOptions, - } - ); + const { container, instance, resolution } = await this.unitMocker.constructUnit( + this.targetClass, + this.classesToExpose, + new DependencyContainer([...identifiersToMocks, ...identifiersToFinal]) + ); if (resolution.mocks.length > 0) { resolution.mocks.forEach((identifier) => { @@ -95,96 +74,9 @@ For detailed instructions and best practices, refer to our documentation: https: }); } - for (const token of resolution.exposedTokens ?? []) { - this.logger.warn( - `Suites Warning: Unreachable Exposed Dependency Detected. The dependency ${String(token)} ` + - `cannot be reached within the testing context of ${this.targetClass.name}.` - ); - } - return { - unit: instance, - unitRef: new UnitReference( - container, - this.classesToExpose, - this.identifiersToBeFinalized, - composedDependencies, - exposedDependencies - ), + unit: instance as TClass, + unitRef: new UnitReference(container, this.classesToExpose, this.identifiersToBeFinalized), }; } } - -export class SociableTestBedBuilder extends SociableBuilderBase { - protected get resolutionOptions(): ResolverOptions { - return { exposedBindings: this.exposedBindings }; - } - - public expose(dependency: Type): SociableTestBedBuilder & TestBedBuilder; - public expose( - dependency: string | symbol, - implementation: Type - ): SociableTestBedBuilder & TestBedBuilder; - public expose( - dependency: TToken, - implementation: Type> - ): SociableTestBedBuilder & TestBedBuilder; - public expose( - dependency: AbstractType | string | symbol, - implementation?: Type - ): SociableTestBedBuilder & TestBedBuilder { - if (typeof dependency === 'function' && implementation === undefined) { - const existing = this.exposedBindings.get(dependency); - if (existing !== undefined && existing !== dependency) { - throw new Error('Cannot expose different implementations for the same dependency token.'); - } - this.classesToExpose.push(dependency as Type); - } else if (typeof implementation === 'function') { - const existing = this.exposedBindings.get(dependency); - if ( - (existing !== undefined && existing !== implementation) || - (this.classesToExpose.some((type) => type === dependency) && implementation !== dependency) - ) { - throw new Error('Cannot expose different implementations for the same dependency token.'); - } - this.exposedBindings.set(dependency, implementation); - } else { - throw new Error('Exposing a dependency token requires a concrete implementation.'); - } - return this; - } - - /** Recursively constructs discovered classes. Exclude abstract tokens and I/O explicitly. */ - public compose(): ComposedTestBedBuilder { - if (this.classesToExpose.length > 0 || this.exposedBindings.size > 0) { - throw new Error('Cannot combine .compose() with .expose(); choose one sociable strategy.'); - } - const composed = new ComposedSociableTestBedBuilder( - this.doublesAdapter, - this.unitMocker, - this.targetClass, - this.logger - ); - this.copyMockConfigurationTo(composed); - return composed; - } -} - -class ComposedSociableTestBedBuilder - extends SociableBuilderBase - implements ComposedTestBedBuilder -{ - private readonly excludedIdentifiers: InjectableIdentifier[] = []; - - protected get resolutionOptions(): ResolverOptions { - return { compose: true, excludedIdentifiers: this.excludedIdentifiers }; - } - - public exclude(...identifiers: [InjectableIdentifier, ...InjectableIdentifier[]]): this { - if (identifiers.length === 0) { - throw new Error('.exclude() requires at least one dependency identifier.'); - } - this.excludedIdentifiers.push(...identifiers); - return this; - } -} diff --git a/packages/core/src/services/builders/solitary-unit-builder.ts b/packages/core/src/services/builders/solitary-unit-builder.ts index d973afb0..26e16a29 100644 --- a/packages/core/src/services/builders/solitary-unit-builder.ts +++ b/packages/core/src/services/builders/solitary-unit-builder.ts @@ -1,13 +1,8 @@ import type { DoublesAdapter } from '@suites/types.doubles'; -import { resolveAdapter } from '../../adapter-provider.js'; -import type { AdapterProvider } from '../../adapter-provider.js'; import type { Type } from '@suites/types.common'; import { UnitReference } from '../unit-reference.js'; import type { UnitMocker } from '../unit-mocker.js'; -import type { - IdentifierToMockOrFinal, - IdentifierToMockImplWithCb, -} from '../dependency-container.js'; +import type { IdentifierToMockOrFinal, IdentifierToMockImplWithCb } from '../dependency-container.js'; import { DependencyContainer } from '../dependency-container.js'; import type { UnitTestBed } from '../../types.js'; import { TestBedBuilder } from './testbed-builder.js'; @@ -19,7 +14,7 @@ export class SolitaryTestBedBuilder implements TestBedBuilder { public constructor( - private readonly doublesAdapter: AdapterProvider, + private readonly doublesAdapter: Promise, private readonly unitMocker: UnitMocker, private readonly targetClass: Type, private readonly logger: Console @@ -28,9 +23,8 @@ export class SolitaryTestBedBuilder } public async compile(): Promise> { - const doublesAdapter = await resolveAdapter(this.doublesAdapter); - const mockFn = doublesAdapter.mock; - const stubFn = doublesAdapter.stub; + const mockFn = await this.doublesAdapter.then((adapter) => adapter.mock); + const stubFn = await this.doublesAdapter.then((adapter) => adapter.stub); const identifiersToMocksImpls: IdentifierToMockOrFinal[] = this.identifiersToBeMocked.map( ([identifier, mockImplCallback]: IdentifierToMockImplWithCb) => { diff --git a/packages/core/src/services/builders/testbed-builder.ts b/packages/core/src/services/builders/testbed-builder.ts index 3dda4e0e..66a0eb3e 100644 --- a/packages/core/src/services/builders/testbed-builder.ts +++ b/packages/core/src/services/builders/testbed-builder.ts @@ -9,45 +9,35 @@ export abstract class TestBedBuilder implements TestBedBuilder { protected readonly identifiersToBeMocked: IdentifierToMockImplWithCb[] = []; protected readonly identifiersToBeFinalized: IdentifierToFinal[] = []; - public mock( - type: AbstractType - ): MockOverride; + public mock(type: AbstractType): MockOverride; public mock( type: AbstractType, identifierMetadata: IdentifierMetadata - ): MockOverride; - public mock(token: string): MockOverride; + ): MockOverride; + public mock(token: string): MockOverride; public mock( token: string, identifierMetadata: IdentifierMetadata - ): MockOverride; - public mock(token: symbol): MockOverride; + ): MockOverride; + public mock(token: symbol): MockOverride; public mock( token: symbol, identifierMetadata: IdentifierMetadata - ): MockOverride; + ): MockOverride; public mock( identifier: InjectableIdentifier, identifierMetadata?: IdentifierMetadata - ): MockOverride; + ): MockOverride; public mock( identifier: InjectableIdentifier, metadata?: IdentifierMetadata - ): MockOverride { + ): MockOverride { return { - auto: (): this => { - this.identifiersToBeMocked.push([ - normalizeIdentifier(identifier, metadata as never), - () => ({}), - ]); - - return this; - }, impl: ( mockImplementation: ( stubFn: () => Stub> ) => DeepPartial - ): this => { + ): TestBedBuilder => { this.identifiersToBeMocked.push([ normalizeIdentifier(identifier, metadata as never), mockImplementation, @@ -55,7 +45,7 @@ export abstract class TestBedBuilder implements TestBedBuilder { return this; }, - final: (finalImplementation: FinalValue): this => { + final: (finalImplementation: FinalValue): TestBedBuilder => { this.identifiersToBeFinalized.push([ normalizeIdentifier(identifier, metadata as never), finalImplementation, @@ -108,7 +98,7 @@ export interface TestBedBuilder { * const mockOverride = testBedBuilder.mock(MyService); * ``` */ - mock(type: AbstractType): MockOverride; + mock(type: AbstractType): MockOverride; /** * Declares a dependency to be mocked using its type along with a corresponding metadata object. @@ -127,7 +117,7 @@ export interface TestBedBuilder { mock( type: AbstractType, identifierMetadata: IdentifierMetadata - ): MockOverride; + ): MockOverride; /** * Declares a dependency to be mocked using a string-based token. @@ -142,7 +132,7 @@ export interface TestBedBuilder { * const mockOverride = testBedBuilder.mock('MyService'); * ``` */ - mock(token: string): MockOverride; + mock(token: string): MockOverride; /** * Declares a dependency to be mocked using a string-based token along with a corresponding metadata object. @@ -161,7 +151,7 @@ export interface TestBedBuilder { mock( token: string, identifierMetadata: IdentifierMetadata - ): MockOverride; + ): MockOverride; /** * Declares a dependency to be mocked using a symbol-based token. @@ -176,7 +166,7 @@ export interface TestBedBuilder { * const mockOverride = testBedBuilder.mock(MY_SERVICE_SYMBOL); * ``` */ - mock(token: symbol): MockOverride; + mock(token: symbol): MockOverride; /** * Declares a dependency to be mocked using a symbol-based token along with a corresponding metadata object. @@ -195,7 +185,7 @@ export interface TestBedBuilder { mock( token: symbol, identifierMetadata: IdentifierMetadata - ): MockOverride; + ): MockOverride; /** * Declares a dependency to be mocked using its type, string-based token, or symbol-based token, @@ -219,7 +209,7 @@ export interface TestBedBuilder { mock( identifier: InjectableIdentifier, identifierMetadata?: IdentifierMetadata - ): MockOverride; + ): MockOverride; } /** @@ -240,7 +230,7 @@ export interface TestBedBuilder { * const mockOverride = testBedBuilder.mock(MyService); * ``` */ - mock(type: AbstractType): MockOverride; + mock(type: AbstractType): MockOverride; /** * Declares a dependency to be mocked using a string-based token. @@ -254,7 +244,7 @@ export interface TestBedBuilder { * const mockOverride = testBedBuilder.mock('MyService'); * ``` */ - mock(token: string): MockOverride; + mock(token: string): MockOverride; /** * Declares a dependency to be mocked using a symbol-based token. @@ -268,7 +258,7 @@ export interface TestBedBuilder { * const mockOverride = testBedBuilder.mock(MY_SERVICE_SYMBOL); * ``` */ - mock(token: symbol): MockOverride; + mock(token: symbol): MockOverride; /** * Declares a dependency to be mocked using its type, string-based token, or symbol-based token. @@ -290,5 +280,5 @@ export interface TestBedBuilder { mock( identifier: InjectableIdentifier, identifierMetadata?: IdentifierMetadata - ): MockOverride; + ): MockOverride; } diff --git a/packages/core/src/services/composition.spec.ts b/packages/core/src/services/composition.spec.ts deleted file mode 100644 index b966057c..00000000 --- a/packages/core/src/services/composition.spec.ts +++ /dev/null @@ -1,359 +0,0 @@ -import type { Type } from '@suites/types.common'; -import type { - ClassInjectable, - DependencyInjectionAdapter, - InjectableIdentifier, -} from '@suites/types.di'; -import { DependencyContainer } from './dependency-container.js'; -import { DependencyResolver } from './dependency-resolver.js'; -import { SociableTestBedBuilder } from './builders/sociable-unit-builder.js'; -import { UnitMocker } from './unit-mocker.js'; - -function registry(entries: [Type, ClassInjectable[]][]): DependencyInjectionAdapter { - return { - inspect: (type: Type) => ({ - list: () => entries.find(([target]) => type === target)?.[1] ?? [], - resolve: () => undefined, - }), - } as DependencyInjectionAdapter; -} -const param = (identifier: InjectableIdentifier, metadata?: unknown): ClassInjectable => - ({ identifier, metadata, type: 'PARAM' }) as ClassInjectable; -const primary = { named: 'primary' } as never; -const secondary = { named: 'secondary' } as never; - -class Client { - read() { - return 'live'; - } -} -class Repository { - constructor(readonly client: Client) {} -} -class Service { - constructor(readonly repository: Repository) {} -} -const adapter = registry([ - [Service, [param(Repository)]], - [Repository, [param(Client)]], -]); -function resolver( - options = {}, - overrides: ConstructorParameters[0] = [] -) { - return new DependencyResolver( - [], - new DependencyContainer(overrides), - adapter, - jest.fn(() => ({})), - options - ); -} -function builder() { - const doubles = Promise.resolve({ - mock: jest.fn((implementation: unknown) => implementation ?? {}), - stub: jest.fn, - }); - return new SociableTestBedBuilder( - doubles, - new UnitMocker( - doubles.then((x) => x.mock), - Promise.resolve(adapter) - ), - Service, - console - ); -} - -describe('opt-in composition', () => { - it('preserves implicit mocking when composition is not selected', () => { - const instance = resolver().instantiateClass(Service) as unknown as Service; - expect(instance.repository).not.toBeInstanceOf(Repository); - }); - - it('constructs transitive and leaf classes when explicitly selected', () => { - const instance = resolver({ compose: true }).instantiateClass(Service) as unknown as Service; - expect(instance.repository).toBeInstanceOf(Repository); - expect(instance.repository.client).toBeInstanceOf(Client); - expect(instance.repository.client.read()).toBe('live'); - }); - - it('never inspects or constructs an excluded boundary', () => { - const inspect = jest.fn((type: Type) => { - if (type === Client) throw new Error('must not discover the excluded client'); - return adapter.inspect(type); - }); - const current = new DependencyResolver( - [], - new DependencyContainer([]), - { inspect }, - jest.fn(() => ({})), - { compose: true, excludedIdentifiers: [Client] } - ); - const instance = current.instantiateClass(Service) as unknown as Service; - expect(instance.repository.client).not.toBeInstanceOf(Client); - expect(inspect).not.toHaveBeenCalledWith(Client); - }); - - it('reuses shared instances without merging metadata-qualified occurrences', () => { - class Root { - constructor( - readonly one: Client, - readonly two: Client, - readonly three: Client - ) {} - } - const current = new DependencyResolver( - [], - new DependencyContainer([]), - registry([ - [Root, [param(Client, primary), param(Client, secondary), param(Client, primary)]], - ]), - jest.fn(() => ({})), - { compose: true } - ); - const instance = current.instantiateClass(Root) as unknown as Root; - expect(instance.one).not.toBe(instance.two); - expect(instance.one).toBe(instance.three); - }); - - it('gives specific overrides precedence over identity-wide exclusions', () => { - const override = { read: () => 'fake' }; - const current = resolver({ compose: true, excludedIdentifiers: [Client] }, [ - [{ identifier: Client, metadata: primary }, override], - ]); - expect(current.resolveOrMock(Client, primary)).toBe(override); - expect(current.resolveOrMock(Client, secondary)).not.toBe(override); - }); - - it('guards only the metadata-qualified real instance, not an overridden sibling', async () => { - class Root { - constructor( - readonly first: Client, - readonly second: Client - ) {} - } - const localAdapter = registry([[Root, [param(Client, primary), param(Client, secondary)]]]); - const doubles = Promise.resolve({ mock: jest.fn((value) => value ?? {}), stub: jest.fn }); - const rootBuilder = new SociableTestBedBuilder( - doubles, - new UnitMocker( - doubles.then((x) => x.mock), - Promise.resolve(localAdapter) - ), - Root, - console - ); - const { unit, unitRef } = await rootBuilder - .compose() - .mock(Client, secondary) - .impl(() => ({ read: () => 'fake' })) - .compile(); - expect(unit.first).toBeInstanceOf(Client); - expect(unit.second).not.toBeInstanceOf(Client); - expect(unitRef.get(Client, secondary)).toBe(unit.second); - expect(() => unitRef.get(Client, primary)).toThrow('exposed dependency'); - }); - - it('composes property dependencies and preserves shared graph identity', () => { - class Root { - first!: Repository; - second!: Repository; - } - const localAdapter = registry([ - [ - Root, - [ - { - identifier: Repository, - type: 'PROPERTY', - property: { key: 'first' }, - } as ClassInjectable, - { - identifier: Repository, - type: 'PROPERTY', - property: { key: 'second' }, - } as ClassInjectable, - ], - ], - [Repository, [param(Client)]], - ]); - const current = new DependencyResolver( - [], - new DependencyContainer([]), - localAdapter, - jest.fn(() => ({})), - { compose: true } - ); - const instance = current.instantiateClass(Root) as unknown as Root; - expect(instance.first).toBeInstanceOf(Repository); - expect(instance.second).toBe(instance.first); - expect(instance.first.client).toBeInstanceOf(Client); - }); - - it('retains distinct object-token identities', () => { - const first = { description: 'same' } as unknown as InjectableIdentifier; - const second = { description: 'same' } as unknown as InjectableIdentifier; - const one = { marker: 1 }; - const current = resolver({ compose: true, excludedIdentifiers: [first] }, [ - [{ identifier: second }, one], - ]); - expect(current.resolveOrMock(first)).not.toBe(one); - expect(current.resolveOrMock(second)).toBe(one); - }); - - it('reports constructor cycles with a dependency path before overflowing', () => { - class One {} - class Two {} - const current = new DependencyResolver( - [], - new DependencyContainer([]), - registry([ - [One, [param(Two)]], - [Two, [param(One)]], - ]), - jest.fn(() => ({})), - { compose: true } - ); - expect(() => current.instantiateClass(One)).toThrow('One -> Two -> One'); - }); - - it('allows a mock boundary to cut a constructor cycle', () => { - class One { - constructor(readonly two: unknown) {} - } - class Two {} - const current = new DependencyResolver( - [], - new DependencyContainer([]), - registry([ - [One, [param(Two)]], - [Two, [param(One)]], - ]), - jest.fn(() => ({})), - { compose: true, excludedIdentifiers: [Two] } - ); - expect(() => current.instantiateClass(One)).not.toThrow(); - }); - - it('retains the composed mode through both override forms', async () => { - const { unit } = await builder() - .compose() - .mock(Client) - .final({ read: () => 'fake' }) - .exclude(Client) - .compile(); - expect(unit.repository.client.read()).toBe('fake'); - const { unit: mocked } = await builder() - .compose() - .mock(Client) - .impl(() => ({ read: () => 'mocked' })) - .exclude(Client) - .compile(); - expect(mocked.repository).toBeInstanceOf(Repository); - }); - - it('does not register a mock until the selector is completed', async () => { - const current = builder().compose(); - current.mock(Client); - const { unit } = await current.compile(); - expect(unit.repository.client).toBeInstanceOf(Client); - }); - - it('never inspects or constructs the graph behind an automatic mock', async () => { - const constructed = jest.fn(); - class Nested { - constructor() { - constructed('Nested'); - } - } - class Boundary { - constructor(readonly nested: Nested) { - constructed('Boundary'); - } - } - class Root { - constructor(readonly boundary: Boundary) {} - } - const localAdapter = registry([ - [Root, [param(Boundary)]], - [Boundary, [param(Nested)]], - ]); - const inspect = jest.fn((type: Type) => { - if (type === Boundary || type === Nested) { - throw new Error('must not inspect a mocked graph'); - } - return localAdapter.inspect(type); - }); - const doubles = Promise.resolve({ mock: jest.fn(() => ({})), stub: jest.fn }); - const { unit, unitRef } = await new SociableTestBedBuilder( - doubles, - new UnitMocker( - doubles.then((value) => value.mock), - Promise.resolve({ inspect }) - ), - Root, - console - ) - .compose() - .mock(Boundary) - .auto() - .compile(); - expect(unit.boundary).toBe(unitRef.get(Boundary)); - expect(inspect).not.toHaveBeenCalledWith(Boundary); - expect(inspect).not.toHaveBeenCalledWith(Nested); - expect(constructed).not.toHaveBeenCalled(); - }); - - it('creates independent composed builders without changing the original mode', async () => { - const current = builder(); - const composed = current.compose(); - const sibling = current.compose(); - expect(composed).not.toBe(current); - expect(composed).not.toBe(sibling); - expect('expose' in composed).toBe(false); - expect('exclude' in current).toBe(false); - composed.exclude(Repository); - current.expose(Repository); - - const { unit: selected } = await current.compile(); - const { unit: automatic } = await sibling.compile(); - const { unit: excluded } = await composed.compile(); - expect(selected.repository).toBeInstanceOf(Repository); - expect(selected.repository.client).not.toBeInstanceOf(Client); - expect(automatic.repository.client).toBeInstanceOf(Client); - expect(excluded.repository).not.toBeInstanceOf(Repository); - }); - - it('copies existing mock configuration without sharing later declarations', async () => { - const current = builder(); - current.mock(Client).impl(() => ({ read: () => 'configured' })); - const composed = current.compose(); - current.mock(Repository).final({ client: { read: () => 'original-only' } }); - const { unit } = await composed.compile(); - expect(unit.repository).toBeInstanceOf(Repository); - expect(unit.repository.client.read()).toBe('configured'); - }); - - it('copies final values into composition', async () => { - const current = builder(); - const supplied = { read: () => 'supplied' }; - current.mock(Client).final(supplied); - const { unit } = await current.compose().compile(); - expect(unit.repository.client).toBe(supplied); - }); - - it('rejects new mode conflicts and empty exclusions', () => { - expect(() => builder().expose(Repository).compose()).toThrow('Cannot combine'); - expect(() => { - // @ts-expect-error Exercise the runtime guard for JavaScript callers. - builder().compose().exclude(); - }).toThrow('at least one'); - }); - - it('rejects retrieving a composed real value while allowing mocks', async () => { - const { unitRef } = await builder().compose().exclude(Client).compile(); - expect(() => unitRef.get(Repository)).toThrow('exposed dependency'); - expect(unitRef.get(Client)).toEqual({}); - }); -}); diff --git a/packages/core/src/services/dependency-container.ts b/packages/core/src/services/dependency-container.ts index ee42f840..e91ffcba 100644 --- a/packages/core/src/services/dependency-container.ts +++ b/packages/core/src/services/dependency-container.ts @@ -32,14 +32,6 @@ export class DependencyContainer { identifier: InjectableIdentifier, metadata?: IdentifierMetadata ): StubbedInstance | ConstantValue | undefined { - return this.resolveEntry(identifier, metadata)?.[1] as - StubbedInstance | ConstantValue | undefined; - } - - public resolveEntry( - identifier: InjectableIdentifier, - metadata?: IdentifierMetadata - ): IdentifierToMockOrFinal | undefined { // If there is one identifier, it is enough to match, no need to check metadata const identifiers = this.identifierToDependency.filter( ([{ identifier: injectableIdentifier }]) => injectableIdentifier === identifier @@ -50,7 +42,7 @@ export class DependencyContainer { } if (identifiers.length === 1 && !metadata) { - return identifiers[0]; + return identifiers[0][1] as StubbedInstance | ConstantValue; } // If there are more than one injectable with the same identifier, we need to check the metadata as well @@ -59,7 +51,9 @@ export class DependencyContainer { isDeepStrictEqual(injectableMetadata, metadata) ); - return identifierToMock; + return identifierToMock + ? (identifierToMock[1] as StubbedInstance | ConstantValue) + : undefined; } const foundIdentifier = this.identifierToDependency.find( @@ -67,7 +61,9 @@ export class DependencyContainer { injectableIdentifier === identifier && typeof metadata === 'undefined' ); - return foundIdentifier; + return foundIdentifier + ? (foundIdentifier[1] as StubbedInstance | ConstantValue) + : undefined; } public list(): IdentifierToMockOrFinal[] { diff --git a/packages/core/src/services/dependency-map.spec.ts b/packages/core/src/services/dependency-map.spec.ts deleted file mode 100644 index 69a5a193..00000000 --- a/packages/core/src/services/dependency-map.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { InjectableIdentifier } from '@suites/types.di'; -import { DependencyMap } from './dependency-map.js'; - -class Provider {} - -describe('Dependency Map qualification', () => { - it('separates named bindings and reuses equivalent metadata', () => { - const map = new DependencyMap(); - const first = { label: 'first' }; - const second = { label: 'second' }; - map.set(Provider, first, { named: 'first' } as never); - map.set(Provider, second, { named: 'second' } as never); - - expect(map.get(Provider, { named: 'first' } as never)).toBe(first); - expect(map.get(Provider, { named: 'second' } as never)).toBe(second); - expect(map.has(Provider)).toBe(false); - expect(map.get(Provider)).toBeUndefined(); - }); - - it('includes symbol-keyed tags and all named/tagged metadata in cache identity', () => { - const tag = Symbol('tag'); - const map = new DependencyMap(); - const first = { label: 'first' }; - const second = { label: 'second' }; - map.set(Provider, first, { named: 'primary', [tag]: 'one' } as never); - map.set(Provider, second, { [tag]: 'two', named: 'primary' } as never); - - expect(map.get(Provider, { [tag]: 'one', named: 'primary' } as never)).toBe(first); - expect(map.get(Provider, { named: 'primary', [tag]: 'two' } as never)).toBe(second); - expect(map.has(Provider, { named: 'primary' } as never)).toBe(false); - expect(map.has(Provider, { named: 'primary', [Symbol('tag')]: 'one' } as never)).toBe(false); - }); - - it('preserves runtime identifier identity', () => { - const map = new DependencyMap(); - const first = { description: 'same' } as unknown as InjectableIdentifier; - const second = { description: 'same' } as unknown as InjectableIdentifier; - const firstSymbol = Symbol('same'); - const secondSymbol = Symbol('same'); - const value = { label: 'first' }; - map.set(first, value); - map.set(firstSymbol, value); - - expect(map.get(first)).toBe(value); - expect(map.has(second)).toBe(false); - expect(map.get(firstSymbol)).toBe(value); - expect(map.has(secondSymbol)).toBe(false); - }); - - it.each([false, 0, null, undefined])('records a qualified cached value of %s', (value) => { - const map = new DependencyMap(); - map.set(Provider, value as never, { named: 'primary' } as never); - - expect(map.has(Provider, { named: 'primary' } as never)).toBe(true); - expect(map.get(Provider, { named: 'primary' } as never)).toBe(value); - expect(map.has(Provider, { named: 'secondary' } as never)).toBe(false); - }); -}); diff --git a/packages/core/src/services/dependency-map.ts b/packages/core/src/services/dependency-map.ts index e5194540..5af45882 100644 --- a/packages/core/src/services/dependency-map.ts +++ b/packages/core/src/services/dependency-map.ts @@ -1,4 +1,3 @@ -import { isDeepStrictEqual } from 'node:util'; import type { IdentifierMetadata, InjectableIdentifier } from '@suites/types.di'; import type { FinalValue, Type } from '@suites/types.common'; import type { StubbedInstance } from '@suites/types.doubles'; @@ -7,10 +6,10 @@ import type { IdentifierToMockOrFinal } from './dependency-container.js'; export class DependencyMap { private readonly resolvedDependencies: IdentifierToMockOrFinal[] = []; - public has(identifier: InjectableIdentifier, metadata?: IdentifierMetadata): boolean { - return this.resolvedDependencies.some( - ([key]) => key.identifier === identifier && isDeepStrictEqual(key.metadata, metadata) - ); + public has(identifier: InjectableIdentifier): boolean { + return this.resolvedDependencies.some(([{ identifier: resolvedId }]) => { + return identifier === resolvedId; + }); } public set( @@ -22,12 +21,11 @@ export class DependencyMap { } public get( - identifier: InjectableIdentifier, - metadata?: IdentifierMetadata + identifier: InjectableIdentifier ): Type | StubbedInstance | FinalValue | undefined { - return this.resolvedDependencies.find( - ([key]) => key.identifier === identifier && isDeepStrictEqual(key.metadata, metadata) - )?.[1]; + return this.resolvedDependencies.find(([{ identifier: resolvedId }]) => { + return identifier === resolvedId; + })?.[1]; } public entries(): IdentifierToMockOrFinal[] { diff --git a/packages/core/src/services/dependency-resolver.spec.ts b/packages/core/src/services/dependency-resolver.spec.ts deleted file mode 100644 index 6644a306..00000000 --- a/packages/core/src/services/dependency-resolver.spec.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { Type } from '@suites/types.common'; -import type { ClassInjectable, DependencyInjectionAdapter } from '@suites/types.di'; -import { DependencyContainer } from './dependency-container.js'; -import { DependencyResolver } from './dependency-resolver.js'; - -class Provider {} -class Root { - constructor( - readonly first: Provider, - readonly second: Provider - ) {} -} - -const primary = { named: 'primary' } as never; -const secondary = { named: 'secondary' } as never; - -function adapterFor(target: Type, dependencies: ClassInjectable[]): DependencyInjectionAdapter { - return { - inspect: (type) => ({ - list: () => (type === target ? dependencies : []), - resolve: () => undefined, - }), - } as DependencyInjectionAdapter; -} - -describe('qualified dependency resolution', () => { - it('injects separate configured values for two occurrences of the same class', () => { - const first = { label: 'first' }; - const second = { label: 'second' }; - const resolver = new DependencyResolver( - [], - new DependencyContainer([ - [{ identifier: Provider, metadata: primary }, first], - [{ identifier: Provider, metadata: secondary }, second], - ]), - adapterFor(Root, [ - { identifier: Provider, metadata: primary, type: 'PARAM' } as ClassInjectable, - { identifier: Provider, metadata: secondary, type: 'PARAM' } as ClassInjectable, - ]), - jest.fn() - ); - - const root = resolver.instantiateClass(Root) as unknown as Root; - expect(root.first).toBe(first); - expect(root.second).toBe(second); - expect(resolver.getResolutionSummary().notFound).toEqual([]); - }); - - it('reuses a mock only when both identifier and metadata match', () => { - const mock = jest.fn(() => ({})); - const resolver = new DependencyResolver( - [], - new DependencyContainer([]), - adapterFor(Root, []), - mock - ); - const first = resolver.resolveOrMock(Provider, primary); - const second = resolver.resolveOrMock(Provider, secondary); - - expect(first).not.toBe(second); - expect(resolver.resolveOrMock(Provider, { named: 'primary' } as never)).toBe(first); - expect(resolver.resolveOrMock(Provider, { named: 'secondary' } as never)).toBe(second); - expect(mock).toHaveBeenCalledTimes(2); - }); - - it('reports a configured metadata variant that was not reached', () => { - const resolver = new DependencyResolver( - [], - new DependencyContainer([ - [{ identifier: Provider, metadata: primary }, { label: 'first' }], - [{ identifier: Provider, metadata: secondary }, { label: 'second' }], - ]), - adapterFor(Root, []), - jest.fn() - ); - resolver.resolveOrMock(Provider, primary); - - expect(resolver.getResolutionSummary().notFound).toEqual([ - [{ identifier: Provider, metadata: secondary }, { label: 'second' }], - ]); - }); - - it('reuses constructed providers only for equivalent metadata without rerunning constructors', () => { - let constructions = 0; - class RealProvider { - constructor() { - constructions += 1; - } - } - const resolver = new DependencyResolver( - [RealProvider], - new DependencyContainer([]), - adapterFor(RealProvider, []), - jest.fn() - ); - - const first = resolver.instantiateClass(RealProvider, primary); - const second = resolver.instantiateClass(RealProvider, secondary); - const unqualified = resolver.instantiateClass(RealProvider); - - expect(first).toBeInstanceOf(RealProvider); - expect(second).not.toBe(first); - expect(unqualified).not.toBe(first); - expect(unqualified).not.toBe(second); - expect(resolver.instantiateClass(RealProvider, { named: 'primary' } as never)).toBe(first); - expect(resolver.instantiateClass(RealProvider, { named: 'secondary' } as never)).toBe(second); - expect(resolver.instantiateClass(RealProvider)).toBe(unqualified); - expect(constructions).toBe(3); - }); - - it('keeps real instance qualification separate from injected property qualification', () => { - class Leaf {} - class RealProvider { - nested!: Leaf; - } - const propertyMetadata = { named: 'property' } as never; - const resolver = new DependencyResolver( - [RealProvider], - new DependencyContainer([]), - adapterFor(RealProvider, [ - { - identifier: Leaf, - metadata: propertyMetadata, - type: 'PROPERTY', - property: { key: 'nested' }, - } as ClassInjectable, - ]), - jest.fn(() => ({})) - ); - - const first = resolver.resolveOrMock(RealProvider, primary) as unknown as RealProvider; - const second = resolver.resolveOrMock( - RealProvider, - propertyMetadata - ) as unknown as RealProvider; - expect(first).toBeInstanceOf(RealProvider); - expect(second).toBeInstanceOf(RealProvider); - expect(first).not.toBe(second); - expect(first.nested).toBe(second.nested); - expect(resolver.resolveOrMock(RealProvider, primary)).toBe(first); - expect(resolver.resolveOrMock(RealProvider, propertyMetadata)).toBe(second); - }); -}); diff --git a/packages/core/src/services/dependency-resolver.ts b/packages/core/src/services/dependency-resolver.ts index 2db87972..c4d6f3c8 100644 --- a/packages/core/src/services/dependency-resolver.ts +++ b/packages/core/src/services/dependency-resolver.ts @@ -1,5 +1,3 @@ -import { isDeepStrictEqual } from 'node:util'; -import { DependencyResolutionError } from '@suites/types.common'; import type { FinalValue, Type, AbstractType } from '@suites/types.common'; import type { MockFunction, StubbedInstance } from '@suites/types.doubles'; import type { @@ -12,29 +10,15 @@ import type { import type { DependencyContainer, IdentifierToMockOrFinal } from './dependency-container.js'; import { DependencyMap } from './dependency-map.js'; -/** Opt-in graph construction; omitted options retain the V4 resolver behavior. */ -export interface ResolverOptions { - compose?: boolean; - exposedBindings?: ReadonlyMap; - excludedIdentifiers?: readonly InjectableIdentifier[]; -} - export class DependencyResolver { private readonly dependencyMap = new DependencyMap(); - private readonly availableClassesToExpose = new Set(); - private readonly constructionPath: { - identifier: InjectableIdentifier; - metadata?: IdentifierMetadata; - }[] = []; - private readonly composedDependencies: IdentifierToMockOrFinal[0][] = []; - private readonly exposedDependencies: IdentifierToMockOrFinal[0][] = []; + private readonly availableClassesToExpose = new Set(); constructor( private readonly classesToExpose: Type[], private readonly mockedFromBeforeContainer: DependencyContainer, private readonly adapter: DependencyInjectionAdapter, - private readonly mockFunction: MockFunction, - private readonly options: ResolverOptions = {} + private readonly mockFunction: MockFunction ) {} private isExposedClass(identifier: InjectableIdentifier): identifier is Type { @@ -55,8 +39,8 @@ export class DependencyResolver { identifier: InjectableIdentifier, metadata?: IdentifierMetadata ): Type | FinalValue | StubbedInstance { - if (this.dependencyMap.has(identifier, metadata)) { - return this.dependencyMap.get(identifier, metadata) as Type | FinalValue | ClassInjectable; + if (this.dependencyMap.has(identifier)) { + return this.dependencyMap.get(identifier) as Type | FinalValue | ClassInjectable; } const existingMock = this.mockedFromBeforeContainer.resolve(identifier, metadata); @@ -66,30 +50,11 @@ export class DependencyResolver { return existingMock; } - const implementation = this.options.exposedBindings?.get(identifier); - if (implementation !== undefined) { - const instance = this.instantiateClass(implementation, metadata, identifier); - this.exposedDependencies.push({ identifier, metadata }); - return instance; - } - - if (this.options.compose) { - // Boundaries are checked before reflection: excluded constructors and their - // dependencies must never execute merely to discover the graph. - if (this.options.excludedIdentifiers?.some((excluded) => excluded === identifier)) { - const mock = this.mockFunction(); - this.dependencyMap.set(identifier, mock, metadata as never); - return mock; - } - - if (typeof identifier === 'function') { - const instance = this.instantiateClass(identifier as Type, metadata); - this.composedDependencies.push({ identifier, metadata }); - return instance; + if (this.isLeafOrPrimitive(identifier)) { + if (this.dependencyMap.has(identifier)) { + return this.dependencyMap.get(identifier) as Type | FinalValue | StubbedInstance; } - } - if (this.isLeafOrPrimitive(identifier)) { if (this.isExposedClass(identifier)) { return this.instantiateClass(identifier, metadata); } @@ -113,66 +78,43 @@ export class DependencyResolver { public instantiateClass( type: Type, - metadata: IdentifierMetadata | undefined = undefined, - identifier: InjectableIdentifier = type + metadata: IdentifierMetadata | undefined = undefined ): Type | FinalValue | StubbedInstance { - if (this.dependencyMap.has(identifier, metadata)) { - return this.dependencyMap.get(identifier, metadata) as - Type | FinalValue | StubbedInstance; - } - - const trackConstruction = this.options.compose || (this.options.exposedBindings?.size ?? 0) > 0; - if ( - trackConstruction && - this.constructionPath.some( - (entry) => entry.identifier === identifier && isDeepStrictEqual(entry.metadata, metadata) - ) - ) { - const path = [...this.constructionPath, { identifier, metadata }] - .map(({ identifier: token }) => (typeof token === 'function' ? token.name : String(token))) - .join(' -> '); - throw new DependencyResolutionError( - `Cannot compose circular constructor dependencies: ${path}. ` + - 'Exclude or explicitly mock one dependency to establish a construction boundary.' - ); + if (this.dependencyMap.has(type)) { + return this.dependencyMap.get(type) as Type | FinalValue | StubbedInstance; } - if (trackConstruction) this.constructionPath.push({ identifier, metadata }); - try { - const injectableRegistry = this.adapter.inspect(type); + const injectableRegistry = this.adapter.inspect(type); - injectableRegistry.list().forEach((injectable: ClassInjectable) => { + injectableRegistry.list().forEach((injectable: ClassInjectable) => { + if (typeof injectable.identifier === 'function') { this.availableClassesToExpose.add(injectable.identifier); - }); + } + }); - const ctorInjectables = injectableRegistry.list().filter(({ type }) => type === 'PARAM'); + const ctorInjectables = injectableRegistry.list().filter(({ type }) => type === 'PARAM'); - const ctorParams = ctorInjectables.map(({ identifier, metadata }: WithMetadata) => { - return this.resolveOrMock(identifier, metadata); - }); + const ctorParams = ctorInjectables.map(({ identifier, metadata }: WithMetadata) => { + if (this.dependencyMap.has(type)) { + return this.dependencyMap.get(type); + } - const instance = new type(...ctorParams); - this.dependencyMap.set(identifier, instance, metadata as never); + return this.resolveOrMock(identifier, metadata); + }); - const propsInjectables = injectableRegistry.list().filter(({ type }) => type === 'PROPERTY'); + const instance = new type(...ctorParams); + this.dependencyMap.set(type, instance, metadata as never); - propsInjectables.forEach(({ identifier, metadata, property }: WithMetadata) => { - const propValue = this.resolveOrMock(identifier, metadata); - instance[property!.key] = propValue; - }); + const propsInjectables = injectableRegistry.list().filter(({ type }) => type === 'PROPERTY'); - return instance; - } finally { - if (trackConstruction) this.constructionPath.pop(); - } - } + propsInjectables.forEach(({ identifier, metadata, property }: WithMetadata) => { + const propValue = this.resolveOrMock(identifier, metadata); + instance[property!.key] = propValue; - public getExposedDependencies(): IdentifierToMockOrFinal[0][] { - return this.exposedDependencies; - } + this.dependencyMap.set(type, propValue, metadata as never); + }); - public getComposedDependencies(): IdentifierToMockOrFinal[0][] { - return this.composedDependencies; + return instance; } public getResolvedDependencies() { @@ -182,7 +124,6 @@ export class DependencyResolver { public getResolutionSummary(): { mocks: { metadata?: unknown; identifier: AbstractType }[]; exposes: Type[]; - exposedTokens: InjectableIdentifier[]; notFound: IdentifierToMockOrFinal[]; } { const exposes = this.classesToExpose.filter((cls) => !this.availableClassesToExpose.has(cls)); @@ -195,7 +136,7 @@ export class DependencyResolver { ); const notFound = this.mockedFromBeforeContainer.list().filter(([injectable]) => { - return !this.dependencyMap.has(injectable.identifier, injectable.metadata as never); + return !this.dependencyMap.has(injectable.identifier); }); return { @@ -205,9 +146,6 @@ export class DependencyResolver { identifier: AbstractType; }[], exposes, - exposedTokens: [...(this.options.exposedBindings?.keys() ?? [])].filter( - (token) => !this.availableClassesToExpose.has(token) - ), }; } } diff --git a/packages/core/src/services/token-exposure.spec.ts b/packages/core/src/services/token-exposure.spec.ts deleted file mode 100644 index a1cf4a89..00000000 --- a/packages/core/src/services/token-exposure.spec.ts +++ /dev/null @@ -1,278 +0,0 @@ -import type { Type } from '@suites/types.common'; -import type { - ClassInjectable, - DependencyInjectionAdapter, - InjectableIdentifier, -} from '@suites/types.di'; -import { SociableTestBedBuilder } from './builders/sociable-unit-builder.js'; -import { UnitMocker } from './unit-mocker.js'; - -const param = (identifier: InjectableIdentifier, metadata?: unknown): ClassInjectable => - ({ identifier, metadata, type: 'PARAM' }) as ClassInjectable; - -function builder(root: Type, entries: [Type, ClassInjectable[]][]) { - const adapter = { - inspect: (type: Type) => ({ - list: () => entries.find(([target]) => target === type)?.[1] ?? [], - resolve: () => undefined, - }), - } as DependencyInjectionAdapter; - const doubles = { - mock: jest.fn((implementation: unknown) => implementation ?? {}), - stub: jest.fn, - }; - const warn = jest.fn(); - const testBed = new SociableTestBedBuilder( - doubles, - new UnitMocker(Promise.resolve(doubles.mock), adapter), - root, - { - ...console, - warn, - } - ); - return { testBed, warn }; -} - -class Cache { - read(): string { - return 'live'; - } -} -class Provider { - constructor(readonly cache: Cache) {} - read(): string { - return this.cache.read(); - } -} - -describe('real implementations behind dependency tokens', () => { - it.each(['PROVIDER', Symbol('PROVIDER')])( - 'constructs %s and resolves its dependencies', - async (token) => { - class Root { - constructor(readonly provider: Provider) {} - } - const { testBed, warn } = builder(Root, [ - [Root, [param(token)]], - [Provider, [param(Cache)]], - ]); - const { unit, unitRef } = await testBed - .expose(token, Provider) - .mock(Cache) - .impl(() => ({ read: () => 'mock' })) - .compile(); - expect(unit.provider).toBeInstanceOf(Provider); - expect(unit.provider.read()).toBe('mock'); - expect(unit.provider.cache).toBe(unitRef.get(Cache)); - expect(() => unitRef.get(token)).toThrow(/exposed dependency/); - expect(() => unitRef.get(Provider)).toThrow(/could not be located/); - expect(warn).not.toHaveBeenCalled(); - } - ); - - it('preserves repeated token identity without aliasing other tokens or the implementation class', async () => { - const first = Symbol('provider'); - const second = Symbol('provider'); - class Leaf {} - class Root { - constructor( - readonly first: Leaf, - readonly repeated: Leaf, - readonly second: Leaf, - readonly direct: Leaf - ) {} - } - const { testBed } = builder(Root, [ - [Root, [param(first), param(first), param(second), param(Leaf)]], - ]); - const { unit } = await testBed.expose(first, Leaf).expose(second, Leaf).expose(Leaf).compile(); - expect(unit.first).toBeInstanceOf(Leaf); - expect(unit.first).toBe(unit.repeated); - expect(unit.first).not.toBe(unit.second); - expect(unit.first).not.toBe(unit.direct); - expect(unit.second).not.toBe(unit.direct); - }); - - it('keeps qualified occurrences independent and honors a qualified mock override', async () => { - const token = Symbol('provider'); - class Leaf {} - class Root { - constructor( - readonly primary: Leaf, - readonly repeated: Leaf, - readonly secondary: Leaf - ) {} - } - const entries: [Type, ClassInjectable[]][] = [ - [ - Root, - [ - param(token, { named: 'primary' }), - param(token, { named: 'primary' }), - param(token, { named: 'secondary' }), - ], - ], - ]; - const { unit, unitRef } = await builder(Root, entries).testBed.expose(token, Leaf).compile(); - expect(unit.primary).toBe(unit.repeated); - expect(unit.primary).not.toBe(unit.secondary); - expect(unit.secondary).toBeInstanceOf(Leaf); - expect(() => unitRef.get(token, { named: 'primary' })).toThrow(/exposed dependency/); - expect(() => unitRef.get(token, { named: 'secondary' })).toThrow(/exposed dependency/); - expect(() => unitRef.get(token)).toThrow(/could not be located/); - - const overridden = await builder(Root, entries) - .testBed.expose(token, Leaf) - .mock(token, { named: 'secondary' }) - .auto() - .compile(); - expect(overridden.unit.primary).toBeInstanceOf(Leaf); - expect(overridden.unit.secondary).toBe(overridden.unitRef.get(token, { named: 'secondary' })); - expect(overridden.unit.secondary).not.toBeInstanceOf(Leaf); - }); - - it('rejects unique bare-token retrieval of a qualified real binding', async () => { - class Root { - constructor(readonly provider: Provider) {} - } - const { testBed } = builder(Root, [ - [Root, [param('provider', { named: 'primary' })]], - [Provider, [param(Cache)]], - ]); - const { unitRef } = await testBed.expose('provider', Provider).compile(); - expect(() => unitRef.get('provider')).toThrow(/exposed dependency/); - expect(() => unitRef.get('provider', { named: 'missing' })).toThrow(/could not be located/); - }); - - it.each(['auto', 'impl', 'final'] as const)( - 'lets %s override a token binding before construction', - async (mode) => { - const constructed = jest.fn(); - class Real { - constructor() { - constructed(); - } - read() { - return 'real'; - } - } - class Root { - constructor(readonly provider: Real) {} - } - const testBed = builder(Root, [[Root, [param('provider')]]]).testBed.expose('provider', Real); - const selector = testBed.mock('provider'); - const supplied = { read: () => 'supplied' }; - if (mode === 'auto') selector.auto(); - if (mode === 'impl') selector.impl(() => supplied); - if (mode === 'final') selector.final(supplied); - const { unit, unitRef } = await testBed.compile(); - expect(constructed).not.toHaveBeenCalled(); - if (mode === 'final') { - expect(unit.provider).toBe(supplied); - expect(() => unitRef.get('provider')).toThrow(/faked dependency/); - } else { - expect(unit.provider).toBe(unitRef.get('provider')); - } - } - ); - - it('constructs abstract-token implementations while preserving the dependency graph', async () => { - abstract class Port { - abstract read(): string; - } - class Root { - constructor(readonly provider: Port) {} - } - const { unit, unitRef } = await builder(Root, [ - [Root, [param(Port)]], - [Provider, [param(Cache)]], - ]) - .testBed.expose(Port, Provider) - .expose(Cache) - .compile(); - expect(unit.provider).toBeInstanceOf(Provider); - expect(unit.provider.read()).toBe('live'); - expect(() => unitRef.get(Port)).toThrow(/exposed dependency/); - }); - - it('detects constructor cycles through token bindings', async () => { - class First { - constructor(readonly second: Second) {} - } - class Second { - constructor(readonly first: First) {} - } - class Root { - constructor(readonly first: First) {} - } - const { testBed } = builder(Root, [ - [Root, [param('first')]], - [First, [param('second')]], - [Second, [param('first')]], - ]); - await expect(testBed.expose('first', First).expose('second', Second).compile()).rejects.toThrow( - /Root -> first -> second -> first/ - ); - }); - - it('honors a qualified final boundary during construction', async () => { - class Shared { - constructor(readonly next: Shared) {} - } - class Root { - constructor(readonly first: Shared) {} - } - const { testBed } = builder(Root, [ - [Root, [param('first')]], - [Shared, [param('second', { named: 'inner' })]], - ]); - const supplied = {}; - const { unit } = await testBed - .expose('first', Shared) - .expose('second', Shared) - .mock('second', { named: 'inner' }) - .final(supplied) - .compile(); - expect(unit.first).toBeInstanceOf(Shared); - expect(unit.first.next).toBe(supplied); - }); - - it('warns for unreachable bindings and rejects contradictory implementations', async () => { - class Root {} - const { testBed, warn } = builder(Root, []); - await testBed.expose('missing', Provider).compile(); - expect(warn).toHaveBeenCalledWith(expect.stringContaining('missing')); - expect(() => testBed.expose('missing', Cache)).toThrow(/different implementations/); - expect(() => testBed.expose('missing', Provider)).not.toThrow(); - }); - - it('keeps selective token exposure separate from composition', () => { - class Root {} - expect(() => builder(Root, []).testBed.expose('provider', Provider).compose()).toThrow( - /Cannot combine/ - ); - }); - - it('rejects missing implementations in untyped calls', () => { - class Root {} - const { testBed } = builder(Root, []); - expect(() => Reflect.apply(testBed.expose, testBed, ['provider'])).toThrow( - /requires a concrete implementation/ - ); - expect(() => Reflect.apply(testBed.expose, testBed, ['provider', {}])).toThrow( - /requires a concrete implementation/ - ); - }); - - it('rejects contradictory direct-class and explicit bindings in either order', () => { - class Root {} - class Alternative extends Cache {} - expect(() => builder(Root, []).testBed.expose(Cache).expose(Cache, Alternative)).toThrow( - /different implementations/ - ); - expect(() => builder(Root, []).testBed.expose(Cache, Alternative).expose(Cache)).toThrow( - /different implementations/ - ); - }); -}); diff --git a/packages/core/src/services/unit-mocker.spec.ts b/packages/core/src/services/unit-mocker.spec.ts index 685dac0f..e4ea919a 100644 --- a/packages/core/src/services/unit-mocker.spec.ts +++ b/packages/core/src/services/unit-mocker.spec.ts @@ -151,50 +151,3 @@ describe('Unit Mocker Unit Spec', () => { }); }); }); - -describe('optional construction and lazy adapters', () => { - it('does not resolve adapters before construction begins', async () => { - const doubles = jest.fn(async () => () => MockedFromBuilder); - const di = jest.fn(async () => ({ - inspect: () => ({ list: () => [], resolve: () => undefined }), - })); - const mocker = new UnitMocker(doubles, di); - expect(doubles).not.toHaveBeenCalled(); - expect(di).not.toHaveBeenCalled(); - const result = await mocker.constructUnit(ArbitraryClassOne, [], new DependencyContainer([])); - expect(result.instance).toBeInstanceOf(ArbitraryClassOne); - expect(di).toHaveBeenCalledTimes(1); - expect(doubles).toHaveBeenCalledTimes(1); - }); - - it('uses an explicit construction strategy without resolving DI or mock providers', async () => { - const unavailable = jest.fn(async (): Promise => { - throw new Error('no adapter'); - }); - const mocker = new UnitMocker(unavailable, unavailable); - const instance = new ArbitraryClassOne(); - const result = await mocker.constructUnit(ArbitraryClassOne, [], new DependencyContainer([]), { - strategy: { - construct: () => ({ - instance, - container: new DependencyContainer([]), - resolution: { mocks: [], exposes: [], notFound: [] }, - }), - }, - }); - expect(result.instance).toBe(instance); - expect(unavailable).not.toHaveBeenCalled(); - }); - - it('reports lazy DI discovery failure through the awaited construction', async () => { - const mocker = new UnitMocker( - Promise.resolve(() => MockedFromBuilder), - async () => { - throw new Error('no DI adapter'); - } - ); - await expect( - mocker.constructUnit(ArbitraryClassOne, [], new DependencyContainer([])) - ).rejects.toThrow('no DI adapter'); - }); -}); diff --git a/packages/core/src/services/unit-mocker.ts b/packages/core/src/services/unit-mocker.ts index aa1b7561..dd7fab61 100644 --- a/packages/core/src/services/unit-mocker.ts +++ b/packages/core/src/services/unit-mocker.ts @@ -1,58 +1,36 @@ import type { MockFunction } from '@suites/types.doubles'; -import type { DependencyInjectionAdapter, InjectableIdentifier } from '@suites/types.di'; +import type { DependencyInjectionAdapter } from '@suites/types.di'; import type { IdentifierToMockOrFinal } from './dependency-container.js'; import { DependencyContainer } from './dependency-container.js'; import type { Type, AbstractType } from '@suites/types.common'; -import type { ResolverOptions } from './dependency-resolver.js'; import { DependencyResolver } from './dependency-resolver.js'; -import { resolveAdapter } from '../adapter-provider.js'; -import type { AdapterProvider } from '../adapter-provider.js'; export interface MockedUnit { container: DependencyContainer; instance: TClass; - composedDependencies?: IdentifierToMockOrFinal[0][]; - exposedDependencies?: IdentifierToMockOrFinal[0][]; resolution: { notFound: IdentifierToMockOrFinal[]; mocks: { metadata?: unknown; identifier: AbstractType }[]; exposes: Type[]; - exposedTokens?: InjectableIdentifier[]; }; } -/** Optional construction path; existing DI adapters keep their inspect contract. */ -export interface UnitConstructionStrategy { - construct(targetClass: Type): MockedUnit | Promise>; -} - -export interface UnitConstructionOptions { - strategy?: UnitConstructionStrategy; - resolution?: ResolverOptions; -} - export class UnitMocker { public constructor( - private readonly mockFunction: AdapterProvider, - private readonly diAdapter: AdapterProvider + private readonly mockFunction: Promise, + private readonly diAdapter: Promise ) {} public async constructUnit( targetClass: Type, classesToExpose: Type[], - mockContainer: DependencyContainer, - options?: UnitConstructionOptions + mockContainer: DependencyContainer ): Promise> { - if (options?.strategy) { - return options.strategy.construct(targetClass); - } - const dependencyResolver = new DependencyResolver( classesToExpose, mockContainer, - await resolveAdapter(this.diAdapter), - await resolveAdapter(this.mockFunction), - options?.resolution + await this.diAdapter, + await this.mockFunction ); const instance = dependencyResolver.instantiateClass(targetClass); @@ -65,8 +43,6 @@ export class UnitMocker { container: new DependencyContainer(identifierToDependency), instance: instance as TClass, resolution: dependencyResolver.getResolutionSummary(), - composedDependencies: dependencyResolver.getComposedDependencies(), - exposedDependencies: dependencyResolver.getExposedDependencies(), }; } } diff --git a/packages/core/src/services/unit-reference.spec.ts b/packages/core/src/services/unit-reference.spec.ts index eeb632b5..12994515 100644 --- a/packages/core/src/services/unit-reference.spec.ts +++ b/packages/core/src/services/unit-reference.spec.ts @@ -106,84 +106,3 @@ describe('object token identity', () => { expect(() => reference.get(finalized)).toThrow(/faked dependency/); }); }); - -describe('metadata-qualified final dependencies', () => { - const metadata = { named: 'primary' } as never; - const identifiers: InjectableIdentifier[] = [ - 'QUALIFIED', - Symbol('QUALIFIED'), - class Qualified {}, - { description: 'QUALIFIED' } as unknown as InjectableIdentifier, - ]; - - it.each(identifiers)('rejects a final selected by bare-token fallback (%s)', (identifier) => { - const value = { endpoint: 'fixed' }; - const key = { identifier, metadata }; - const reference = new UnitReference( - new DependencyContainer([[key, value]]), - [], - [[key, value]] - ); - - expect(() => reference.get(identifier)).toThrow(/faked dependency/); - expect(() => reference.get(identifier, metadata)).toThrow(/faked dependency/); - }); - - it.each([false, 0, null, undefined])( - 'rejects a final value of %s before value lookup', - (value) => { - const key = { identifier: 'QUALIFIED', metadata }; - const reference = new UnitReference( - new DependencyContainer([[key, value as never]]), - [], - [[key, value as never]] - ); - - expect(() => reference.get('QUALIFIED')).toThrow(/faked dependency/); - } - ); - - it('preserves bare-token fallback for a unique qualified mock', () => { - const mock = { find: jest.fn() }; - const reference = new UnitReference( - new DependencyContainer([[{ identifier: 'QUALIFIED', metadata }, mock]]), - [], - [] - ); - - expect(reference.get('QUALIFIED')).toBe(mock); - }); - - it('does not block an unqualified mock sharing a token and value with a qualified final', () => { - const sharedValue = { find: jest.fn() }; - const finalKey = { identifier: 'QUALIFIED', metadata }; - const reference = new UnitReference( - new DependencyContainer([ - [finalKey, sharedValue], - [{ identifier: 'QUALIFIED' }, sharedValue], - ]), - [], - [[finalKey, sharedValue]] - ); - - expect(reference.get('QUALIFIED')).toBe(sharedValue); - expect(() => reference.get('QUALIFIED', metadata)).toThrow(/faked dependency/); - }); - - it('preserves ambiguity and mismatched-metadata failures', () => { - const finalKey = { identifier: 'QUALIFIED', metadata }; - const reference = new UnitReference( - new DependencyContainer([ - [finalKey, { endpoint: 'fixed' }], - [{ identifier: 'QUALIFIED', metadata: { named: 'secondary' } }, { find: jest.fn() }], - ]), - [], - [[finalKey, { endpoint: 'fixed' }]] - ); - - expect(() => reference.get('QUALIFIED')).toThrow(/could not be located/); - expect(() => reference.get('QUALIFIED', { named: 'missing' } as never)).toThrow( - /could not be located/ - ); - }); -}); diff --git a/packages/core/src/services/unit-reference.ts b/packages/core/src/services/unit-reference.ts index 11994e0e..f63548c4 100644 --- a/packages/core/src/services/unit-reference.ts +++ b/packages/core/src/services/unit-reference.ts @@ -3,11 +3,7 @@ import type { StubbedInstance } from '@suites/types.doubles'; import type { IdentifierMetadata, InjectableIdentifier } from '@suites/types.di'; import type { AbstractType } from '@suites/types.common'; import { DependencyResolutionError } from '@suites/types.common'; -import type { - DependencyContainer, - IdentifierToFinal, - IdentifierToMockOrFinal, -} from './dependency-container.js'; +import type { DependencyContainer, IdentifierToFinal } from './dependency-container.js'; import { referenceDependencyNotFoundError, stringifyIdentifier } from './functions.static.js'; /** @@ -147,9 +143,7 @@ export class UnitReference { public constructor( private readonly mocksContainer: DependencyContainer, private readonly exposedInstances: InjectableIdentifier[], - private readonly fakedDependencies: IdentifierToFinal[], - private readonly composedDependencies: readonly IdentifierToMockOrFinal[0][] = [], - private readonly exposedDependencies: readonly IdentifierToMockOrFinal[0][] = [] + private readonly fakedDependencies: IdentifierToFinal[] ) {} public get(type: AbstractType): StubbedInstance; @@ -175,17 +169,11 @@ export class UnitReference { identifier: InjectableIdentifier, identifierMetadata?: IdentifierMetadata ): StubbedInstance { - const selectedDependency = this.mocksContainer.resolveEntry(identifier, identifierMetadata); - if ( this.fakedDependencies .map(([identifier]) => identifier) .some( - (id) => - id.identifier === identifier && - (isDeepStrictEqual(id.metadata, identifierMetadata) || - (selectedDependency !== undefined && - isDeepStrictEqual(id.metadata, selectedDependency[0].metadata))) + (id) => id.identifier === identifier && isDeepStrictEqual(id.metadata, identifierMetadata) ) ) { const identifierString = stringifyIdentifier(identifier, identifierMetadata); @@ -196,36 +184,14 @@ Faked dependencies are not intended for direct retrieval and should be accessed testing context or container. Refer to the docs for further information: https://suites.dev/docs`); } - if ( - selectedDependency !== undefined && - this.exposedDependencies.some( - (entry) => - entry.identifier === identifier && - isDeepStrictEqual(entry.metadata, selectedDependency?.[0].metadata) - ) - ) { - throw new DependencyResolutionError( - `The dependency ${stringifyIdentifier(identifier, identifierMetadata)} is marked as an exposed dependency. ` + - 'Access the real instance through the unit instead of unitRef.get().' - ); - } - - if ( - typeof identifier === 'function' && - (this.exposedInstances.includes(identifier) || - this.composedDependencies.some( - (entry) => - entry.identifier === identifier && - isDeepStrictEqual(entry.metadata, selectedDependency?.[0].metadata) - )) - ) { + if (typeof identifier === 'function' && this.exposedInstances.includes(identifier)) { throw new DependencyResolutionError(`The dependency associated with the specified identifier '${identifier.name}' could not be retrieved from the current testing context, as it is marked as an exposed dependency. Exposed dependencies are not intended for direct retrieval and should be accessed through the appropriate testing context or container. Refer to the docs for further information: https://suites.dev/docs`); } - const dependency = selectedDependency?.[1]; + const dependency = this.mocksContainer.resolve(identifier, identifierMetadata); if (!dependency) { const message = referenceDependencyNotFoundError(identifier, identifierMetadata); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9738e503..69401c23 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -58,15 +58,7 @@ export interface UnitTestBed { * @since 3.0.0 * @see {@link https://suites.dev/docs/api-reference/mock-configuration | Mock Configuration} */ -export interface MockOverride> { - /** - * Generates a mock without an initial implementation. - * Configure and inspect the mock through `unitRef.get()` after compilation. - * - * @returns The originating builder for method chaining. - */ - auto(): TBuilder; - +export interface MockOverride { /** * Configures mocks with stubs that can be retrieved and modified later via `unitRef.get()`. * @@ -80,7 +72,7 @@ export interface MockOverride>) => DeepPartial - ): TBuilder; + ): TestBedBuilder; /** * Sets immutable final values for a mocked dependency. The mock cannot be retrieved @@ -93,5 +85,5 @@ export interface MockOverride): TBuilder; + final(finalImplementation: DeepPartial): TestBedBuilder; } diff --git a/packages/doubles/jest/CHANGELOG.md b/packages/doubles/jest/CHANGELOG.md index e489de8b..c8170c7f 100644 --- a/packages/doubles/jest/CHANGELOG.md +++ b/packages/doubles/jest/CHANGELOG.md @@ -3,17 +3,6 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. -# [4.2.0](https://github.com/suites-dev/unit/compare/%40suites%2Fdoubles.jest%404.1.2...%40suites%2Fdoubles.jest%404.2.0) (2026-11-09) - -### Bug Fixes - -* **doubles.jest:** preserve supplied getter and setter descriptors ([f7e9788](https://github.com/suites-dev/unit/commit/f7e9788457e6fb279eaa62f84b2a5fa4e4c48edc)) - -### Features - -* **doubles.jest:** preserve fluent builder APIs after mock overrides ([cf34898](https://github.com/suites-dev/unit/commit/cf34898eb006258adfa7f33c92ff088f8e4b9494)) - - ## [4.1.2](https://github.com/suites-dev/unit/compare/%40suites%2Fdoubles.jest%404.1.1...%40suites%2Fdoubles.jest%404.1.2) (2026-11-05) **Note:** Version bump only for package @suites/doubles.jest diff --git a/packages/doubles/jest/package.json b/packages/doubles/jest/package.json index b077247c..9b0f05eb 100644 --- a/packages/doubles/jest/package.json +++ b/packages/doubles/jest/package.json @@ -1,6 +1,6 @@ { "name": "@suites/doubles.jest", - "version": "4.2.0", + "version": "4.1.2", "license": "Apache-2.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", @@ -61,7 +61,7 @@ "jest": "^30.5.2" }, "dependencies": { - "@suites/core.unit": "workspace:^4.2.0", + "@suites/core.unit": "workspace:^4.1.2", "@suites/types.common": "workspace:^4.0.2", "@suites/types.di": "workspace:^4.1.2", "@suites/types.doubles": "workspace:^4.0.2" diff --git a/packages/doubles/jest/src/mock.spec.ts b/packages/doubles/jest/src/mock.spec.ts index dec7eb03..8787a556 100644 --- a/packages/doubles/jest/src/mock.spec.ts +++ b/packages/doubles/jest/src/mock.spec.ts @@ -119,163 +119,6 @@ describe('Mocking Proxy Mechanism Unit Spec', () => { }); }); - describe('accessor implementations', () => { - test.each([false, undefined])( - 'preserves a getter returning %s without reading it during initialization', - (value) => { - let reads = 0; - const implementation = { - get enabled() { - reads += 1; - return value; - }, - }; - const descriptor = Object.getOwnPropertyDescriptor(implementation, 'enabled'); - - const mockObject = mock(implementation); - - expect(reads).toBe(0); - expect(Object.getOwnPropertyDescriptor(mockObject, 'enabled')).toEqual(descriptor); - expect(mockObject.enabled).toBe(value); - expect(reads).toBe(1); - } - ); - - test.each([true, false])( - 'preserves getter and setter behavior with configurable=%s', - (configurable) => { - let value = false; - let reads = 0; - let writes = 0; - const implementation = Object.create(null, { - enabled: { - enumerable: true, - configurable, - get() { - reads += 1; - return value; - }, - set(nextValue: boolean) { - writes += 1; - value = nextValue; - }, - }, - }); - const descriptor = Object.getOwnPropertyDescriptor(implementation, 'enabled'); - - const mockObject = mock<{ enabled: boolean }>(implementation); - - expect(reads).toBe(0); - expect(writes).toBe(0); - expect(Object.getOwnPropertyDescriptor(mockObject, 'enabled')).toEqual(descriptor); - mockObject.enabled = true; - expect(writes).toBe(1); - expect(mockObject.enabled).toBe(true); - expect(reads).toBe(1); - } - ); - - test.each([true, false])( - 'preserves a setter-only property with configurable=%s', - (configurable) => { - const received: boolean[] = []; - const implementation = Object.create(null, { - enabled: { - enumerable: true, - configurable, - set(value: boolean) { - received.push(value); - }, - }, - }); - - const mockObject = mock<{ enabled: boolean }>(implementation); - - expect(received).toEqual([]); - expect(mockObject.enabled).toBeUndefined(); - mockObject.enabled = false; - expect(received).toEqual([false]); - } - ); - - test('preserves a non-configurable getter and rejects assignment', () => { - let reads = 0; - const implementation = Object.create(null, { - enabled: { - enumerable: true, - configurable: false, - get() { - reads += 1; - return false; - }, - }, - }); - const mockObject = mock<{ enabled: boolean }>(implementation); - - expect(reads).toBe(0); - expect(mockObject.enabled).toBe(false); - expect(() => { - mockObject.enabled = true; - }).toThrow(TypeError); - expect(mockObject.enabled).toBe(false); - }); - - test('preserves accessors inside a nested supplied implementation', () => { - let reads = 0; - const settings = { - get enabled() { - reads += 1; - return false; - }, - }; - const descriptor = Object.getOwnPropertyDescriptor(settings, 'enabled'); - - const mockObject = mock({ settings }); - - expect(reads).toBe(0); - expect(Object.getOwnPropertyDescriptor(mockObject.settings, 'enabled')).toEqual(descriptor); - expect(mockObject.settings.enabled).toBe(false); - expect(reads).toBe(1); - }); - - test('returns an accessor-provided object without eagerly traversing it', () => { - let reads = 0; - const settings = { enabled: false }; - const implementation = { - get settings() { - reads += 1; - return settings; - }, - }; - - const mockObject = mock(implementation); - - expect(reads).toBe(0); - expect(mockObject.settings).toBe(settings); - expect(reads).toBe(1); - }); - - test('preserves a non-enumerable accessor', () => { - let reads = 0; - const implementation = Object.create(null, { - enabled: { - enumerable: false, - configurable: true, - get() { - reads += 1; - return false; - }, - }, - }); - const mockObject = mock<{ enabled: boolean }>(implementation); - - expect(reads).toBe(0); - expect(Object.keys(mockObject)).not.toContain('enabled'); - expect(mockObject.enabled).toBe(false); - expect(reads).toBe(1); - }); - }); - describe('promises', () => { test('should successfully use mock for promises resolving', async () => { const mockObject = mock(); diff --git a/packages/doubles/jest/src/mock.static.ts b/packages/doubles/jest/src/mock.static.ts index b06da4ba..55d5eca1 100644 --- a/packages/doubles/jest/src/mock.static.ts +++ b/packages/doubles/jest/src/mock.static.ts @@ -10,11 +10,6 @@ const overrideMockImp = (obj: DeepPartial): Mocked => { for (const name of Object.keys(obj)) { const descriptor = Object.getOwnPropertyDescriptor(obj, name); - // Accessors already live on the proxy target; preserve them without evaluating or assigning. - if (descriptor && ('get' in descriptor || 'set' in descriptor)) { - continue; - } - if (descriptor && !descriptor.writable && !descriptor.configurable) { continue; } @@ -34,7 +29,7 @@ const overrideMockImp = (obj: DeepPartial): Mocked => { const handler = () => ({ set: (obj: Mocked, property: ProxiedProperty, value: T) => { const descriptor = Object.getOwnPropertyDescriptor(obj, property); - if (descriptor && 'value' in descriptor && !descriptor.writable && !descriptor.configurable) { + if (descriptor && !descriptor.writable && !descriptor.configurable) { return true; } // @ts-ignore https://github.com/microsoft/TypeScript/issues/1863 diff --git a/packages/doubles/jest/unit.d.ts b/packages/doubles/jest/unit.d.ts index df0d2ed7..efa77855 100644 --- a/packages/doubles/jest/unit.d.ts +++ b/packages/doubles/jest/unit.d.ts @@ -178,7 +178,7 @@ declare module '@suites/core.unit' { * @template TDependency The type of the dependency to be mocked. * @template TClass The type of the class under test. */ - export interface MockOverride { + export interface MockOverride { /** * Provides a mock implementation using stub functions for methods. * @@ -202,7 +202,7 @@ declare module '@suites/core.unit' { mockImplementation: ( stubFn: () => JestStub> ) => DeepPartial - ): TBuilder; + ): TestBedBuilder; /** * Provides a final implementation with concrete values or functions. @@ -223,6 +223,6 @@ declare module '@suites/core.unit' { * .compile(); * ``` */ - final(finalImplementation: DeepPartial): TBuilder; + final(finalImplementation: DeepPartial): TestBedBuilder; } } diff --git a/packages/doubles/vitest/CHANGELOG.md b/packages/doubles/vitest/CHANGELOG.md index 2271de0e..d96725ee 100644 --- a/packages/doubles/vitest/CHANGELOG.md +++ b/packages/doubles/vitest/CHANGELOG.md @@ -3,17 +3,6 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. -# [4.2.0](https://github.com/suites-dev/unit/compare/%40suites%2Fdoubles.vitest%404.1.2...%40suites%2Fdoubles.vitest%404.2.0) (2026-11-09) - -### Bug Fixes - -* **doubles.vitest:** preserve supplied getter and setter descriptors ([3b8c278](https://github.com/suites-dev/unit/commit/3b8c27815779d71c93a3d3adf1fd43d84a203f85)) - -### Features - -* **doubles.vitest:** preserve fluent builder APIs after mock overrides ([17842c3](https://github.com/suites-dev/unit/commit/17842c338064f286b14dada4c17bc03804446db0)) - - ## [4.1.2](https://github.com/suites-dev/unit/compare/%40suites%2Fdoubles.vitest%404.1.1...%40suites%2Fdoubles.vitest%404.1.2) (2026-11-05) **Note:** Version bump only for package @suites/doubles.vitest diff --git a/packages/doubles/vitest/package.json b/packages/doubles/vitest/package.json index 854f9715..33a54716 100644 --- a/packages/doubles/vitest/package.json +++ b/packages/doubles/vitest/package.json @@ -1,6 +1,6 @@ { "name": "@suites/doubles.vitest", - "version": "4.2.0", + "version": "4.1.2", "license": "Apache-2.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", @@ -62,7 +62,7 @@ "vitest": ">= 1.0" }, "dependencies": { - "@suites/core.unit": "workspace:^4.2.0", + "@suites/core.unit": "workspace:^4.1.2", "@suites/types.common": "workspace:^4.0.2", "@suites/types.di": "workspace:^4.1.2", "@suites/types.doubles": "workspace:^4.0.2" diff --git a/packages/doubles/vitest/src/mock.spec.ts b/packages/doubles/vitest/src/mock.spec.ts index 45bfe114..21353b4b 100644 --- a/packages/doubles/vitest/src/mock.spec.ts +++ b/packages/doubles/vitest/src/mock.spec.ts @@ -119,163 +119,6 @@ describe('Mocking Proxy Mechanism Unit Spec', () => { }); }); - describe('accessor implementations', () => { - test.each([false, undefined])( - 'preserves a getter returning %s without reading it during initialization', - (value) => { - let reads = 0; - const implementation = { - get enabled() { - reads += 1; - return value; - }, - }; - const descriptor = Object.getOwnPropertyDescriptor(implementation, 'enabled'); - - const mockObject = mock(implementation); - - expect(reads).toBe(0); - expect(Object.getOwnPropertyDescriptor(mockObject, 'enabled')).toEqual(descriptor); - expect(mockObject.enabled).toBe(value); - expect(reads).toBe(1); - } - ); - - test.each([true, false])( - 'preserves getter and setter behavior with configurable=%s', - (configurable) => { - let value = false; - let reads = 0; - let writes = 0; - const implementation = Object.create(null, { - enabled: { - enumerable: true, - configurable, - get() { - reads += 1; - return value; - }, - set(nextValue: boolean) { - writes += 1; - value = nextValue; - }, - }, - }); - const descriptor = Object.getOwnPropertyDescriptor(implementation, 'enabled'); - - const mockObject = mock<{ enabled: boolean }>(implementation); - - expect(reads).toBe(0); - expect(writes).toBe(0); - expect(Object.getOwnPropertyDescriptor(mockObject, 'enabled')).toEqual(descriptor); - mockObject.enabled = true; - expect(writes).toBe(1); - expect(mockObject.enabled).toBe(true); - expect(reads).toBe(1); - } - ); - - test.each([true, false])( - 'preserves a setter-only property with configurable=%s', - (configurable) => { - const received: boolean[] = []; - const implementation = Object.create(null, { - enabled: { - enumerable: true, - configurable, - set(value: boolean) { - received.push(value); - }, - }, - }); - - const mockObject = mock<{ enabled: boolean }>(implementation); - - expect(received).toEqual([]); - expect(mockObject.enabled).toBeUndefined(); - mockObject.enabled = false; - expect(received).toEqual([false]); - } - ); - - test('preserves a non-configurable getter and rejects assignment', () => { - let reads = 0; - const implementation = Object.create(null, { - enabled: { - enumerable: true, - configurable: false, - get() { - reads += 1; - return false; - }, - }, - }); - const mockObject = mock<{ enabled: boolean }>(implementation); - - expect(reads).toBe(0); - expect(mockObject.enabled).toBe(false); - expect(() => { - mockObject.enabled = true; - }).toThrow(TypeError); - expect(mockObject.enabled).toBe(false); - }); - - test('preserves accessors inside a nested supplied implementation', () => { - let reads = 0; - const settings = { - get enabled() { - reads += 1; - return false; - }, - }; - const descriptor = Object.getOwnPropertyDescriptor(settings, 'enabled'); - - const mockObject = mock({ settings }); - - expect(reads).toBe(0); - expect(Object.getOwnPropertyDescriptor(mockObject.settings, 'enabled')).toEqual(descriptor); - expect(mockObject.settings.enabled).toBe(false); - expect(reads).toBe(1); - }); - - test('returns an accessor-provided object without eagerly traversing it', () => { - let reads = 0; - const settings = { enabled: false }; - const implementation = { - get settings() { - reads += 1; - return settings; - }, - }; - - const mockObject = mock(implementation); - - expect(reads).toBe(0); - expect(mockObject.settings).toBe(settings); - expect(reads).toBe(1); - }); - - test('preserves a non-enumerable accessor', () => { - let reads = 0; - const implementation = Object.create(null, { - enabled: { - enumerable: false, - configurable: true, - get() { - reads += 1; - return false; - }, - }, - }); - const mockObject = mock<{ enabled: boolean }>(implementation); - - expect(reads).toBe(0); - expect(Object.keys(mockObject)).not.toContain('enabled'); - expect(mockObject.enabled).toBe(false); - expect(reads).toBe(1); - }); - }); - describe('promises', () => { test('should successfully use mock for promises resolving', async () => { const mockObject = mock(); diff --git a/packages/doubles/vitest/src/mock.static.ts b/packages/doubles/vitest/src/mock.static.ts index 0efcd091..0c15c521 100644 --- a/packages/doubles/vitest/src/mock.static.ts +++ b/packages/doubles/vitest/src/mock.static.ts @@ -10,11 +10,6 @@ const overrideMockImp = (obj: DeepPartial): Mocked => { for (const name of Object.keys(obj)) { const descriptor = Object.getOwnPropertyDescriptor(obj, name); - // Accessors already live on the proxy target; preserve them without evaluating or assigning. - if (descriptor && ('get' in descriptor || 'set' in descriptor)) { - continue; - } - if (descriptor && !descriptor.writable && !descriptor.configurable) { continue; } @@ -34,7 +29,7 @@ const overrideMockImp = (obj: DeepPartial): Mocked => { const handler = () => ({ set: (obj: Mocked, property: ProxiedProperty, value: T) => { const descriptor = Object.getOwnPropertyDescriptor(obj, property); - if (descriptor && 'value' in descriptor && !descriptor.writable && !descriptor.configurable) { + if (descriptor && !descriptor.writable && !descriptor.configurable) { return true; } // @ts-ignore https://github.com/microsoft/TypeScript/issues/1863 diff --git a/packages/doubles/vitest/unit.d.ts b/packages/doubles/vitest/unit.d.ts index 80923b5e..9066cb12 100644 --- a/packages/doubles/vitest/unit.d.ts +++ b/packages/doubles/vitest/unit.d.ts @@ -178,7 +178,7 @@ declare module '@suites/core.unit' { * @template TDependency The type of the dependency to be mocked. * @template TClass The type of the class under test. */ - export interface MockOverride { + export interface MockOverride { /** * Provides a mock implementation using stub functions for methods. * @@ -202,7 +202,7 @@ declare module '@suites/core.unit' { mockImplementation: ( stubFn: () => VitestStub> ) => DeepPartial - ): TBuilder; + ): TestBedBuilder; /** * Provides a final implementation with concrete values or functions. @@ -223,6 +223,6 @@ declare module '@suites/core.unit' { * .compile(); * ``` */ - final(finalImplementation: DeepPartial): TBuilder; + final(finalImplementation: DeepPartial): TestBedBuilder; } } diff --git a/packages/unit/CHANGELOG.md b/packages/unit/CHANGELOG.md index 6b7aae02..483e6528 100644 --- a/packages/unit/CHANGELOG.md +++ b/packages/unit/CHANGELOG.md @@ -3,15 +3,6 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. -# [4.2.0](https://github.com/suites-dev/unit/compare/%40suites%2Funit%404.1.2...%40suites%2Funit%404.2.0) (2026-11-09) - -### Features - -* **unit:** add mock().auto() for intentional automatic mocks ([7dadcef](https://github.com/suites-dev/unit/commit/7dadcef7e65cbad900af84c7a32aaed4ec083ed1)) -* **unit:** add opt-in compose() and variadic exclude() ([d16e812](https://github.com/suites-dev/unit/commit/d16e8127275b2c783d3692be83333e5e6e7e6e38)) -* **unit:** expose real implementations for dependency tokens ([fcc3afa](https://github.com/suites-dev/unit/commit/fcc3afac2650ae783e2e7b3ec7006112a67d21a2)) - - ## [4.1.2](https://github.com/suites-dev/unit/compare/%40suites%2Funit%404.1.1...%40suites%2Funit%404.1.2) (2026-11-05) **Note:** Version bump only for package @suites/unit diff --git a/packages/unit/README.md b/packages/unit/README.md index 6b2850ee..91748533 100644 --- a/packages/unit/README.md +++ b/packages/unit/README.md @@ -142,63 +142,6 @@ Use `TestBed.sociable(UserService).expose(ConcreteDependency).compile()` to keep See [solitary tests](https://suites.dev/docs/guides/solitary/), [sociable tests](https://suites.dev/docs/guides/sociable/), and [mock configuration](https://suites.dev/docs/api-reference/mock-configuration/). -### Expose an implementation behind a token - -When a dependency is injected through a string, symbol, or abstract-class token, supply the concrete implementation that Suites should construct: - -```typescript -const { unit, unitRef } = await TestBed.sociable(CheckoutService) - .expose(PRICING, PricingService) - .mock(ExchangeRates) - .impl((stub) => ({ rate: stub().mockReturnValue(1.2) })) - .compile(); -``` - -Suites constructs `PricingService` under the `PRICING` token and resolves its dependencies using the usual sociable rules. Unexposed dependencies remain automatically mocked. Existing `.expose(Class)` calls keep their behavior. For abstract or concrete class tokens, TypeScript checks that the implementation satisfies the token's instance type; plain strings and symbols do not carry that type information. - -Repeated requests for the same token and metadata share an instance. Different tokens or metadata qualifiers receive separate instances, even when they use the same implementation class. Exposing a token does not also expose or alias its implementation's class token. A binding applies to every metadata-qualified occurrence of its token; use a qualified `.mock(...).auto()`, `.impl()`, or `.final()` declaration to override a particular occurrence. Explicit mock declarations take precedence over exposure, regardless of declaration order. - -Access exposed instances through the unit; `unitRef.get()` remains for mocks. Repeating the same binding is allowed, but assigning different implementations to the same token throws during configuration. Constructor cycles through bindings produce a dependency-path error. An unreachable token binding produces a warning. - -Token exposure uses selective sociable construction and cannot be combined with `.compose()`. The test supplies the binding explicitly: Suites does not read or verify the application's container registration. Keep the selected implementation aligned with production wiring when that matters to the test. - -## Declare an automatic mock - -Use `.mock(Service).auto()` to generate a mock without supplying an initial implementation. Configure its behavior after compilation through `unitRef.get()`: - -```typescript -const { unit, unitRef } = await TestBed.sociable(OrderService) - .expose(PricingService) - .mock(TaxService) - .auto() - .compile(); - -unitRef.get(TaxService).calculate.mockReturnValue(20); -``` - -`.mock(Service)` selects a dependency; complete the declaration with `.auto()`, `.impl(callback)`, or `.final(value)`. Automatic mocks use the selected doubles adapter's normal behavior, including unstubbed methods returning `undefined`. They support the same token and metadata overloads as the other mock declarations. - -`.auto()` is available in solitary, selective sociable, and composition setups and preserves the builder's chaining methods. In composition, it replaces the selected dependency with a mock and stops traversal of that dependency. `.exclude(...tokens)` remains a convenient way to declare multiple composition boundaries. Existing automatic mocking defaults are unchanged; `.auto()` does not enable fail-fast validation. - -## Compose a dependency graph - -Composition is opt-in. `.compose()` returns a separate builder with a copy of the current mock configuration. It recursively constructs discovered class dependencies while keeping your selected boundaries mocked: - -```typescript -const { unit, unitRef } = await TestBed.sociable(OrderService) - .compose() - .exclude(DatabaseService, HttpClient) - .compile(); -``` - -`exclude()` requires at least one token. To reuse a readonly tuple, pass it with spread: `.exclude(...boundaries)`. - -Exclusions match token identity across metadata-qualified occurrences. Specific `.mock(token, metadata).impl(...)` or `.final(...)` overrides take precedence. You can continue composing configuration after either override. A boundary stops traversal; excluded constructors are not executed to discover their dependencies. Real composed instances are accessed through the unit rather than `unitRef.get()`. - -String, symbol, and adapter-registered object tokens stay mocked unless overridden. TypeScript erases abstractness at runtime: explicitly exclude or override abstract class tokens and classes that perform I/O. Suites Unit does not infer safe construction from a class name. Composition requires adapter-discoverable dependencies; it cannot discover undecorated graphs or erased interface types without explicit construction information. Constructor cycles require an excluded or mocked boundary; shared dependencies retain their token and metadata identity. - -Existing `.expose(Class)` tests retain their selective behavior. Choose either `.expose(Class)` or `.compose()` for a test. - ## Migration - [Upgrade from Suites 3 to 4](https://github.com/suites-dev/unit/blob/master/docs/migration-v4.md) diff --git a/packages/unit/package.json b/packages/unit/package.json index 85c9b182..138a7de3 100644 --- a/packages/unit/package.json +++ b/packages/unit/package.json @@ -1,7 +1,7 @@ { "name": "@suites/unit", "private": false, - "version": "4.2.0", + "version": "4.1.2", "license": "Apache-2.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", @@ -73,7 +73,7 @@ "README.md" ], "dependencies": { - "@suites/core.unit": "workspace:^4.2.0", + "@suites/core.unit": "workspace:^4.1.2", "@suites/types.common": "workspace:^4.0.2", "@suites/types.di": "workspace:^4.1.2", "@suites/types.doubles": "workspace:^4.0.2" diff --git a/packages/unit/src/index.ts b/packages/unit/src/index.ts index 80c4a72c..43bf6078 100644 --- a/packages/unit/src/index.ts +++ b/packages/unit/src/index.ts @@ -4,6 +4,5 @@ export type { TestBedBuilder, SolitaryTestBedBuilder, SociableTestBedBuilder, - ComposedTestBedBuilder, } from './types.js'; export type { UnitReference, MockOverride } from '@suites/core.unit'; diff --git a/packages/unit/src/testbed.ts b/packages/unit/src/testbed.ts index db002214..b4fcbc1a 100644 --- a/packages/unit/src/testbed.ts +++ b/packages/unit/src/testbed.ts @@ -1,8 +1,4 @@ -import { - SuitesDIAdapters, - SuitesDoublesAdapters, - testBedBuilderFactory, -} from './testbed-builder.js'; +import { SuitesDIAdapters, SuitesDoublesAdapters, testBedBuilderFactory } from './testbed-builder.js'; import type { Type } from '@suites/types.common'; import { SociableTestBedBuilder as SociableTestBedBuilderCore, @@ -96,7 +92,7 @@ export class TestBed { */ public static sociable( targetClass: Type - ): Pick, 'expose' | 'compose'> { + ): Pick, 'expose'> { return testBedBuilderFactory(SuitesDIAdapters, SuitesDoublesAdapters, targetClass).create( SociableTestBedBuilderCore ); diff --git a/packages/unit/src/types.ts b/packages/unit/src/types.ts index a0c9a571..a0df3308 100644 --- a/packages/unit/src/types.ts +++ b/packages/unit/src/types.ts @@ -48,31 +48,8 @@ export interface SociableTestBedBuilder extends SociableTestBedBuilderCo * @see {@link https://suites.dev/docs/api-reference/testbed-sociable | TestBed.sociable() API Reference} */ expose(dependency: Type): SociableTestBedBuilder; - - /** - * Constructs the supplied implementation for a string or symbol dependency token. - * - * @param dependency - The token requested by the dependent class. - * @param implementation - The concrete class to construct for that token. - * @returns The sociable builder for further configuration. - */ - expose(dependency: string | symbol, implementation: Type): SociableTestBedBuilder; - - /** - * Constructs a compatible implementation for an abstract or concrete class token. - * - * @param dependency - The class token requested by the dependent class. - * @param implementation - A concrete implementation of the token's instance type. - * @returns The sociable builder for further configuration. - */ - expose( - dependency: TToken, - implementation: Type> - ): SociableTestBedBuilder; } -export type { ComposedTestBedBuilder } from '@suites/core.unit'; - export interface SolitaryTestBedBuilder extends TestBedBuilder {} /** @@ -312,27 +289,7 @@ export interface UnitTestBed { * @template TDependency The type of the dependency to be mocked. * @template TClass The type of the class under test. */ -export interface MockOverride< - TDependency, - TClass, - TBuilder = TestBedBuilder, -> extends MockOverrideCore { - /** - * Generates a mock without an initial implementation. - * Configure and inspect the mock through `unitRef.get()` after compilation. - * - * @returns The originating builder for method chaining. - * @example - * ```ts - * const { unitRef } = await TestBed.solitary(MyService) - * .mock(Logger) - * .auto() - * .compile(); - * unitRef.get(Logger).log.mockReturnValue(undefined); - * ``` - */ - auto(): TBuilder; - +export interface MockOverride extends MockOverrideCore { /** * Provides a mock implementation using stub functions for methods. * @@ -354,7 +311,7 @@ export interface MockOverride< */ impl( mockImplementation: (stubFn: Stub>) => DeepPartial - ): TBuilder; + ): TestBedBuilder; /** * Provides a final implementation with concrete values or functions. @@ -375,7 +332,7 @@ export interface MockOverride< * .compile(); * ``` */ - final(finalImplementation: DeepPartial): TBuilder; + final(finalImplementation: DeepPartial): TestBedBuilder; } /** @@ -394,7 +351,7 @@ export interface TestBedBuilder extends TestBedBuilderCore { * @template TDependency The type of the dependency being mocked. * @returns An instance of MockOverride for further configuration. */ - mock(type: AbstractType): MockOverride; + mock(type: AbstractType): MockOverride; /** * Declares a dependency to be mocked using its type along with a corresponding metadata object. @@ -408,7 +365,7 @@ export interface TestBedBuilder extends TestBedBuilderCore { mock( type: AbstractType, identifierMetadata: IdentifierMetadata - ): MockOverride; + ): MockOverride; /** * Declares a dependency to be mocked using a string-based token. @@ -418,7 +375,7 @@ export interface TestBedBuilder extends TestBedBuilderCore { * @template TDependency The type of the dependency being mocked. * @returns An instance of MockOverride for further configuration. */ - mock(token: string): MockOverride; + mock(token: string): MockOverride; /** * Declares a dependency to be mocked using a string-based token along with a corresponding @@ -433,7 +390,7 @@ export interface TestBedBuilder extends TestBedBuilderCore { mock( token: string, identifierMetadata: IdentifierMetadata - ): MockOverride; + ): MockOverride; /** * Declares a dependency to be mocked using a symbol-based token. @@ -443,7 +400,7 @@ export interface TestBedBuilder extends TestBedBuilderCore { * @template TDependency The type of the dependency being mocked. * @returns An instance of MockOverride for further configuration. */ - mock(token: symbol): MockOverride; + mock(token: symbol): MockOverride; /** * Declares a dependency to be mocked using a symbol-based token along with a corresponding @@ -458,7 +415,7 @@ export interface TestBedBuilder extends TestBedBuilderCore { mock( token: symbol, identifierMetadata: IdentifierMetadata - ): MockOverride; + ): MockOverride; /** * Declares a dependency to be mocked using a flexible identifier. @@ -473,7 +430,7 @@ export interface TestBedBuilder extends TestBedBuilderCore { mock( identifier: InjectableIdentifier, identifierMetadata?: IdentifierMetadata - ): MockOverride; + ): MockOverride; /** * Finalizes the test environment configuration and builds the test instance. * diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f79fb3c7..804f8680 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -359,7 +359,7 @@ importers: packages/doubles/jest: dependencies: '@suites/core.unit': - specifier: workspace:^4.2.0 + specifier: workspace:^4.1.2 version: link:../../core '@suites/types.common': specifier: workspace:^4.0.2 @@ -381,7 +381,7 @@ importers: packages/doubles/vitest: dependencies: '@suites/core.unit': - specifier: workspace:^4.2.0 + specifier: workspace:^4.1.2 version: link:../../core '@suites/types.common': specifier: workspace:^4.0.2 @@ -420,7 +420,7 @@ importers: packages/unit: dependencies: '@suites/core.unit': - specifier: workspace:^4.2.0 + specifier: workspace:^4.1.2 version: link:../core '@suites/types.common': specifier: workspace:^4.0.2 From 4ba448f9cf3606713a3e13875a010feb3db02090 Mon Sep 17 00:00:00 2001 From: omermorad Date: Wed, 11 Nov 2026 00:12:29 +0200 Subject: [PATCH 2/6] fix(core.unit): track selected overrides in usage warnings --- .../core/src/services/dependency-container.ts | 18 ++++--- .../core/src/services/dependency-resolver.ts | 13 +++-- .../core/src/services/override-usage.spec.ts | 50 +++++++++++++++++++ 3 files changed, 69 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/services/override-usage.spec.ts diff --git a/packages/core/src/services/dependency-container.ts b/packages/core/src/services/dependency-container.ts index e91ffcba..ee42f840 100644 --- a/packages/core/src/services/dependency-container.ts +++ b/packages/core/src/services/dependency-container.ts @@ -32,6 +32,14 @@ export class DependencyContainer { identifier: InjectableIdentifier, metadata?: IdentifierMetadata ): StubbedInstance | ConstantValue | undefined { + return this.resolveEntry(identifier, metadata)?.[1] as + StubbedInstance | ConstantValue | undefined; + } + + public resolveEntry( + identifier: InjectableIdentifier, + metadata?: IdentifierMetadata + ): IdentifierToMockOrFinal | undefined { // If there is one identifier, it is enough to match, no need to check metadata const identifiers = this.identifierToDependency.filter( ([{ identifier: injectableIdentifier }]) => injectableIdentifier === identifier @@ -42,7 +50,7 @@ export class DependencyContainer { } if (identifiers.length === 1 && !metadata) { - return identifiers[0][1] as StubbedInstance | ConstantValue; + return identifiers[0]; } // If there are more than one injectable with the same identifier, we need to check the metadata as well @@ -51,9 +59,7 @@ export class DependencyContainer { isDeepStrictEqual(injectableMetadata, metadata) ); - return identifierToMock - ? (identifierToMock[1] as StubbedInstance | ConstantValue) - : undefined; + return identifierToMock; } const foundIdentifier = this.identifierToDependency.find( @@ -61,9 +67,7 @@ export class DependencyContainer { injectableIdentifier === identifier && typeof metadata === 'undefined' ); - return foundIdentifier - ? (foundIdentifier[1] as StubbedInstance | ConstantValue) - : undefined; + return foundIdentifier; } public list(): IdentifierToMockOrFinal[] { diff --git a/packages/core/src/services/dependency-resolver.ts b/packages/core/src/services/dependency-resolver.ts index c4d6f3c8..fea0f68d 100644 --- a/packages/core/src/services/dependency-resolver.ts +++ b/packages/core/src/services/dependency-resolver.ts @@ -12,6 +12,7 @@ import { DependencyMap } from './dependency-map.js'; export class DependencyResolver { private readonly dependencyMap = new DependencyMap(); + private readonly usedMockEntries = new Set(); private readonly availableClassesToExpose = new Set(); constructor( @@ -43,9 +44,11 @@ export class DependencyResolver { return this.dependencyMap.get(identifier) as Type | FinalValue | ClassInjectable; } - const existingMock = this.mockedFromBeforeContainer.resolve(identifier, metadata); + const existingEntry = this.mockedFromBeforeContainer.resolveEntry(identifier, metadata); - if (existingMock !== undefined) { + if (existingEntry !== undefined && existingEntry[1] !== undefined) { + const existingMock = existingEntry[1]; + this.usedMockEntries.add(existingEntry); this.dependencyMap.set(identifier, existingMock, metadata as never); return existingMock; } @@ -135,9 +138,9 @@ export class DependencyResolver { ([injectable]) => !this.availableClassesToExpose.has(injectable.identifier as AbstractType) ); - const notFound = this.mockedFromBeforeContainer.list().filter(([injectable]) => { - return !this.dependencyMap.has(injectable.identifier); - }); + const notFound = this.mockedFromBeforeContainer + .list() + .filter((entry) => !this.usedMockEntries.has(entry)); return { notFound, diff --git a/packages/core/src/services/override-usage.spec.ts b/packages/core/src/services/override-usage.spec.ts new file mode 100644 index 00000000..228f41f4 --- /dev/null +++ b/packages/core/src/services/override-usage.spec.ts @@ -0,0 +1,50 @@ +import type { DependencyInjectionAdapter } from '@suites/types.di'; +import { DependencyContainer, type IdentifierToMockOrFinal } from './dependency-container.js'; +import { DependencyResolver } from './dependency-resolver.js'; + +const adapter = { + inspect: () => ({ list: () => [] }), +} as DependencyInjectionAdapter; + +function resolver(entries: IdentifierToMockOrFinal[]) { + return new DependencyResolver([], new DependencyContainer(entries), adapter, jest.fn); +} + +describe('configured override usage', () => { + it('recognizes a qualified override selected by a bare token', () => { + const selected: IdentifierToMockOrFinal = [ + { identifier: 'TOKEN', metadata: { named: 'primary' } }, + { label: 'configured' }, + ]; + const current = resolver([selected]); + + expect(current.resolveOrMock('TOKEN')).toBe(selected[1]); + expect(current.getResolutionSummary().notFound).toEqual([]); + }); + + it('reports only the unused override when two qualifiers are configured', () => { + const selected: IdentifierToMockOrFinal = [ + { identifier: 'TOKEN', metadata: { named: 'primary' } }, + { label: 'selected' }, + ]; + const unused: IdentifierToMockOrFinal = [ + { identifier: 'TOKEN', metadata: { named: 'secondary' } }, + { label: 'unused' }, + ]; + const current = resolver([selected, unused]); + + expect(current.resolveOrMock('TOKEN', { named: 'primary' })).toBe(selected[1]); + expect(current.getResolutionSummary().notFound).toEqual([unused]); + }); + + it('recognizes a false final value selected by a bare token', () => { + const selected: IdentifierToMockOrFinal = [ + { identifier: 'TOKEN', metadata: { named: 'primary' } }, + false, + ]; + const current = resolver([selected]); + + expect(current.resolveOrMock('TOKEN')).toBe(false); + expect(current.getResolutionSummary().notFound).toEqual([]); + }); +}); From f51642e10699ce9507ab01fd05e797edca9a1b90 Mon Sep 17 00:00:00 2001 From: omermorad Date: Wed, 11 Nov 2026 00:13:15 +0200 Subject: [PATCH 3/6] chore(release): version core.unit 4.1.3 --- packages/core/CHANGELOG.md | 6 ++++++ packages/core/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 41c8ef83..424bdd37 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [4.1.3](https://github.com/suites-dev/unit/compare/%40suites%2Fcore.unit%404.1.2...%40suites%2Fcore.unit%404.1.3) (2026-11-11) + +### Bug Fixes + +* **core.unit:** track selected overrides in usage warnings ([4ba448f](https://github.com/suites-dev/unit/commit/4ba448f9)) + ## [4.1.2](https://github.com/suites-dev/unit/compare/%40suites%2Fcore.unit%404.1.1...%40suites%2Fcore.unit%404.1.2) (2026-11-05) **Note:** Version bump only for package @suites/core.unit diff --git a/packages/core/package.json b/packages/core/package.json index d5937d70..5055213a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@suites/core.unit", "private": false, - "version": "4.1.2", + "version": "4.1.3", "license": "Apache-2.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", From 7a5791de3ab320174ebc81b308f4b6b9f09ae76d Mon Sep 17 00:00:00 2001 From: omermorad Date: Wed, 11 Nov 2026 00:16:03 +0200 Subject: [PATCH 4/6] fix(doubles.jest): preserve getter mocks without eager access --- packages/doubles/jest/src/mock.spec.ts | 231 +++++++++++++++++++++++ packages/doubles/jest/src/mock.static.ts | 98 ++++++---- 2 files changed, 293 insertions(+), 36 deletions(-) diff --git a/packages/doubles/jest/src/mock.spec.ts b/packages/doubles/jest/src/mock.spec.ts index 8787a556..824ab8cd 100644 --- a/packages/doubles/jest/src/mock.spec.ts +++ b/packages/doubles/jest/src/mock.spec.ts @@ -119,6 +119,237 @@ describe('Mocking Proxy Mechanism Unit Spec', () => { }); }); + describe('accessor implementations', () => { + test.each([false, undefined])( + 'preserves a getter returning %s without reading it during initialization', + (value) => { + let reads = 0; + const implementation = { + get enabled() { + reads += 1; + return value; + }, + }; + const descriptor = Object.getOwnPropertyDescriptor(implementation, 'enabled'); + + const mockObject = mock(implementation); + + expect(reads).toBe(0); + expect(Object.getOwnPropertyDescriptor(mockObject, 'enabled')).toEqual(descriptor); + expect(mockObject.enabled).toBe(value); + expect(reads).toBe(1); + } + ); + + test.each([true, false])( + 'preserves getter and setter behavior with configurable=%s', + (configurable) => { + let value = false; + let reads = 0; + let writes = 0; + const implementation = Object.create(null, { + enabled: { + enumerable: true, + configurable, + get() { + reads += 1; + return value; + }, + set(nextValue: boolean) { + writes += 1; + value = nextValue; + }, + }, + }); + const descriptor = Object.getOwnPropertyDescriptor(implementation, 'enabled'); + + const mockObject = mock<{ enabled: boolean }>(implementation); + + expect(reads).toBe(0); + expect(writes).toBe(0); + expect(Object.getOwnPropertyDescriptor(mockObject, 'enabled')).toEqual(descriptor); + mockObject.enabled = true; + expect(writes).toBe(1); + expect(mockObject.enabled).toBe(true); + expect(reads).toBe(1); + } + ); + + test.each([true, false])( + 'preserves a setter-only property with configurable=%s', + (configurable) => { + const received: boolean[] = []; + const implementation = Object.create(null, { + enabled: { + enumerable: true, + configurable, + set(value: boolean) { + received.push(value); + }, + }, + }); + + const mockObject = mock<{ enabled: boolean }>(implementation); + + expect(received).toEqual([]); + expect(mockObject.enabled).toBeUndefined(); + mockObject.enabled = false; + expect(received).toEqual([false]); + } + ); + + test('preserves a non-configurable getter and rejects assignment', () => { + let reads = 0; + const implementation = Object.create(null, { + enabled: { + enumerable: true, + configurable: false, + get() { + reads += 1; + return false; + }, + }, + }); + const mockObject = mock<{ enabled: boolean }>(implementation); + + expect(reads).toBe(0); + expect(mockObject.enabled).toBe(false); + expect(() => { + mockObject.enabled = true; + }).toThrow(TypeError); + expect(mockObject.enabled).toBe(false); + }); + + test('preserves accessors inside a nested supplied implementation', () => { + let reads = 0; + const settings = { + get enabled() { + reads += 1; + return false; + }, + }; + const descriptor = Object.getOwnPropertyDescriptor(settings, 'enabled'); + + const mockObject = mock({ settings }); + + expect(reads).toBe(0); + expect(Object.getOwnPropertyDescriptor(mockObject.settings, 'enabled')).toEqual(descriptor); + expect(mockObject.settings.enabled).toBe(false); + expect(reads).toBe(1); + }); + + test('returns an accessor-provided object without eagerly traversing it', () => { + let reads = 0; + const settings = { enabled: false }; + const implementation = { + get settings() { + reads += 1; + return settings; + }, + }; + + const mockObject = mock(implementation); + + expect(reads).toBe(0); + expect(mockObject.settings).toBe(settings); + expect(reads).toBe(1); + }); + + test.each([true, false])( + 'deep mocks getter/setter objects lazily with configurable=%s', + (configurable) => { + let reads = 0; + let writes = 0; + let child: { nested: { read?: () => string } } = { nested: {} }; + const implementation = Object.create(null, { + child: { + enumerable: true, + configurable, + get() { + reads += 1; + return child; + }, + set(value: typeof child) { + writes += 1; + child = value; + }, + }, + }); + const descriptor = Object.getOwnPropertyDescriptor(implementation, 'child'); + const mockObject = mock<{ child: { nested: { read(): string } } }>(implementation); + + expect(reads).toBe(0); + expect(writes).toBe(0); + const firstChild = mockObject.child; + expect(reads).toBe(1); + firstChild.nested.read.mockReturnValue('first'); + expect(mockObject.child).toBe(firstChild); + expect(mockObject.child.nested.read()).toBe('first'); + expect(writes).toBe(0); + expect(Object.getOwnPropertyDescriptor(mockObject, 'child')).toEqual(descriptor); + + const originalChild = child; + child = { nested: {} }; + const replacement = mockObject.child; + expect(replacement).not.toBe(firstChild); + expect(mockObject.child).toBe(replacement); + replacement.nested.read.mockReturnValue('replacement'); + expect(mockObject.child.nested.read()).toBe('replacement'); + expect(firstChild.nested.read()).toBe('first'); + + child = originalChild; + expect(mockObject.child).toBe(firstChild); + mockObject.child = firstChild; + expect(writes).toBe(1); + expect(mockObject.child).toBe(firstChild); + expect(mockObject.child.nested.read()).toBe('first'); + } + ); + + test('supports spying on getter/setter objects without losing deep mocks', () => { + let child: { read?: () => string } = {}; + const mockObject = mock<{ child: { read(): string } }>({ + get child() { + return child; + }, + set child(value) { + child = value; + }, + }); + const getter = jest.spyOn(mockObject, 'child', 'get'); + + const firstChild = mockObject.child; + firstChild.read.mockReturnValue('mocked'); + expect(getter).toHaveBeenCalledTimes(1); + expect(mockObject.child.read()).toBe('mocked'); + expect(getter).toHaveBeenCalledTimes(2); + + getter.mockRestore(); + expect(mockObject.child).toBe(firstChild); + expect(mockObject.child.read()).toBe('mocked'); + }); + + test('preserves a non-enumerable accessor', () => { + let reads = 0; + const implementation = Object.create(null, { + enabled: { + enumerable: false, + configurable: true, + get() { + reads += 1; + return false; + }, + }, + }); + const mockObject = mock<{ enabled: boolean }>(implementation); + + expect(reads).toBe(0); + expect(Object.keys(mockObject)).not.toContain('enabled'); + expect(mockObject.enabled).toBe(false); + expect(reads).toBe(1); + }); + }); + describe('promises', () => { test('should successfully use mock for promises resolving', async () => { const mockObject = mock(); diff --git a/packages/doubles/jest/src/mock.static.ts b/packages/doubles/jest/src/mock.static.ts index 55d5eca1..1117b392 100644 --- a/packages/doubles/jest/src/mock.static.ts +++ b/packages/doubles/jest/src/mock.static.ts @@ -6,10 +6,15 @@ import type { Mocked } from './types.js'; type ProxiedProperty = string | number | symbol; const overrideMockImp = (obj: DeepPartial): Mocked => { - const proxy = new Proxy>(obj as Mocked, handler()); + const proxy = new Proxy>(obj as Mocked, handler(true)); for (const name of Object.keys(obj)) { const descriptor = Object.getOwnPropertyDescriptor(obj, name); + // Accessors already live on the proxy target; preserve them without evaluating or assigning. + if (descriptor && ('get' in descriptor || 'set' in descriptor)) { + continue; + } + if (descriptor && !descriptor.writable && !descriptor.configurable) { continue; } @@ -26,48 +31,69 @@ const overrideMockImp = (obj: DeepPartial): Mocked => { return proxy; }; -const handler = () => ({ - set: (obj: Mocked, property: ProxiedProperty, value: T) => { - const descriptor = Object.getOwnPropertyDescriptor(obj, property); - if (descriptor && !descriptor.writable && !descriptor.configurable) { - return true; - } - // @ts-ignore https://github.com/microsoft/TypeScript/issues/1863 - obj[property] = value; - return true; - }, - - get: (obj: Mocked, property: ProxiedProperty) => { - // @ts-ignore - if (!(property in obj)) { - if (property === 'then') { - return undefined; +const handler = (deepMockAccessors = false) => { + const accessorMocks = new WeakMap(); + + return { + set: (obj: Mocked, property: ProxiedProperty, value: T) => { + const descriptor = Object.getOwnPropertyDescriptor(obj, property); + if (descriptor && 'value' in descriptor && !descriptor.writable && !descriptor.configurable) { + return true; } + // @ts-ignore https://github.com/microsoft/TypeScript/issues/1863 + obj[property] = value; + return true; + }, - // Jest's internal equality checking does some weird stuff to check for iterable equality - if (property === Symbol.iterator) { - // @ts-ignore - return obj[property]; + get: (obj: Mocked, property: ProxiedProperty) => { + // @ts-ignore + if (!(property in obj)) { + if (property === 'then') { + return undefined; + } + + // Jest's internal equality checking does some weird stuff to check for iterable equality + if (property === Symbol.iterator) { + // @ts-ignore + return obj[property]; + } + + if (property !== 'calls') { + // @ts-ignore + obj[property] = new Proxy>(jestGlobals.fn(), handler()); + // @ts-ignore private property + obj[property as never]._isMockObject = true; + } } - if (property !== 'calls') { - // @ts-ignore - obj[property] = new Proxy>(jestGlobals.fn(), handler()); - // @ts-ignore private property - obj[property as never]._isMockObject = true; + const value: unknown = Reflect.get(obj, property); + const descriptor = Object.getOwnPropertyDescriptor(obj, property); + if ( + deepMockAccessors && + descriptor?.enumerable && + descriptor.get && + descriptor.set && + typeof value === 'object' && + value !== null + ) { + const cached = accessorMocks.get(value); + if (cached) { + return cached; + } + const mocked = overrideMockImp(value); + accessorMocks.set(value, mocked); + accessorMocks.set(mocked, mocked); + return mocked; } - } - // @ts-ignore - if (obj instanceof Date && typeof obj[property] === 'function') { - // @ts-ignore - return obj[property].bind(obj); - } + if (obj instanceof Date && typeof value === 'function') { + return value.bind(obj); + } - // @ts-ignore - return obj[property]; - }, -}); + return value; + }, + }; +}; /** * Creates a fully mocked instance with auto-generated Jest stub methods. From 8938461c0ff997c26d4be12c5071e66ff8c39395 Mon Sep 17 00:00:00 2001 From: omermorad Date: Wed, 11 Nov 2026 00:16:07 +0200 Subject: [PATCH 5/6] fix(doubles.vitest): preserve getter mocks without eager access --- packages/doubles/vitest/src/mock.spec.ts | 232 +++++++++++++++++++++ packages/doubles/vitest/src/mock.static.ts | 100 +++++---- 2 files changed, 295 insertions(+), 37 deletions(-) diff --git a/packages/doubles/vitest/src/mock.spec.ts b/packages/doubles/vitest/src/mock.spec.ts index 21353b4b..df3210b6 100644 --- a/packages/doubles/vitest/src/mock.spec.ts +++ b/packages/doubles/vitest/src/mock.spec.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ +import { vi } from 'vitest'; import { mock } from './mock.static.js'; import type { Mocked } from './types.js'; @@ -119,6 +120,237 @@ describe('Mocking Proxy Mechanism Unit Spec', () => { }); }); + describe('accessor implementations', () => { + test.each([false, undefined])( + 'preserves a getter returning %s without reading it during initialization', + (value) => { + let reads = 0; + const implementation = { + get enabled() { + reads += 1; + return value; + }, + }; + const descriptor = Object.getOwnPropertyDescriptor(implementation, 'enabled'); + + const mockObject = mock(implementation); + + expect(reads).toBe(0); + expect(Object.getOwnPropertyDescriptor(mockObject, 'enabled')).toEqual(descriptor); + expect(mockObject.enabled).toBe(value); + expect(reads).toBe(1); + } + ); + + test.each([true, false])( + 'preserves getter and setter behavior with configurable=%s', + (configurable) => { + let value = false; + let reads = 0; + let writes = 0; + const implementation = Object.create(null, { + enabled: { + enumerable: true, + configurable, + get() { + reads += 1; + return value; + }, + set(nextValue: boolean) { + writes += 1; + value = nextValue; + }, + }, + }); + const descriptor = Object.getOwnPropertyDescriptor(implementation, 'enabled'); + + const mockObject = mock<{ enabled: boolean }>(implementation); + + expect(reads).toBe(0); + expect(writes).toBe(0); + expect(Object.getOwnPropertyDescriptor(mockObject, 'enabled')).toEqual(descriptor); + mockObject.enabled = true; + expect(writes).toBe(1); + expect(mockObject.enabled).toBe(true); + expect(reads).toBe(1); + } + ); + + test.each([true, false])( + 'preserves a setter-only property with configurable=%s', + (configurable) => { + const received: boolean[] = []; + const implementation = Object.create(null, { + enabled: { + enumerable: true, + configurable, + set(value: boolean) { + received.push(value); + }, + }, + }); + + const mockObject = mock<{ enabled: boolean }>(implementation); + + expect(received).toEqual([]); + expect(mockObject.enabled).toBeUndefined(); + mockObject.enabled = false; + expect(received).toEqual([false]); + } + ); + + test('preserves a non-configurable getter and rejects assignment', () => { + let reads = 0; + const implementation = Object.create(null, { + enabled: { + enumerable: true, + configurable: false, + get() { + reads += 1; + return false; + }, + }, + }); + const mockObject = mock<{ enabled: boolean }>(implementation); + + expect(reads).toBe(0); + expect(mockObject.enabled).toBe(false); + expect(() => { + mockObject.enabled = true; + }).toThrow(TypeError); + expect(mockObject.enabled).toBe(false); + }); + + test('preserves accessors inside a nested supplied implementation', () => { + let reads = 0; + const settings = { + get enabled() { + reads += 1; + return false; + }, + }; + const descriptor = Object.getOwnPropertyDescriptor(settings, 'enabled'); + + const mockObject = mock({ settings }); + + expect(reads).toBe(0); + expect(Object.getOwnPropertyDescriptor(mockObject.settings, 'enabled')).toEqual(descriptor); + expect(mockObject.settings.enabled).toBe(false); + expect(reads).toBe(1); + }); + + test('returns an accessor-provided object without eagerly traversing it', () => { + let reads = 0; + const settings = { enabled: false }; + const implementation = { + get settings() { + reads += 1; + return settings; + }, + }; + + const mockObject = mock(implementation); + + expect(reads).toBe(0); + expect(mockObject.settings).toBe(settings); + expect(reads).toBe(1); + }); + + test.each([true, false])( + 'deep mocks getter/setter objects lazily with configurable=%s', + (configurable) => { + let reads = 0; + let writes = 0; + let child: { nested: { read?: () => string } } = { nested: {} }; + const implementation = Object.create(null, { + child: { + enumerable: true, + configurable, + get() { + reads += 1; + return child; + }, + set(value: typeof child) { + writes += 1; + child = value; + }, + }, + }); + const descriptor = Object.getOwnPropertyDescriptor(implementation, 'child'); + const mockObject = mock<{ child: { nested: { read(): string } } }>(implementation); + + expect(reads).toBe(0); + expect(writes).toBe(0); + const firstChild = mockObject.child; + expect(reads).toBe(1); + firstChild.nested.read.mockReturnValue('first'); + expect(mockObject.child).toBe(firstChild); + expect(mockObject.child.nested.read()).toBe('first'); + expect(writes).toBe(0); + expect(Object.getOwnPropertyDescriptor(mockObject, 'child')).toEqual(descriptor); + + const originalChild = child; + child = { nested: {} }; + const replacement = mockObject.child; + expect(replacement).not.toBe(firstChild); + expect(mockObject.child).toBe(replacement); + replacement.nested.read.mockReturnValue('replacement'); + expect(mockObject.child.nested.read()).toBe('replacement'); + expect(firstChild.nested.read()).toBe('first'); + + child = originalChild; + expect(mockObject.child).toBe(firstChild); + mockObject.child = firstChild; + expect(writes).toBe(1); + expect(mockObject.child).toBe(firstChild); + expect(mockObject.child.nested.read()).toBe('first'); + } + ); + + test('supports spying on getter/setter objects without losing deep mocks', () => { + let child: { read?: () => string } = {}; + const mockObject = mock<{ child: { read(): string } }>({ + get child() { + return child; + }, + set child(value) { + child = value; + }, + }); + const getter = vi.spyOn(mockObject, 'child', 'get'); + + const firstChild = mockObject.child; + firstChild.read.mockReturnValue('mocked'); + expect(getter).toHaveBeenCalledTimes(1); + expect(mockObject.child.read()).toBe('mocked'); + expect(getter).toHaveBeenCalledTimes(2); + + getter.mockRestore(); + expect(mockObject.child).toBe(firstChild); + expect(mockObject.child.read()).toBe('mocked'); + }); + + test('preserves a non-enumerable accessor', () => { + let reads = 0; + const implementation = Object.create(null, { + enabled: { + enumerable: false, + configurable: true, + get() { + reads += 1; + return false; + }, + }, + }); + const mockObject = mock<{ enabled: boolean }>(implementation); + + expect(reads).toBe(0); + expect(Object.keys(mockObject)).not.toContain('enabled'); + expect(mockObject.enabled).toBe(false); + expect(reads).toBe(1); + }); + }); + describe('promises', () => { test('should successfully use mock for promises resolving', async () => { const mockObject = mock(); diff --git a/packages/doubles/vitest/src/mock.static.ts b/packages/doubles/vitest/src/mock.static.ts index 0c15c521..2b9945da 100644 --- a/packages/doubles/vitest/src/mock.static.ts +++ b/packages/doubles/vitest/src/mock.static.ts @@ -6,10 +6,15 @@ import type { Mocked } from './types.js'; type ProxiedProperty = string | number | symbol; const overrideMockImp = (obj: DeepPartial): Mocked => { - const proxy = new Proxy>(obj as Mocked, handler()); + const proxy = new Proxy>(obj as Mocked, handler(true)); for (const name of Object.keys(obj)) { const descriptor = Object.getOwnPropertyDescriptor(obj, name); + // Accessors already live on the proxy target; preserve them without evaluating or assigning. + if (descriptor && ('get' in descriptor || 'set' in descriptor)) { + continue; + } + if (descriptor && !descriptor.writable && !descriptor.configurable) { continue; } @@ -26,49 +31,70 @@ const overrideMockImp = (obj: DeepPartial): Mocked => { return proxy; }; -const handler = () => ({ - set: (obj: Mocked, property: ProxiedProperty, value: T) => { - const descriptor = Object.getOwnPropertyDescriptor(obj, property); - if (descriptor && !descriptor.writable && !descriptor.configurable) { - return true; - } - // @ts-ignore https://github.com/microsoft/TypeScript/issues/1863 - obj[property] = value; - return true; - }, - - get: (obj: Mocked, property: ProxiedProperty) => { - // @ts-ignore - if (!(property in obj)) { - if (property === 'then') { - return undefined; +const handler = (deepMockAccessors = false) => { + const accessorMocks = new WeakMap(); + + return { + set: (obj: Mocked, property: ProxiedProperty, value: T) => { + const descriptor = Object.getOwnPropertyDescriptor(obj, property); + if (descriptor && 'value' in descriptor && !descriptor.writable && !descriptor.configurable) { + return true; } + // @ts-ignore https://github.com/microsoft/TypeScript/issues/1863 + obj[property] = value; + return true; + }, - // Vitest's internal equality checking does some weird stuff to check for iterable equality - if (property === Symbol.iterator) { - // @ts-ignore - return obj[property]; + get: (obj: Mocked, property: ProxiedProperty) => { + // @ts-ignore + if (!(property in obj)) { + if (property === 'then') { + return undefined; + } + + // Vitest's internal equality checking does some weird stuff to check for iterable equality + if (property === Symbol.iterator) { + // @ts-ignore + return obj[property]; + } + + if (property !== 'calls') { + // @ts-ignore + // eslint-disable-next-line no-undef + obj[property] = new Proxy>(vi.fn(), handler()); + // @ts-ignore private property + obj[property as never]._isMockObject = true; + } } - if (property !== 'calls') { - // @ts-ignore - // eslint-disable-next-line no-undef - obj[property] = new Proxy>(vi.fn(), handler()); - // @ts-ignore private property - obj[property as never]._isMockObject = true; + const value: unknown = Reflect.get(obj, property); + const descriptor = Object.getOwnPropertyDescriptor(obj, property); + if ( + deepMockAccessors && + descriptor?.enumerable && + descriptor.get && + descriptor.set && + typeof value === 'object' && + value !== null + ) { + const cached = accessorMocks.get(value); + if (cached) { + return cached; + } + const mocked = overrideMockImp(value); + accessorMocks.set(value, mocked); + accessorMocks.set(mocked, mocked); + return mocked; } - } - // @ts-ignore - if (obj instanceof Date && typeof obj[property] === 'function') { - // @ts-ignore - return obj[property].bind(obj); - } + if (obj instanceof Date && typeof value === 'function') { + return value.bind(obj); + } - // @ts-ignore - return obj[property]; - }, -}); + return value; + }, + }; +}; /** * Creates a fully mocked instance with auto-generated Vitest stub methods. From fee5d3d8d22e094e976fdeb2d9ad73b137a0504b Mon Sep 17 00:00:00 2001 From: omermorad Date: Wed, 11 Nov 2026 00:16:09 +0200 Subject: [PATCH 6/6] chore(release): version getter fixes as 4.1.3 patches --- packages/doubles/jest/CHANGELOG.md | 6 ++++++ packages/doubles/jest/package.json | 2 +- packages/doubles/vitest/CHANGELOG.md | 6 ++++++ packages/doubles/vitest/package.json | 2 +- 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/doubles/jest/CHANGELOG.md b/packages/doubles/jest/CHANGELOG.md index c8170c7f..37919a92 100644 --- a/packages/doubles/jest/CHANGELOG.md +++ b/packages/doubles/jest/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [4.1.3](https://github.com/suites-dev/unit/compare/%40suites%2Fdoubles.jest%404.1.2...%40suites%2Fdoubles.jest%404.1.3) (2026-11-11) + +### Bug Fixes + +* **doubles.jest:** preserve accessors and lazily mock nested getter/setter objects + ## [4.1.2](https://github.com/suites-dev/unit/compare/%40suites%2Fdoubles.jest%404.1.1...%40suites%2Fdoubles.jest%404.1.2) (2026-11-05) **Note:** Version bump only for package @suites/doubles.jest diff --git a/packages/doubles/jest/package.json b/packages/doubles/jest/package.json index 9b0f05eb..09d662a1 100644 --- a/packages/doubles/jest/package.json +++ b/packages/doubles/jest/package.json @@ -1,6 +1,6 @@ { "name": "@suites/doubles.jest", - "version": "4.1.2", + "version": "4.1.3", "license": "Apache-2.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", diff --git a/packages/doubles/vitest/CHANGELOG.md b/packages/doubles/vitest/CHANGELOG.md index d96725ee..65539de3 100644 --- a/packages/doubles/vitest/CHANGELOG.md +++ b/packages/doubles/vitest/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [4.1.3](https://github.com/suites-dev/unit/compare/%40suites%2Fdoubles.vitest%404.1.2...%40suites%2Fdoubles.vitest%404.1.3) (2026-11-11) + +### Bug Fixes + +* **doubles.vitest:** preserve accessors and lazily mock nested getter/setter objects + ## [4.1.2](https://github.com/suites-dev/unit/compare/%40suites%2Fdoubles.vitest%404.1.1...%40suites%2Fdoubles.vitest%404.1.2) (2026-11-05) **Note:** Version bump only for package @suites/doubles.vitest diff --git a/packages/doubles/vitest/package.json b/packages/doubles/vitest/package.json index 33a54716..faf364b2 100644 --- a/packages/doubles/vitest/package.json +++ b/packages/doubles/vitest/package.json @@ -1,6 +1,6 @@ { "name": "@suites/doubles.vitest", - "version": "4.1.2", + "version": "4.1.3", "license": "Apache-2.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js",