diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index 0111308..69ea7a8 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -1,17 +1,17 @@ import { - Type, + Binding, DebugElement, - ModuleWithProviders, - EventEmitter, EnvironmentProviders, + EventEmitter, + InputSignalWithTransform, + ModuleWithProviders, Provider, Signal, - InputSignalWithTransform, - Binding, + Type, } from '@angular/core'; import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing'; import { Routes } from '@angular/router'; -import { BoundFunctions, Queries, queries, Config as dtlConfig, PrettyDOMOptions } from '@testing-library/dom'; +import { BoundFunctions, Config as dtlConfig, PrettyDOMOptions, Queries, queries } from '@testing-library/dom'; // TODO: import from Angular (is a breaking change) interface OutputRef { @@ -381,6 +381,24 @@ export interface RenderComponentOptions | unknown[])[]; + /** + * @description + * Replace specific imports on a standalone component without replacing the entire imports array. + * Unlike `componentImports`, which replaces all imports, this option lets you swap out targeted + * child components without needing to enumerate all other imports. + * Mutually exclusive with `componentImports`. + * + * @default + * undefined + * + * @example + * await render(AppComponent, { + * importOverrides: [ + * { replace: RealChildComponent, with: MockChildComponent } + * ] + * }) + */ + importOverrides?: ImportOverride[]; /** * @description * Queries to bind. Overrides the default set from DOM Testing Library unless merged. @@ -492,6 +510,13 @@ export interface RenderComponentOptions; + /** The replacement import to use instead */ + with: Type | unknown[]; +} + export interface ComponentOverride { component: Type; providers: Provider[]; diff --git a/projects/testing-library/src/lib/testing-library.ts b/projects/testing-library/src/lib/testing-library.ts index b31d9c9..17a1f4e 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -1,5 +1,7 @@ import { ApplicationInitStatus, + ApplicationRef, + Binding, ChangeDetectorRef, Component, NgZone, @@ -11,8 +13,6 @@ import { SimpleChanges, Type, isStandalone, - Binding, - ApplicationRef, } from '@angular/core'; import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed, tick } from '@angular/core/testing'; import { NavigationExtras, Router } from '@angular/router'; @@ -32,11 +32,12 @@ import { import { getConfig } from './config'; import { ComponentOverride, + Config, + ImportOverride, OutputRefKeysWithCallback, RenderComponentOptions, RenderResult, RenderTemplateOptions, - Config, } from './models'; type SubscribedOutput = readonly [key: keyof T, callback: (v: any) => void, subscription: OutputRefSubscription]; @@ -75,6 +76,7 @@ export async function render( componentProviders = [], childComponentOverrides = [], componentImports, + importOverrides, excludeComponentDeclaration = false, routes = [], removeAngularAttributes = false, @@ -100,6 +102,12 @@ export async function render( ...domConfig, }); + if (componentImports && importOverrides) { + throw new Error( + `Cannot specify both componentImports and importOverrides. Use componentImports for full replacement, or importOverrides for targeted replacement.`, + ); + } + TestBed.configureTestingModule({ declarations: addAutoDeclarations(sut, { declarations, @@ -115,6 +123,7 @@ export async function render( deferBlockBehavior: deferBlockBehavior ?? DeferBlockBehavior.Manual, }); overrideComponentImports(sut, componentImports); + applyImportOverrides(sut, importOverrides); overrideChildComponentProviders(childComponentOverrides); configureTestBed(TestBed); @@ -462,6 +471,21 @@ function overrideComponentImports(sut: Type | string, imports: } } +function applyImportOverrides(sut: Type | string, overrides: ImportOverride[] | undefined) { + if (overrides?.length) { + if (typeof sut === 'function' && isStandalone(sut)) { + TestBed.overrideComponent(sut, { + remove: { imports: overrides.map((o) => o.replace) }, + add: { imports: overrides.map((o) => o.with) }, + }); + } else { + throw new Error( + `Error while rendering ${sut}: Cannot specify importOverrides on a template or non-standalone component.`, + ); + } + } +} + function overrideChildComponentProviders(componentOverrides: ComponentOverride[]) { if (componentOverrides) { for (const { component, providers } of componentOverrides) { diff --git a/projects/testing-library/src/tests/import-overrides.spec.ts b/projects/testing-library/src/tests/import-overrides.spec.ts new file mode 100644 index 0000000..f6531b4 --- /dev/null +++ b/projects/testing-library/src/tests/import-overrides.spec.ts @@ -0,0 +1,86 @@ +import { Component } from '@angular/core'; +import { expect, test } from 'vitest'; +import { render, screen } from '../public_api'; + +@Component({ + selector: 'atl-child', + template: `Hello from child`, + standalone: true, +}) +class ChildComponent {} + +@Component({ + selector: 'atl-child', + template: `Hello from stub`, + standalone: true, + host: { 'collision-id': 'StubComponent' }, +}) +class StubChildComponent {} + +@Component({ + selector: 'atl-other', + template: `Hello from other`, + standalone: true, +}) +class OtherComponent {} + +@Component({ + selector: 'atl-fixture', + template: ``, + standalone: true, + imports: [ChildComponent, OtherComponent], +}) +class FixtureComponent {} + +@Component({ + selector: 'atl-non-standalone', + template: `non-standalone`, + standalone: false, +}) +class NonStandaloneComponent {} + +test('importOverrides - replaces a single import', async () => { + await render(FixtureComponent, { + importOverrides: [{ replace: ChildComponent, with: StubChildComponent }], + }); + + expect(screen.getByText('Hello from stub')).toBeInTheDocument(); + expect(screen.queryByText('Hello from child')).not.toBeInTheDocument(); +}); + +test('importOverrides - leaves other imports intact', async () => { + await render(FixtureComponent, { + importOverrides: [{ replace: ChildComponent, with: StubChildComponent }], + }); + + expect(screen.getByText('Hello from stub')).toBeInTheDocument(); + expect(screen.getByText('Hello from other')).toBeInTheDocument(); +}); + +test('importOverrides - throws on non-standalone component', async () => { + await expect( + render(NonStandaloneComponent, { + declarations: [NonStandaloneComponent], + excludeComponentDeclaration: true, + importOverrides: [{ replace: ChildComponent, with: StubChildComponent }], + } as any), + ).rejects.toThrow(/Cannot specify importOverrides on a template or non-standalone component/); +}); + +test('importOverrides - throws when used with componentImports', async () => { + await expect( + render(FixtureComponent, { + componentImports: [ChildComponent], + importOverrides: [{ replace: ChildComponent, with: StubChildComponent }], + }), + ).rejects.toThrow(/Cannot specify both componentImports and importOverrides/); +}); + +test('importOverrides - empty array is a no-op', async () => { + await render(FixtureComponent, { + importOverrides: [], + }); + + expect(screen.getByText('Hello from child')).toBeInTheDocument(); + expect(screen.getByText('Hello from other')).toBeInTheDocument(); +});