Skip to content

Commit 401dec4

Browse files
AndrewKushnirdylhunn
authored andcommitted
feat(core): update TestBed to recognize Standalone Components (#45809)
This commit updates an internal logic of the TestBed to recognize Standalone Components to be able to apply the necessary overrides correctly. PR Close #45809
1 parent bb8d709 commit 401dec4

File tree

3 files changed

+288
-6
lines changed

3 files changed

+288
-6
lines changed

packages/core/test/test_bed_spec.ts

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,254 @@ describe('TestBed', () => {
168168
});
169169
});
170170

171+
describe('TestBed with Standalone types', () => {
172+
beforeEach(() => {
173+
getTestBed().resetTestingModule();
174+
});
175+
176+
it('should override providers on standalone component itself', () => {
177+
const A = new InjectionToken('A');
178+
179+
@Component({
180+
standalone: true,
181+
template: '{{ a }}',
182+
providers: [{provide: A, useValue: 'A'}],
183+
})
184+
class MyStandaloneComp {
185+
constructor(@Inject(A) public a: string) {}
186+
}
187+
188+
// NOTE: the `TestBed.configureTestingModule` is load-bearing here: it instructs
189+
// TestBed to examine and override providers in dependencies.
190+
TestBed.configureTestingModule({imports: [MyStandaloneComp]});
191+
TestBed.overrideProvider(A, {useValue: 'Overridden A'});
192+
193+
const fixture = TestBed.createComponent(MyStandaloneComp);
194+
fixture.detectChanges();
195+
196+
expect(fixture.nativeElement.innerHTML).toBe('Overridden A');
197+
});
198+
199+
it('should override providers in standalone component dependencies via overrideProvider', () => {
200+
const A = new InjectionToken('A');
201+
@NgModule({
202+
providers: [{provide: A, useValue: 'A'}],
203+
})
204+
class ComponentDependenciesModule {
205+
}
206+
207+
@Component({
208+
standalone: true,
209+
template: '{{ a }}',
210+
imports: [ComponentDependenciesModule],
211+
})
212+
class MyStandaloneComp {
213+
constructor(@Inject(A) public a: string) {}
214+
}
215+
216+
// NOTE: the `TestBed.configureTestingModule` is load-bearing here: it instructs
217+
// TestBed to examine and override providers in dependencies.
218+
TestBed.configureTestingModule({imports: [MyStandaloneComp]});
219+
TestBed.overrideProvider(A, {useValue: 'Overridden A'});
220+
221+
const fixture = TestBed.createComponent(MyStandaloneComp);
222+
fixture.detectChanges();
223+
224+
expect(fixture.nativeElement.innerHTML).toBe('Overridden A');
225+
});
226+
227+
it('should override providers in standalone component dependencies via overrideModule', () => {
228+
const A = new InjectionToken('A');
229+
@NgModule({
230+
providers: [{provide: A, useValue: 'A'}],
231+
})
232+
class ComponentDependenciesModule {
233+
}
234+
235+
@Component({
236+
standalone: true,
237+
template: '{{ a }}',
238+
imports: [ComponentDependenciesModule],
239+
})
240+
class MyStandaloneComp {
241+
constructor(@Inject(A) public a: string) {}
242+
}
243+
244+
// NOTE: the `TestBed.configureTestingModule` is *not* needed here, since the TestBed
245+
// knows which NgModule was overridden and needs re-compilation.
246+
TestBed.overrideModule(
247+
ComponentDependenciesModule, {set: {providers: [{provide: A, useValue: 'Overridden A'}]}});
248+
249+
const fixture = TestBed.createComponent(MyStandaloneComp);
250+
fixture.detectChanges();
251+
252+
expect(fixture.nativeElement.innerHTML).toBe('Overridden A');
253+
});
254+
255+
it('should allow overriding a template of a standalone component', () => {
256+
@Component({
257+
standalone: true,
258+
template: 'Original',
259+
})
260+
class MyStandaloneComp {
261+
}
262+
263+
// NOTE: the `TestBed.configureTestingModule` call is *not* required here, since TestBed already
264+
// knows that the `MyStandaloneComp` should be overridden/recompiled.
265+
TestBed.overrideComponent(MyStandaloneComp, {set: {template: 'Overridden'}});
266+
267+
const fixture = TestBed.createComponent(MyStandaloneComp);
268+
fixture.detectChanges();
269+
270+
expect(fixture.nativeElement.innerHTML).toBe('Overridden');
271+
});
272+
273+
it('should allow overriding the set of directives and pipes used in a standalone component',
274+
() => {
275+
@Directive({
276+
selector: '[dir]',
277+
standalone: true,
278+
host: {'[id]': 'id'},
279+
})
280+
class MyStandaloneDirectiveA {
281+
id = 'A';
282+
}
283+
284+
@Directive({
285+
selector: '[dir]',
286+
standalone: true,
287+
host: {'[id]': 'id'},
288+
})
289+
class MyStandaloneDirectiveB {
290+
id = 'B';
291+
}
292+
293+
@Pipe({name: 'pipe', standalone: true})
294+
class MyStandalonePipeA {
295+
transform(value: string): string {
296+
return `transformed ${value} (A)`;
297+
}
298+
}
299+
@Pipe({name: 'pipe', standalone: true})
300+
class MyStandalonePipeB {
301+
transform(value: string): string {
302+
return `transformed ${value} (B)`;
303+
}
304+
}
305+
306+
@Component({
307+
standalone: true,
308+
template: '<div dir>{{ name | pipe }}</div>',
309+
imports: [MyStandalonePipeA, MyStandaloneDirectiveA],
310+
})
311+
class MyStandaloneComp {
312+
name = 'MyStandaloneComp';
313+
}
314+
315+
// NOTE: the `TestBed.configureTestingModule` call is *not* required here, since TestBed
316+
// already knows that the `MyStandaloneComp` should be overridden/recompiled.
317+
TestBed.overrideComponent(
318+
MyStandaloneComp, {set: {imports: [MyStandalonePipeB, MyStandaloneDirectiveB]}});
319+
320+
const fixture = TestBed.createComponent(MyStandaloneComp);
321+
fixture.detectChanges();
322+
323+
const rootElement = fixture.nativeElement.firstChild;
324+
expect(rootElement.id).toBe('B');
325+
expect(rootElement.innerHTML).toBe('transformed MyStandaloneComp (B)');
326+
});
327+
328+
it('should reflect overrides on imported standalone directive', () => {
329+
@Directive({
330+
selector: '[dir]',
331+
standalone: true,
332+
host: {'[id]': 'id'},
333+
})
334+
class DepStandaloneDirective {
335+
id = 'A';
336+
}
337+
338+
@Component({
339+
selector: 'standalone-cmp',
340+
standalone: true,
341+
template: 'Original MyStandaloneComponent',
342+
})
343+
class DepStandaloneComponent {
344+
id = 'A';
345+
}
346+
347+
@Component({
348+
standalone: true,
349+
template: '<standalone-cmp dir>Hello world!</standalone-cmp>',
350+
imports: [DepStandaloneDirective, DepStandaloneComponent],
351+
})
352+
class RootStandaloneComp {
353+
}
354+
355+
// NOTE: the `TestBed.configureTestingModule` call is *not* required here, since TestBed
356+
// already knows which Components/Directives are overridden and should be recompiled.
357+
TestBed.overrideComponent(
358+
DepStandaloneComponent, {set: {template: 'Overridden MyStandaloneComponent'}});
359+
TestBed.overrideDirective(DepStandaloneDirective, {set: {host: {'[id]': '\'Overridden\''}}});
360+
361+
const fixture = TestBed.createComponent(RootStandaloneComp);
362+
fixture.detectChanges();
363+
364+
const rootElement = fixture.nativeElement.firstChild;
365+
366+
expect(rootElement.id).toBe('Overridden');
367+
expect(rootElement.innerHTML).toBe('Overridden MyStandaloneComponent');
368+
});
369+
370+
describe('NgModules as dependencies', () => {
371+
@Component({
372+
selector: 'test-cmp',
373+
template: '...',
374+
})
375+
class TestComponent {
376+
testField = 'default';
377+
}
378+
379+
@Component({
380+
selector: 'test-cmp',
381+
template: '...',
382+
})
383+
class MockTestComponent {
384+
testField = 'overridden';
385+
}
386+
387+
@NgModule({
388+
declarations: [TestComponent],
389+
exports: [TestComponent],
390+
})
391+
class TestModule {
392+
}
393+
394+
@Component({
395+
standalone: true,
396+
selector: 'app-root',
397+
template: `<test-cmp #testCmpCtrl></test-cmp>`,
398+
imports: [TestModule],
399+
})
400+
class AppComponent {
401+
@ViewChild('testCmpCtrl', {static: true}) testCmpCtrl!: TestComponent;
402+
}
403+
404+
it('should allow declarations and exports overrides on an imported NgModule', () => {
405+
// replace TestComponent with MockTestComponent
406+
TestBed.overrideModule(TestModule, {
407+
remove: {declarations: [TestComponent], exports: [TestComponent]},
408+
add: {declarations: [MockTestComponent], exports: [MockTestComponent]}
409+
});
410+
const fixture = TestBed.createComponent(AppComponent);
411+
fixture.detectChanges();
412+
413+
const app = fixture.componentInstance;
414+
expect(app.testCmpCtrl.testField).toBe('overridden');
415+
});
416+
});
417+
});
418+
171419
describe('TestBed', () => {
172420
beforeEach(() => {
173421
getTestBed().resetTestingModule();

packages/core/testing/src/r3_test_bed.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -418,8 +418,7 @@ export class TestBedRender3 implements TestBed {
418418
const componentDef = (type as any).ɵcmp;
419419

420420
if (!componentDef) {
421-
throw new Error(
422-
`It looks like '${stringify(type)}' has not been IVY compiled - it has no 'ɵcmp' field`);
421+
throw new Error(`It looks like '${stringify(type)}' has not been compiled.`);
423422
}
424423

425424
// TODO: Don't cast as `InjectionToken<boolean>`, proper type is boolean[]

packages/core/testing/src/r3_test_bed_compiler.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {ResourceLoader} from '@angular/compiler';
1010
import {ApplicationInitStatus, Compiler, COMPILER_OPTIONS, Component, Directive, Injector, InjectorType, LOCALE_ID, ModuleWithComponentFactories, ModuleWithProviders, NgModule, NgModuleFactory, NgZone, Pipe, PlatformRef, Provider, resolveForwardRef, Type, ɵcompileComponent as compileComponent, ɵcompileDirective as compileDirective, ɵcompileNgModuleDefs as compileNgModuleDefs, ɵcompilePipe as compilePipe, ɵDEFAULT_LOCALE_ID as DEFAULT_LOCALE_ID, ɵDirectiveDef as DirectiveDef, ɵgetInjectableDef as getInjectableDef, ɵNG_COMP_DEF as NG_COMP_DEF, ɵNG_DIR_DEF as NG_DIR_DEF, ɵNG_INJ_DEF as NG_INJ_DEF, ɵNG_MOD_DEF as NG_MOD_DEF, ɵNG_PIPE_DEF as NG_PIPE_DEF, ɵNgModuleFactory as R3NgModuleFactory, ɵNgModuleTransitiveScopes as NgModuleTransitiveScopes, ɵNgModuleType as NgModuleType, ɵpatchComponentDefWithScope as patchComponentDefWithScope, ɵRender3ComponentFactory as ComponentFactory, ɵRender3NgModuleRef as NgModuleRef, ɵsetLocaleId as setLocaleId, ɵtransitiveScopesFor as transitiveScopesFor, ɵɵInjectableDeclaration as InjectableDeclaration} from '@angular/core';
1111

1212
import {clearResolutionOfComponentResourcesQueue, isComponentDefPendingResolution, resolveComponentResources, restoreComponentResolutionQueue} from '../../src/metadata/resource_loading';
13+
import {ComponentDef, ComponentType} from '../../src/render3';
1314

1415
import {MetadataOverride} from './metadata_override';
1516
import {ComponentResolver, DirectiveResolver, NgModuleResolver, PipeResolver, Resolver} from './resolvers';
@@ -424,8 +425,24 @@ export class R3TestBedCompiler {
424425
}
425426
this.moduleProvidersOverridden.add(moduleType);
426427

428+
// NOTE: the line below triggers JIT compilation of the module injector,
429+
// which also invokes verification of the NgModule semantics, which produces
430+
// detailed error messages. The fact that the code relies on this line being
431+
// present here is suspicious and should be refactored in a way that the line
432+
// below can be moved (for ex. after an early exit check below).
427433
const injectorDef: any = (moduleType as any)[NG_INJ_DEF];
428-
if (this.providerOverridesByToken.size > 0) {
434+
435+
// No provider overrides, exit early.
436+
if (this.providerOverridesByToken.size === 0) return;
437+
438+
if (isStandaloneComponent(moduleType)) {
439+
// Visit all component dependencies and override providers there.
440+
const def = getComponentDef(moduleType);
441+
const dependencies = maybeUnwrapFn(def.dependencies ?? []);
442+
for (const dependency of dependencies) {
443+
this.applyProviderOverridesToModule(dependency);
444+
}
445+
} else {
429446
const providers = [
430447
...injectorDef.providers,
431448
...(this.providerOverridesByModule.get(moduleType as InjectorType<any>) || [])
@@ -482,7 +499,7 @@ export class R3TestBedCompiler {
482499
compileNgModuleDefs(ngModule as NgModuleType<any>, metadata);
483500
}
484501

485-
private queueType(type: Type<any>, moduleType: Type<any>|TestingModuleOverride): void {
502+
private queueType(type: Type<any>, moduleType: Type<any>|TestingModuleOverride|null): void {
486503
const component = this.resolvers.component.resolve(type);
487504
if (component) {
488505
// Check whether a give Type has respective NG def (ɵcmp) and compile if def is
@@ -508,8 +525,11 @@ export class R3TestBedCompiler {
508525
// real module, which was imported. This pattern is understood to mean that the component
509526
// should use its original scope, but that the testing module should also contain the
510527
// component in its scope.
511-
if (!this.componentToModuleScope.has(type) ||
512-
this.componentToModuleScope.get(type) === TestingModuleOverride.DECLARATION) {
528+
//
529+
// Note: standalone components have no associated NgModule, so the `moduleType` can be `null`.
530+
if (moduleType !== null &&
531+
(!this.componentToModuleScope.has(type) ||
532+
this.componentToModuleScope.get(type) === TestingModuleOverride.DECLARATION)) {
513533
this.componentToModuleScope.set(type, moduleType);
514534
}
515535
return;
@@ -553,6 +573,10 @@ export class R3TestBedCompiler {
553573
queueTypesFromModulesArrayRecur(maybeUnwrapFn(def.exports));
554574
} else if (isModuleWithProviders(value)) {
555575
queueTypesFromModulesArrayRecur([value.ngModule]);
576+
} else if (isStandaloneComponent(value)) {
577+
this.queueType(value, null);
578+
const def = getComponentDef(value);
579+
queueTypesFromModulesArrayRecur(maybeUnwrapFn(def.dependencies ?? []));
556580
}
557581
}
558582
};
@@ -790,6 +814,17 @@ function initResolvers(): Resolvers {
790814
};
791815
}
792816

817+
function isStandaloneComponent<T>(value: Type<T>): value is ComponentType<T> {
818+
const def = getComponentDef(value);
819+
return !!def?.standalone;
820+
}
821+
822+
function getComponentDef(value: ComponentType<unknown>): ComponentDef<unknown>;
823+
function getComponentDef(value: Type<unknown>): ComponentDef<unknown>|null;
824+
function getComponentDef(value: Type<unknown>): ComponentDef<unknown>|null {
825+
return (value as any).ɵcmp ?? null;
826+
}
827+
793828
function hasNgModuleDef<T>(value: Type<T>): value is NgModuleType<T> {
794829
return value.hasOwnProperty('ɵmod');
795830
}

0 commit comments

Comments
 (0)