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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions projects/testing-library/src/lib/models.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
Expand Down Expand Up @@ -370,6 +370,7 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
* @description
* A collection of imports to override a standalone component's imports with.
*
* @deprecated use the `importOverrides` option instead.
* @default
* undefined
*
Expand All @@ -381,6 +382,24 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
* })
*/
componentImports?: (Type<unknown> | 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[];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the PR @rpd10
Do you think this should be preferred over componentImports, and could componentImports be deprecated?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks Tim - good question. To me, it feels like this matches TestBed's behavior for services that are providedIn: 'root'. If you do nothing, TestBed would inject the real service. You can choose to provide custom mocks/stubs for specific services as needed. If we were building this from the ground up, I would argue that this should be the default behavior.

That being said, I would worry about the migration path for existing consumers and whether it could be automated via ng update. I think the migration would be challenging.

So I think this becomes a question of whether you would want to expose both options and have users/agents reason about which one to use, versus potentially causing heartache during migration. Let me know what your thoughts are.

Copy link
Copy Markdown
Member

@timdeschryver timdeschryver May 18, 2026

Choose a reason for hiding this comment

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

We're currently also investigating recreating ATL, and a part of this is removing old/deprecated API's.
importOverrides could be added there as well. With that in mind, I don't want to add a migration for it (because it's not that easy, as you've mentioned).

If you want, feel free to also include this change to https://github.com/testing-library/angular-testing-library/blob/main/projects/testing-library/zoneless/src/public_api.ts

We can add a @deprecated to componentImports to warn users, and guide them to the new importOverrides.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks, added the deprecation notice and hooked this up in the zoneless approach. I didn't see any unit tests for the zoneless API, please let me know if I missed anything.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

/**
* @description
* Queries to bind. Overrides the default set from DOM Testing Library unless merged.
Expand Down Expand Up @@ -492,6 +511,13 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
deferBlockBehavior?: DeferBlockBehavior;
}

export interface ImportOverride {
/** The import to replace (matched by identity) */
replace: Type<unknown>;
/** The replacement import to use instead */
with: Type<unknown> | unknown[];
}

export interface ComponentOverride<T> {
component: Type<T>;
providers: Provider[];
Expand Down
30 changes: 27 additions & 3 deletions projects/testing-library/src/lib/testing-library.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
ApplicationInitStatus,
ApplicationRef,
Binding,
ChangeDetectorRef,
Component,
NgZone,
Expand All @@ -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';
Expand All @@ -32,11 +32,12 @@ import {
import { getConfig } from './config';
import {
ComponentOverride,
Config,
ImportOverride,
OutputRefKeysWithCallback,
RenderComponentOptions,
RenderResult,
RenderTemplateOptions,
Config,
} from './models';

type SubscribedOutput<T> = readonly [key: keyof T, callback: (v: any) => void, subscription: OutputRefSubscription];
Expand Down Expand Up @@ -75,6 +76,7 @@ export async function render<SutType, WrapperType = SutType>(
componentProviders = [],
childComponentOverrides = [],
componentImports,
importOverrides,
excludeComponentDeclaration = false,
routes = [],
removeAngularAttributes = false,
Expand All @@ -100,6 +102,12 @@ export async function render<SutType, WrapperType = SutType>(
...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,
Expand All @@ -115,6 +123,7 @@ export async function render<SutType, WrapperType = SutType>(
deferBlockBehavior: deferBlockBehavior ?? DeferBlockBehavior.Manual,
});
overrideComponentImports(sut, componentImports);
applyImportOverrides(sut, importOverrides);
overrideChildComponentProviders(childComponentOverrides);

configureTestBed(TestBed);
Expand Down Expand Up @@ -462,6 +471,21 @@ function overrideComponentImports<SutType>(sut: Type<SutType> | string, imports:
}
}

function applyImportOverrides<SutType>(sut: Type<SutType> | 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<any>[]) {
if (componentOverrides) {
for (const { component, providers } of componentOverrides) {
Expand Down
86 changes: 86 additions & 0 deletions projects/testing-library/src/tests/import-overrides.spec.ts
Original file line number Diff line number Diff line change
@@ -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: `<atl-child /><atl-other />`,
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();
});
75 changes: 75 additions & 0 deletions projects/testing-library/src/tests/zoneless.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,81 @@ test('renders and interacts with the component using a template', async () => {
await vi.waitFor(() => expect(valueControl).toHaveTextContent('20'));
});

@Component({
selector: 'atl-child',
template: `<span data-testid="child">Real Child</span>`,
})
class ChildComponent {}

@Component({
selector: 'atl-child',
template: `<span data-testid="child">Mock Child</span>`,
})
class MockChildComponent {}

@Component({
selector: 'atl-other',
template: `<span data-testid="other">Other</span>`,
})
class OtherComponent {}

@Component({
selector: 'atl-parent',
template: `<atl-child /><atl-other />`,
imports: [ChildComponent, OtherComponent],
})
class ParentComponent {}

@Component({
selector: 'atl-non-standalone',
template: `non-standalone`,
standalone: false,
})
class NonStandaloneComponent {}

test('replaces an import with importOverrides', async () => {
await render(ParentComponent, {
importOverrides: [{ replace: ChildComponent, with: MockChildComponent }],
});

expect(screen.getByTestId('child')).toHaveTextContent('Mock Child');
});

test('importOverrides leaves other imports intact', async () => {
await render(ParentComponent, {
importOverrides: [{ replace: ChildComponent, with: MockChildComponent }],
});

expect(screen.getByTestId('child')).toHaveTextContent('Mock Child');
expect(screen.getByTestId('other')).toHaveTextContent('Other');
});

test('importOverrides throws on non-standalone component', async () => {
await expect(
render(NonStandaloneComponent, {
importOverrides: [{ replace: ChildComponent, with: MockChildComponent }],
} as any),
).rejects.toThrow(/Cannot specify importOverrides on a template or non-standalone component/);
});

test('throws when importOverrides is used on a template', async () => {
await expect(
render(`<atl-parent />`, {
imports: [ParentComponent],
importOverrides: [{ replace: ChildComponent, with: MockChildComponent }],
} as any),
).rejects.toThrow(/Cannot specify importOverrides on a template or non-standalone component/);
});

test('importOverrides empty array is a no-op', async () => {
await render(ParentComponent, {
importOverrides: [],
});

expect(screen.getByTestId('child')).toHaveTextContent('Real Child');
expect(screen.getByTestId('other')).toHaveTextContent('Other');
});

test('can provide custom service providers', async () => {
const user = userEvent.setup();
await render(ServiceFixtureComponent, {
Expand Down
47 changes: 46 additions & 1 deletion projects/testing-library/zoneless/src/public_api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { Component, type Type, type Binding, type Provider, type EnvironmentProviders } from '@angular/core';
import {
Component,
isStandalone,
type Binding,
type EnvironmentProviders,
type Provider,
type Type,
} from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
getQueriesForElement,
Expand Down Expand Up @@ -110,6 +117,13 @@ export interface RenderOptions<Q extends Queries = typeof queries> {
providers?: (Provider | EnvironmentProviders)[];
}

export interface ImportOverride {
/** The import to replace (matched by identity) */
replace: Type<unknown>;
/** The replacement import to use instead */
with: Type<unknown> | unknown[];
}

export interface RenderComponentOptions<Q extends Queries = typeof queries> extends RenderOptions<Q> {
/**
* @description
Expand All @@ -132,6 +146,22 @@ export interface RenderComponentOptions<Q extends Queries = typeof queries> exte
* })
*/
bindings?: Binding[];

/**
* @description
* Replace specific imports on a standalone component. This is useful for mocking dependencies of the component under test.
*
* @default
* undefined
*
* @example
* await render(AppComponent, {
* importOverrides: [
* { replace: RealChildComponent, with: MockChildComponent }
* ]
* })
*/
importOverrides?: ImportOverride[];
}

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

if ('importOverrides' in renderOptions && (renderOptions as RenderComponentOptions).importOverrides?.length) {
const sut = componentOrTemplate as Type<unknown>;
if (typeof sut === 'function' && isStandalone(sut)) {
const overrides = (renderOptions as RenderComponentOptions).importOverrides!;
TestBed.overrideComponent(sut, {
remove: { imports: overrides.map((o) => o.replace) },
add: { imports: overrides.map((o) => o.with) },
});
} else {
throw new Error(
`Error while rendering: Cannot specify importOverrides on a template or non-standalone component.`,
);
}
}

renderOptions.configureTestBed?.(TestBed);
await TestBed.compileComponents();

Expand Down
Loading