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[];
/**
* @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