Skip to content

Commit

Permalink
WIP: Reactive inputs in TestBed.createComponent
Browse files Browse the repository at this point in the history
  • Loading branch information
atscott committed Sep 23, 2024
1 parent 8e01734 commit 6d7816d
Show file tree
Hide file tree
Showing 6 changed files with 191 additions and 8 deletions.
2 changes: 2 additions & 0 deletions adev/src/content/guide/testing/utility-apis.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ These are rarely needed.
## The `ComponentFixture`

The `TestBed.createComponent<T>` creates an instance of the component `T` and returns a strongly typed `ComponentFixture` for that component.
You can also pass inputs to the `createComponent` method to ensure initial input values are set up correctly, for example
`TestBed.createComponent(ExampleComponent, {inputs: {'name': 'Angular'}})`.

The `ComponentFixture` properties and methods provide access to the component, its DOM representation, and aspects of its Angular environment.

Expand Down
11 changes: 9 additions & 2 deletions goldens/public-api/core/testing/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export class ComponentFixture<T> {
whenStable(): Promise<any>;
}

// @public (undocumented)
// @public
export const ComponentFixtureAutoDetect: InjectionToken<boolean>;

// @public (undocumented)
Expand Down Expand Up @@ -121,7 +121,7 @@ export interface TestBed {
// (undocumented)
configureTestingModule(moduleDef: TestModuleMetadata): TestBed;
// (undocumented)
createComponent<T>(component: Type<T>): ComponentFixture<T>;
createComponent<T>(component: Type<T>, options?: TestBedCreateComponentOptions): ComponentFixture<T>;
// (undocumented)
execute(tokens: any[], fn: Function, context?: any): any;
flushEffects(): void;
Expand Down Expand Up @@ -184,6 +184,13 @@ export interface TestBed {
// @public
export const TestBed: TestBedStatic;

// @public
export interface TestBedCreateComponentOptions {
inputs?: {
[templateName: string]: unknown;
};
}

// @public
export interface TestBedStatic extends TestBed {
// (undocumented)
Expand Down
92 changes: 91 additions & 1 deletion packages/core/test/test_bed_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,26 @@ import {
ɵɵelementStart as elementStart,
ɵɵsetNgModuleScope as setNgModuleScope,
ɵɵtext as text,
signal,
computed,
provideExperimentalZonelessChangeDetection,
provideZoneChangeDetection,
ChangeDetectionStrategy,
} from '@angular/core';
import {DeferBlockBehavior} from '@angular/core/testing';
import {TestBed, TestBedImpl} from '@angular/core/testing/src/test_bed';
import {
TestBed,
TestBedCreateComponentInputs,
TestBedImpl,
} from '@angular/core/testing/src/test_bed';
import {By} from '@angular/platform-browser';
import {expect} from '@angular/platform-browser/testing/src/matchers';

import {NgModuleType} from '../src/render3';
import {depsTracker} from '../src/render3/deps_tracker/deps_tracker';
import {setClassMetadataAsync} from '../src/render3/metadata';
import {
ComponentFixtureAutoDetect,
TEARDOWN_TESTING_MODULE_ON_DESTROY_DEFAULT,
THROW_ON_UNKNOWN_ELEMENTS_DEFAULT,
THROW_ON_UNKNOWN_PROPERTIES_DEFAULT,
Expand Down Expand Up @@ -723,6 +733,86 @@ describe('TestBed', () => {
expect(divElement.properties['title']).toEqual('some title');
});

describe('inputs', () => {
beforeEach(() => {
TestBed.configureTestingModule({providers: [provideExperimentalZonelessChangeDetection()]});
});

@Component({
template: '{{a}}',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class App {
@Input({required: true}) a!: string;

ngOnInit() {
if (!this.a) {
throw new Error('a not initialized');
}
}
}

it('should assign initial values for inputs', async () => {
const inputs: TestBedCreateComponentInputs<App> = {a: signal('1')};
const fixture = TestBed.createComponent(App, {inputs});
await fixture.whenStable();
expect(fixture.nativeElement.innerHTML).toEqual('1');
});

it('should react to changing the value of an input', async () => {
const inputs = {a: signal('1')};
const fixture = TestBed.createComponent(App, {inputs});
await fixture.whenStable();
expect(fixture.nativeElement.innerHTML).toEqual('1');

inputs.a.set('2');
await fixture.whenStable();
expect(fixture.nativeElement.innerHTML).toEqual('2');
});

it('reacts to changing the value of an input with detectChanges', async () => {
const inputs = {a: signal('1')};
const fixture = TestBed.createComponent(App, {inputs});
await fixture.whenStable();
expect(fixture.nativeElement.innerHTML).toEqual('1');

inputs.a.set('2');
fixture.detectChanges();
expect(fixture.nativeElement.innerHTML).toEqual('2');
});

it('works for inputs with aliases', async () => {
@Component({
template: '{{a}}',
})
class AppWithAlias {
@Input({alias: 'anAlias'}) a!: string;
}
const inputs = {a: signal('1')};
const fixture = TestBed.createComponent(AppWithAlias, {inputs});
await fixture.whenStable();
expect(fixture.nativeElement.innerHTML).toEqual('1');

inputs.a.set('2');
fixture.detectChanges();
expect(fixture.nativeElement.innerHTML).toEqual('2');
});

it('should assign inputs before change detection with ZoneJS and ComponentFixutureAutoDetect', async () => {
TestBed.configureTestingModule({
providers: [
provideZoneChangeDetection(),
{provide: ComponentFixtureAutoDetect, useValue: true},
],
});
const inputs = {a: signal('1')};
const fixture = TestBed.createComponent(App, {inputs});
await expectAsync(fixture.whenStable()).not.toBeRejected();
expect(fixture.nativeElement.innerHTML).toEqual('1');
});
});

it('should give the ability to access interpolated properties on a node', () => {
const fixture = TestBed.createComponent(ComponentWithPropBindings);
fixture.detectChanges();
Expand Down
84 changes: 79 additions & 5 deletions packages/core/testing/src/test_bed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ import {
ɵsetUnknownElementStrictMode as setUnknownElementStrictMode,
ɵsetUnknownPropertyStrictMode as setUnknownPropertyStrictMode,
ɵstringify as stringify,
Signal,
effect,
reflectComponentType,
InputSignalWithTransform,
} from '@angular/core';

import {ComponentFixture} from './component_fixture';
Expand All @@ -65,6 +69,43 @@ export interface TestBedStatic extends TestBed {
new (...args: any[]): TestBed;
}

/**
* Additional options for configuring the component fixture on creation.
*
* @publicApi
* @see TestBed#createComponent
*/
export interface TestBedCreateComponentOptions<T> {
/**
* The initial inputs for the component.
*
* These key/value pairs will be used with `ComponentRef#setInput` after the
* test component is created and before the fixture is returned from `createComponent`.
*
* Specifying inputs at creation is useful in conjunction with `ComponentFixtureAutoDetect`
* to ensure that the inputs are set before change detection runs on the component.
*
* @see ComponentRef#setInput
* @see ComponentFixture#componentRef
*/
inputs?: TestBedCreateComponentInputs<T>;
}

/**
* Represents possible input values for a component.
*
* This is a combination of the publicly available type on the component along with
* an allowance for any additional property keys for defining inputs that are
* protected.
*
* @publicApi
*/
export type TestBedCreateComponentInputs<T> = {
[K in keyof T]?: T[K] extends InputSignalWithTransform<any, infer WriteT>
? Signal<WriteT>
: Signal<T[K]>;
} & Record<PropertyKey, Signal<unknown>>;

/**
* @publicApi
*/
Expand Down Expand Up @@ -160,7 +201,10 @@ export interface TestBed {

overrideTemplateUsingTestingModule(component: Type<any>, template: string): TestBed;

createComponent<T>(component: Type<T>): ComponentFixture<T>;
createComponent<T>(
component: Type<T>,
options?: TestBedCreateComponentOptions<T>,
): ComponentFixture<T>;

/**
* Execute any pending effects.
Expand Down Expand Up @@ -398,8 +442,11 @@ export class TestBedImpl implements TestBed {
return TestBedImpl.INSTANCE.runInInjectionContext(fn);
}

static createComponent<T>(component: Type<T>): ComponentFixture<T> {
return TestBedImpl.INSTANCE.createComponent(component);
static createComponent<T>(
component: Type<T>,
options?: TestBedCreateComponentOptions<T>,
): ComponentFixture<T> {
return TestBedImpl.INSTANCE.createComponent(component, options);
}

static resetTestingModule(): TestBed {
Expand Down Expand Up @@ -668,7 +715,10 @@ export class TestBedImpl implements TestBed {
return this.overrideComponent(component, {set: {template, templateUrl: null!}});
}

createComponent<T>(type: Type<T>): ComponentFixture<T> {
createComponent<T>(
type: Type<T>,
{inputs = {}}: TestBedCreateComponentOptions<T> = {},
): ComponentFixture<T> {
const testComponentRenderer = this.inject(TestComponentRenderer);
const rootElId = `root${_nextRootElementId++}`;
testComponentRenderer.insertRootElement(rootElId);
Expand All @@ -694,7 +744,31 @@ export class TestBedImpl implements TestBed {
`#${rootElId}`,
this.testModuleRef,
) as ComponentRef<T>;
return this.runInInjectionContext(() => new ComponentFixture(componentRef));
const fixture = this.runInInjectionContext(() => new ComponentFixture(componentRef));

const mirror = reflectComponentType(type);
const inputPropertyNames = Object.keys(inputs);
if (mirror && inputPropertyNames.length > 0) {
const propNameToTemplateNameMap = mirror.inputs.reduce(
(map, input) => map.set(input.propName, input.templateName),
new Map<string, string>(),
);
this.runInInjectionContext(() => {
effect(() => {
for (const propertyName of inputPropertyNames) {
const templateName = propNameToTemplateNameMap.get(propertyName);
if (!templateName) {
throw new Error(
`No input with property name '${propertyName}' was not found on the component.`,
);
}
fixture.componentRef.setInput(templateName, inputs[propertyName]());
}
});
});
}

return fixture;
};
const noNgZone = this.inject(ComponentFixtureNoNgZone, false);
const ngZone = noNgZone ? null : this.inject(NgZone, null);
Expand Down
8 changes: 8 additions & 0 deletions packages/core/testing/src/test_bed_common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ export class TestComponentRenderer {

/**
* @publicApi
*
* When true, ensures components created with `TestBed#createComponent` are attached to the application.
* This more closely matches production behavior for the vast majority of components in Angular.
* When using `ComponentFixtureAutoDetect`, consider also defining the initial inputs in the
* options of `TestBed#createComponent` to ensure they are set prior to the first change detection
* of the component.
*
* @see TestBedCreateComponentOptions
*/
export const ComponentFixtureAutoDetect = new InjectionToken<boolean>('ComponentFixtureAutoDetect');

Expand Down
2 changes: 2 additions & 0 deletions packages/core/testing/src/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export {
inject,
InjectSetupWrapper,
withModule,
TestBedCreateComponentOptions,
TestBedCreateComponentInputs,
} from './test_bed';
export {
TestComponentRenderer,
Expand Down

0 comments on commit 6d7816d

Please sign in to comment.