Skip to content

Commit 633ecbd

Browse files
authored
feat: add importOverrides (#578)
1 parent a952a6a commit 633ecbd

5 files changed

Lines changed: 266 additions & 10 deletions

File tree

projects/testing-library/src/lib/models.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import {
2-
Type,
2+
Binding,
33
DebugElement,
4-
ModuleWithProviders,
5-
EventEmitter,
64
EnvironmentProviders,
5+
EventEmitter,
6+
InputSignalWithTransform,
7+
ModuleWithProviders,
78
Provider,
89
Signal,
9-
InputSignalWithTransform,
10-
Binding,
10+
Type,
1111
} from '@angular/core';
1212
import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing';
1313
import { Routes } from '@angular/router';
14-
import { BoundFunctions, Queries, queries, Config as dtlConfig, PrettyDOMOptions } from '@testing-library/dom';
14+
import { BoundFunctions, Config as dtlConfig, PrettyDOMOptions, Queries, queries } from '@testing-library/dom';
1515

1616
// TODO: import from Angular (is a breaking change)
1717
interface OutputRef<T> {
@@ -370,6 +370,7 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
370370
* @description
371371
* A collection of imports to override a standalone component's imports with.
372372
*
373+
* @deprecated use the `importOverrides` option instead.
373374
* @default
374375
* undefined
375376
*
@@ -381,6 +382,24 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
381382
* })
382383
*/
383384
componentImports?: (Type<unknown> | unknown[])[];
385+
/**
386+
* @description
387+
* Replace specific imports on a standalone component without replacing the entire imports array.
388+
* Unlike `componentImports`, which replaces all imports, this option lets you swap out targeted
389+
* child components without needing to enumerate all other imports.
390+
* Mutually exclusive with `componentImports`.
391+
*
392+
* @default
393+
* undefined
394+
*
395+
* @example
396+
* await render(AppComponent, {
397+
* importOverrides: [
398+
* { replace: RealChildComponent, with: MockChildComponent }
399+
* ]
400+
* })
401+
*/
402+
importOverrides?: ImportOverride[];
384403
/**
385404
* @description
386405
* Queries to bind. Overrides the default set from DOM Testing Library unless merged.
@@ -492,6 +511,13 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
492511
deferBlockBehavior?: DeferBlockBehavior;
493512
}
494513

514+
export interface ImportOverride {
515+
/** The import to replace (matched by identity) */
516+
replace: Type<unknown>;
517+
/** The replacement import to use instead */
518+
with: Type<unknown> | unknown[];
519+
}
520+
495521
export interface ComponentOverride<T> {
496522
component: Type<T>;
497523
providers: Provider[];

projects/testing-library/src/lib/testing-library.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import {
22
ApplicationInitStatus,
3+
ApplicationRef,
4+
Binding,
35
ChangeDetectorRef,
46
Component,
57
NgZone,
@@ -11,8 +13,6 @@ import {
1113
SimpleChanges,
1214
Type,
1315
isStandalone,
14-
Binding,
15-
ApplicationRef,
1616
} from '@angular/core';
1717
import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed, tick } from '@angular/core/testing';
1818
import { NavigationExtras, Router } from '@angular/router';
@@ -32,11 +32,12 @@ import {
3232
import { getConfig } from './config';
3333
import {
3434
ComponentOverride,
35+
Config,
36+
ImportOverride,
3537
OutputRefKeysWithCallback,
3638
RenderComponentOptions,
3739
RenderResult,
3840
RenderTemplateOptions,
39-
Config,
4041
} from './models';
4142

4243
type SubscribedOutput<T> = readonly [key: keyof T, callback: (v: any) => void, subscription: OutputRefSubscription];
@@ -75,6 +76,7 @@ export async function render<SutType, WrapperType = SutType>(
7576
componentProviders = [],
7677
childComponentOverrides = [],
7778
componentImports,
79+
importOverrides,
7880
excludeComponentDeclaration = false,
7981
routes = [],
8082
removeAngularAttributes = false,
@@ -100,6 +102,12 @@ export async function render<SutType, WrapperType = SutType>(
100102
...domConfig,
101103
});
102104

105+
if (componentImports && importOverrides) {
106+
throw new Error(
107+
`Cannot specify both componentImports and importOverrides. Use componentImports for full replacement, or importOverrides for targeted replacement.`,
108+
);
109+
}
110+
103111
TestBed.configureTestingModule({
104112
declarations: addAutoDeclarations(sut, {
105113
declarations,
@@ -115,6 +123,7 @@ export async function render<SutType, WrapperType = SutType>(
115123
deferBlockBehavior: deferBlockBehavior ?? DeferBlockBehavior.Manual,
116124
});
117125
overrideComponentImports(sut, componentImports);
126+
applyImportOverrides(sut, importOverrides);
118127
overrideChildComponentProviders(childComponentOverrides);
119128

120129
configureTestBed(TestBed);
@@ -462,6 +471,21 @@ function overrideComponentImports<SutType>(sut: Type<SutType> | string, imports:
462471
}
463472
}
464473

474+
function applyImportOverrides<SutType>(sut: Type<SutType> | string, overrides: ImportOverride[] | undefined) {
475+
if (overrides?.length) {
476+
if (typeof sut === 'function' && isStandalone(sut)) {
477+
TestBed.overrideComponent(sut, {
478+
remove: { imports: overrides.map((o) => o.replace) },
479+
add: { imports: overrides.map((o) => o.with) },
480+
});
481+
} else {
482+
throw new Error(
483+
`Error while rendering ${sut}: Cannot specify importOverrides on a template or non-standalone component.`,
484+
);
485+
}
486+
}
487+
}
488+
465489
function overrideChildComponentProviders(componentOverrides: ComponentOverride<any>[]) {
466490
if (componentOverrides) {
467491
for (const { component, providers } of componentOverrides) {
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { Component } from '@angular/core';
2+
import { expect, test } from 'vitest';
3+
import { render, screen } from '../public_api';
4+
5+
@Component({
6+
selector: 'atl-child',
7+
template: `Hello from child`,
8+
standalone: true,
9+
})
10+
class ChildComponent {}
11+
12+
@Component({
13+
selector: 'atl-child',
14+
template: `Hello from stub`,
15+
standalone: true,
16+
host: { 'collision-id': 'StubComponent' },
17+
})
18+
class StubChildComponent {}
19+
20+
@Component({
21+
selector: 'atl-other',
22+
template: `Hello from other`,
23+
standalone: true,
24+
})
25+
class OtherComponent {}
26+
27+
@Component({
28+
selector: 'atl-fixture',
29+
template: `<atl-child /><atl-other />`,
30+
standalone: true,
31+
imports: [ChildComponent, OtherComponent],
32+
})
33+
class FixtureComponent {}
34+
35+
@Component({
36+
selector: 'atl-non-standalone',
37+
template: `non-standalone`,
38+
standalone: false,
39+
})
40+
class NonStandaloneComponent {}
41+
42+
test('importOverrides - replaces a single import', async () => {
43+
await render(FixtureComponent, {
44+
importOverrides: [{ replace: ChildComponent, with: StubChildComponent }],
45+
});
46+
47+
expect(screen.getByText('Hello from stub')).toBeInTheDocument();
48+
expect(screen.queryByText('Hello from child')).not.toBeInTheDocument();
49+
});
50+
51+
test('importOverrides - leaves other imports intact', async () => {
52+
await render(FixtureComponent, {
53+
importOverrides: [{ replace: ChildComponent, with: StubChildComponent }],
54+
});
55+
56+
expect(screen.getByText('Hello from stub')).toBeInTheDocument();
57+
expect(screen.getByText('Hello from other')).toBeInTheDocument();
58+
});
59+
60+
test('importOverrides - throws on non-standalone component', async () => {
61+
await expect(
62+
render(NonStandaloneComponent, {
63+
declarations: [NonStandaloneComponent],
64+
excludeComponentDeclaration: true,
65+
importOverrides: [{ replace: ChildComponent, with: StubChildComponent }],
66+
} as any),
67+
).rejects.toThrow(/Cannot specify importOverrides on a template or non-standalone component/);
68+
});
69+
70+
test('importOverrides - throws when used with componentImports', async () => {
71+
await expect(
72+
render(FixtureComponent, {
73+
componentImports: [ChildComponent],
74+
importOverrides: [{ replace: ChildComponent, with: StubChildComponent }],
75+
}),
76+
).rejects.toThrow(/Cannot specify both componentImports and importOverrides/);
77+
});
78+
79+
test('importOverrides - empty array is a no-op', async () => {
80+
await render(FixtureComponent, {
81+
importOverrides: [],
82+
});
83+
84+
expect(screen.getByText('Hello from child')).toBeInTheDocument();
85+
expect(screen.getByText('Hello from other')).toBeInTheDocument();
86+
});

projects/testing-library/src/tests/zoneless.spec.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,81 @@ test('renders and interacts with the component using a template', async () => {
154154
await vi.waitFor(() => expect(valueControl).toHaveTextContent('20'));
155155
});
156156

157+
@Component({
158+
selector: 'atl-child',
159+
template: `<span data-testid="child">Real Child</span>`,
160+
})
161+
class ChildComponent {}
162+
163+
@Component({
164+
selector: 'atl-child',
165+
template: `<span data-testid="child">Mock Child</span>`,
166+
})
167+
class MockChildComponent {}
168+
169+
@Component({
170+
selector: 'atl-other',
171+
template: `<span data-testid="other">Other</span>`,
172+
})
173+
class OtherComponent {}
174+
175+
@Component({
176+
selector: 'atl-parent',
177+
template: `<atl-child /><atl-other />`,
178+
imports: [ChildComponent, OtherComponent],
179+
})
180+
class ParentComponent {}
181+
182+
@Component({
183+
selector: 'atl-non-standalone',
184+
template: `non-standalone`,
185+
standalone: false,
186+
})
187+
class NonStandaloneComponent {}
188+
189+
test('replaces an import with importOverrides', async () => {
190+
await render(ParentComponent, {
191+
importOverrides: [{ replace: ChildComponent, with: MockChildComponent }],
192+
});
193+
194+
expect(screen.getByTestId('child')).toHaveTextContent('Mock Child');
195+
});
196+
197+
test('importOverrides leaves other imports intact', async () => {
198+
await render(ParentComponent, {
199+
importOverrides: [{ replace: ChildComponent, with: MockChildComponent }],
200+
});
201+
202+
expect(screen.getByTestId('child')).toHaveTextContent('Mock Child');
203+
expect(screen.getByTestId('other')).toHaveTextContent('Other');
204+
});
205+
206+
test('importOverrides throws on non-standalone component', async () => {
207+
await expect(
208+
render(NonStandaloneComponent, {
209+
importOverrides: [{ replace: ChildComponent, with: MockChildComponent }],
210+
} as any),
211+
).rejects.toThrow(/Cannot specify importOverrides on a template or non-standalone component/);
212+
});
213+
214+
test('throws when importOverrides is used on a template', async () => {
215+
await expect(
216+
render(`<atl-parent />`, {
217+
imports: [ParentComponent],
218+
importOverrides: [{ replace: ChildComponent, with: MockChildComponent }],
219+
} as any),
220+
).rejects.toThrow(/Cannot specify importOverrides on a template or non-standalone component/);
221+
});
222+
223+
test('importOverrides empty array is a no-op', async () => {
224+
await render(ParentComponent, {
225+
importOverrides: [],
226+
});
227+
228+
expect(screen.getByTestId('child')).toHaveTextContent('Real Child');
229+
expect(screen.getByTestId('other')).toHaveTextContent('Other');
230+
});
231+
157232
test('can provide custom service providers', async () => {
158233
const user = userEvent.setup();
159234
await render(ServiceFixtureComponent, {

projects/testing-library/zoneless/src/public_api.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { Component, type Type, type Binding, type Provider, type EnvironmentProviders } from '@angular/core';
1+
import {
2+
Component,
3+
isStandalone,
4+
type Binding,
5+
type EnvironmentProviders,
6+
type Provider,
7+
type Type,
8+
} from '@angular/core';
29
import { ComponentFixture, TestBed } from '@angular/core/testing';
310
import {
411
getQueriesForElement,
@@ -110,6 +117,13 @@ export interface RenderOptions<Q extends Queries = typeof queries> {
110117
providers?: (Provider | EnvironmentProviders)[];
111118
}
112119

120+
export interface ImportOverride {
121+
/** The import to replace (matched by identity) */
122+
replace: Type<unknown>;
123+
/** The replacement import to use instead */
124+
with: Type<unknown> | unknown[];
125+
}
126+
113127
export interface RenderComponentOptions<Q extends Queries = typeof queries> extends RenderOptions<Q> {
114128
/**
115129
* @description
@@ -132,6 +146,22 @@ export interface RenderComponentOptions<Q extends Queries = typeof queries> exte
132146
* })
133147
*/
134148
bindings?: Binding[];
149+
150+
/**
151+
* @description
152+
* Replace specific imports on a standalone component. This is useful for mocking dependencies of the component under test.
153+
*
154+
* @default
155+
* undefined
156+
*
157+
* @example
158+
* await render(AppComponent, {
159+
* importOverrides: [
160+
* { replace: RealChildComponent, with: MockChildComponent }
161+
* ]
162+
* })
163+
*/
164+
importOverrides?: ImportOverride[];
135165
}
136166

137167
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
@@ -194,6 +224,21 @@ export async function render<ComponentType, WrapperType = ComponentType>(
194224
providers: renderOptions.providers ?? [],
195225
});
196226

227+
if ('importOverrides' in renderOptions && (renderOptions as RenderComponentOptions).importOverrides?.length) {
228+
const sut = componentOrTemplate as Type<unknown>;
229+
if (typeof sut === 'function' && isStandalone(sut)) {
230+
const overrides = (renderOptions as RenderComponentOptions).importOverrides!;
231+
TestBed.overrideComponent(sut, {
232+
remove: { imports: overrides.map((o) => o.replace) },
233+
add: { imports: overrides.map((o) => o.with) },
234+
});
235+
} else {
236+
throw new Error(
237+
`Error while rendering: Cannot specify importOverrides on a template or non-standalone component.`,
238+
);
239+
}
240+
}
241+
197242
renderOptions.configureTestBed?.(TestBed);
198243
await TestBed.compileComponents();
199244

0 commit comments

Comments
 (0)