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
This commit is contained in:
Andrew Kushnir 2022-04-27 21:47:59 -07:00 committed by Dylan Hunn
parent bb8d7091c6
commit 401dec46eb
3 changed files with 288 additions and 6 deletions

View file

@ -168,6 +168,254 @@ describe('TestBed', () => {
});
});
describe('TestBed with Standalone types', () => {
beforeEach(() => {
getTestBed().resetTestingModule();
});
it('should override providers on standalone component itself', () => {
const A = new InjectionToken('A');
@Component({
standalone: true,
template: '{{ a }}',
providers: [{provide: A, useValue: 'A'}],
})
class MyStandaloneComp {
constructor(@Inject(A) public a: string) {}
}
// NOTE: the `TestBed.configureTestingModule` is load-bearing here: it instructs
// TestBed to examine and override providers in dependencies.
TestBed.configureTestingModule({imports: [MyStandaloneComp]});
TestBed.overrideProvider(A, {useValue: 'Overridden A'});
const fixture = TestBed.createComponent(MyStandaloneComp);
fixture.detectChanges();
expect(fixture.nativeElement.innerHTML).toBe('Overridden A');
});
it('should override providers in standalone component dependencies via overrideProvider', () => {
const A = new InjectionToken('A');
@NgModule({
providers: [{provide: A, useValue: 'A'}],
})
class ComponentDependenciesModule {
}
@Component({
standalone: true,
template: '{{ a }}',
imports: [ComponentDependenciesModule],
})
class MyStandaloneComp {
constructor(@Inject(A) public a: string) {}
}
// NOTE: the `TestBed.configureTestingModule` is load-bearing here: it instructs
// TestBed to examine and override providers in dependencies.
TestBed.configureTestingModule({imports: [MyStandaloneComp]});
TestBed.overrideProvider(A, {useValue: 'Overridden A'});
const fixture = TestBed.createComponent(MyStandaloneComp);
fixture.detectChanges();
expect(fixture.nativeElement.innerHTML).toBe('Overridden A');
});
it('should override providers in standalone component dependencies via overrideModule', () => {
const A = new InjectionToken('A');
@NgModule({
providers: [{provide: A, useValue: 'A'}],
})
class ComponentDependenciesModule {
}
@Component({
standalone: true,
template: '{{ a }}',
imports: [ComponentDependenciesModule],
})
class MyStandaloneComp {
constructor(@Inject(A) public a: string) {}
}
// NOTE: the `TestBed.configureTestingModule` is *not* needed here, since the TestBed
// knows which NgModule was overridden and needs re-compilation.
TestBed.overrideModule(
ComponentDependenciesModule, {set: {providers: [{provide: A, useValue: 'Overridden A'}]}});
const fixture = TestBed.createComponent(MyStandaloneComp);
fixture.detectChanges();
expect(fixture.nativeElement.innerHTML).toBe('Overridden A');
});
it('should allow overriding a template of a standalone component', () => {
@Component({
standalone: true,
template: 'Original',
})
class MyStandaloneComp {
}
// NOTE: the `TestBed.configureTestingModule` call is *not* required here, since TestBed already
// knows that the `MyStandaloneComp` should be overridden/recompiled.
TestBed.overrideComponent(MyStandaloneComp, {set: {template: 'Overridden'}});
const fixture = TestBed.createComponent(MyStandaloneComp);
fixture.detectChanges();
expect(fixture.nativeElement.innerHTML).toBe('Overridden');
});
it('should allow overriding the set of directives and pipes used in a standalone component',
() => {
@Directive({
selector: '[dir]',
standalone: true,
host: {'[id]': 'id'},
})
class MyStandaloneDirectiveA {
id = 'A';
}
@Directive({
selector: '[dir]',
standalone: true,
host: {'[id]': 'id'},
})
class MyStandaloneDirectiveB {
id = 'B';
}
@Pipe({name: 'pipe', standalone: true})
class MyStandalonePipeA {
transform(value: string): string {
return `transformed ${value} (A)`;
}
}
@Pipe({name: 'pipe', standalone: true})
class MyStandalonePipeB {
transform(value: string): string {
return `transformed ${value} (B)`;
}
}
@Component({
standalone: true,
template: '<div dir>{{ name | pipe }}</div>',
imports: [MyStandalonePipeA, MyStandaloneDirectiveA],
})
class MyStandaloneComp {
name = 'MyStandaloneComp';
}
// NOTE: the `TestBed.configureTestingModule` call is *not* required here, since TestBed
// already knows that the `MyStandaloneComp` should be overridden/recompiled.
TestBed.overrideComponent(
MyStandaloneComp, {set: {imports: [MyStandalonePipeB, MyStandaloneDirectiveB]}});
const fixture = TestBed.createComponent(MyStandaloneComp);
fixture.detectChanges();
const rootElement = fixture.nativeElement.firstChild;
expect(rootElement.id).toBe('B');
expect(rootElement.innerHTML).toBe('transformed MyStandaloneComp (B)');
});
it('should reflect overrides on imported standalone directive', () => {
@Directive({
selector: '[dir]',
standalone: true,
host: {'[id]': 'id'},
})
class DepStandaloneDirective {
id = 'A';
}
@Component({
selector: 'standalone-cmp',
standalone: true,
template: 'Original MyStandaloneComponent',
})
class DepStandaloneComponent {
id = 'A';
}
@Component({
standalone: true,
template: '<standalone-cmp dir>Hello world!</standalone-cmp>',
imports: [DepStandaloneDirective, DepStandaloneComponent],
})
class RootStandaloneComp {
}
// NOTE: the `TestBed.configureTestingModule` call is *not* required here, since TestBed
// already knows which Components/Directives are overridden and should be recompiled.
TestBed.overrideComponent(
DepStandaloneComponent, {set: {template: 'Overridden MyStandaloneComponent'}});
TestBed.overrideDirective(DepStandaloneDirective, {set: {host: {'[id]': '\'Overridden\''}}});
const fixture = TestBed.createComponent(RootStandaloneComp);
fixture.detectChanges();
const rootElement = fixture.nativeElement.firstChild;
expect(rootElement.id).toBe('Overridden');
expect(rootElement.innerHTML).toBe('Overridden MyStandaloneComponent');
});
describe('NgModules as dependencies', () => {
@Component({
selector: 'test-cmp',
template: '...',
})
class TestComponent {
testField = 'default';
}
@Component({
selector: 'test-cmp',
template: '...',
})
class MockTestComponent {
testField = 'overridden';
}
@NgModule({
declarations: [TestComponent],
exports: [TestComponent],
})
class TestModule {
}
@Component({
standalone: true,
selector: 'app-root',
template: `<test-cmp #testCmpCtrl></test-cmp>`,
imports: [TestModule],
})
class AppComponent {
@ViewChild('testCmpCtrl', {static: true}) testCmpCtrl!: TestComponent;
}
it('should allow declarations and exports overrides on an imported NgModule', () => {
// replace TestComponent with MockTestComponent
TestBed.overrideModule(TestModule, {
remove: {declarations: [TestComponent], exports: [TestComponent]},
add: {declarations: [MockTestComponent], exports: [MockTestComponent]}
});
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const app = fixture.componentInstance;
expect(app.testCmpCtrl.testField).toBe('overridden');
});
});
});
describe('TestBed', () => {
beforeEach(() => {
getTestBed().resetTestingModule();

View file

@ -418,8 +418,7 @@ export class TestBedRender3 implements TestBed {
const componentDef = (type as any).ɵcmp;
if (!componentDef) {
throw new Error(
`It looks like '${stringify(type)}' has not been IVY compiled - it has no 'ɵcmp' field`);
throw new Error(`It looks like '${stringify(type)}' has not been compiled.`);
}
// TODO: Don't cast as `InjectionToken<boolean>`, proper type is boolean[]

View file

@ -10,6 +10,7 @@ import {ResourceLoader} from '@angular/compiler';
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';
import {clearResolutionOfComponentResourcesQueue, isComponentDefPendingResolution, resolveComponentResources, restoreComponentResolutionQueue} from '../../src/metadata/resource_loading';
import {ComponentDef, ComponentType} from '../../src/render3';
import {MetadataOverride} from './metadata_override';
import {ComponentResolver, DirectiveResolver, NgModuleResolver, PipeResolver, Resolver} from './resolvers';
@ -424,8 +425,24 @@ export class R3TestBedCompiler {
}
this.moduleProvidersOverridden.add(moduleType);
// NOTE: the line below triggers JIT compilation of the module injector,
// which also invokes verification of the NgModule semantics, which produces
// detailed error messages. The fact that the code relies on this line being
// present here is suspicious and should be refactored in a way that the line
// below can be moved (for ex. after an early exit check below).
const injectorDef: any = (moduleType as any)[NG_INJ_DEF];
if (this.providerOverridesByToken.size > 0) {
// No provider overrides, exit early.
if (this.providerOverridesByToken.size === 0) return;
if (isStandaloneComponent(moduleType)) {
// Visit all component dependencies and override providers there.
const def = getComponentDef(moduleType);
const dependencies = maybeUnwrapFn(def.dependencies ?? []);
for (const dependency of dependencies) {
this.applyProviderOverridesToModule(dependency);
}
} else {
const providers = [
...injectorDef.providers,
...(this.providerOverridesByModule.get(moduleType as InjectorType<any>) || [])
@ -482,7 +499,7 @@ export class R3TestBedCompiler {
compileNgModuleDefs(ngModule as NgModuleType<any>, metadata);
}
private queueType(type: Type<any>, moduleType: Type<any>|TestingModuleOverride): void {
private queueType(type: Type<any>, moduleType: Type<any>|TestingModuleOverride|null): void {
const component = this.resolvers.component.resolve(type);
if (component) {
// Check whether a give Type has respective NG def (ɵcmp) and compile if def is
@ -508,8 +525,11 @@ export class R3TestBedCompiler {
// real module, which was imported. This pattern is understood to mean that the component
// should use its original scope, but that the testing module should also contain the
// component in its scope.
if (!this.componentToModuleScope.has(type) ||
this.componentToModuleScope.get(type) === TestingModuleOverride.DECLARATION) {
//
// Note: standalone components have no associated NgModule, so the `moduleType` can be `null`.
if (moduleType !== null &&
(!this.componentToModuleScope.has(type) ||
this.componentToModuleScope.get(type) === TestingModuleOverride.DECLARATION)) {
this.componentToModuleScope.set(type, moduleType);
}
return;
@ -553,6 +573,10 @@ export class R3TestBedCompiler {
queueTypesFromModulesArrayRecur(maybeUnwrapFn(def.exports));
} else if (isModuleWithProviders(value)) {
queueTypesFromModulesArrayRecur([value.ngModule]);
} else if (isStandaloneComponent(value)) {
this.queueType(value, null);
const def = getComponentDef(value);
queueTypesFromModulesArrayRecur(maybeUnwrapFn(def.dependencies ?? []));
}
}
};
@ -790,6 +814,17 @@ function initResolvers(): Resolvers {
};
}
function isStandaloneComponent<T>(value: Type<T>): value is ComponentType<T> {
const def = getComponentDef(value);
return !!def?.standalone;
}
function getComponentDef(value: ComponentType<unknown>): ComponentDef<unknown>;
function getComponentDef(value: Type<unknown>): ComponentDef<unknown>|null;
function getComponentDef(value: Type<unknown>): ComponentDef<unknown>|null {
return (value as any).ɵcmp ?? null;
}
function hasNgModuleDef<T>(value: Type<T>): value is NgModuleType<T> {
return value.hasOwnProperty('ɵmod');
}