Skip to content
Open
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
37 changes: 31 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 @@ -381,6 +381,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
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.

/**
* @description
* Queries to bind. Overrides the default set from DOM Testing Library unless merged.
Expand Down Expand Up @@ -492,6 +510,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();
});
Loading