Skip to content
Merged
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
29 changes: 28 additions & 1 deletion docs/src/content/docs/testing-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,29 @@ it('should...', () => {
});
```

`props` keys may be either the class property name or the input's public name, so
an input that declares an alias can be set either way:

```ts
@Component({ /* ... */ })
class UserComponent {
@Input('userName') name = '';
age = input.required<number>({ alias: 'userAge' });
}

it('should...', () => {
// by class property name — type-checked and autocompleted
spectator = createComponent({ props: { name: 'John', age: 30 } });

// ...or by public name
spectator = createComponent({ props: { userName: 'John', userAge: 30 } });
});
```

Because an alias cannot be derived from the type system, `props` accepts unknown
string keys. A key that matches no input is reported by Angular at runtime
(`NG0303`) rather than at compile time.

By providing `overrideComponents` options in scope of our `createComponent()` function we can define the way of overriding standalone component and it's dependencies
```ts
@Component({
Expand Down Expand Up @@ -196,7 +219,8 @@ spectator.detectChanges();
```

### `setInput()`
Changes the value of an `@Input()` of the tested component:
Changes the value of an `@Input()` of the tested component. As with `props`, an
input may be addressed by its class property name or by its public name:

```ts
it('should...', () => {
Expand All @@ -205,6 +229,9 @@ it('should...', () => {
spectator.setInput({
className: 'danger'
});

// an input declared as `@Input('cssClass') className` answers to both
spectator.setInput('cssClass', 'danger');
});
```
### `output()`
Expand Down
227 changes: 227 additions & 0 deletions projects/spectator/jest/test/props-alias-names.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { Component, Input, input, model } from '@angular/core';
import { createComponentFactory, createRoutingFactory } from '@openng/spectator/jest';

describe('PropsAliasNames', () => {
describe('input decorators', () => {
@Component({
selector: 'app-root',
template: `
<div data-test="props--name">{{ name }}</div>
<div data-test="props--age">{{ numOfYears }}</div>
`,
standalone: true,
})
class DummyComponent {
@Input('userName') public name = '';
@Input({ alias: 'age' }) public numOfYears = 0;
}

const createComponent = createComponentFactory(DummyComponent);

it('props should accept the property name of an @Input() with a rename alias', () => {
const spectator = createComponent({ props: { name: 'John' } });

expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John');
});

it('props should accept the property name of an @Input() with an alias option', () => {
const spectator = createComponent({ props: { numOfYears: 123 } });

expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123');
});

it('props should still accept the alias names', () => {
const spectator = createComponent({ props: { userName: 'John', age: 123 } });

expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John');
expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123');
});

it('props should accept property names and alias names in the same object', () => {
const spectator = createComponent({ props: { name: 'John', age: 123 } });

expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John');
expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123');
});

it('setInput should accept the property name of an aliased input', () => {
const spectator = createComponent();

spectator.setInput('name', 'John');
spectator.setInput('numOfYears', 123);

expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John');
expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123');
});
});

describe('signal inputs', () => {
@Component({
selector: 'app-root',
template: `
<div data-test="props--name">{{ name() }}</div>
<div data-test="props--age">{{ numOfYears() }}</div>
<div data-test="props--nickname">{{ nickname() }}</div>
`,
standalone: true,
})
class DummyComponent {
public name = input.required<string>({ alias: 'userName' });
public numOfYears = input(0, { alias: 'age' });
public nickname = model('', { alias: 'nick' });
}

const createComponent = createComponentFactory(DummyComponent);

it('props should accept the property name of a required signal input with an alias', () => {
const spectator = createComponent({ props: { name: 'John' } });

expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John');
});

it('props should accept the property name of an optional signal input with an alias', () => {
const spectator = createComponent({ props: { name: 'John', numOfYears: 123 } });

expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123');
});

it('props should accept the property name of a model with an alias', () => {
const spectator = createComponent({ props: { name: 'John', nickname: 'Johnny' } });

expect(spectator.query('[data-test="props--nickname"]')!.innerHTML).toBe('Johnny');
});

it('props should still accept the alias names', () => {
const spectator = createComponent({ props: { userName: 'John', age: 123, nick: 'Johnny' } });

expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John');
expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123');
expect(spectator.query('[data-test="props--nickname"]')!.innerHTML).toBe('Johnny');
});
});

describe('inputs without an alias', () => {
@Component({
selector: 'app-root',
template: `
<div data-test="props--title">{{ title }}</div>
<div data-test="props--count">{{ count() }}</div>
`,
standalone: true,
})
class DummyComponent {
@Input() public title = '';
public count = input(0);
}

const createComponent = createComponentFactory(DummyComponent);

it('props should keep working for inputs that declare no alias', () => {
const spectator = createComponent({ props: { title: 'Hello', count: 7 } });

expect(spectator.query('[data-test="props--title"]')!.innerHTML).toBe('Hello');
expect(spectator.query('[data-test="props--count"]')!.innerHTML).toBe('7');
});

it('props should leave a key that matches no input alone', () => {
// Angular reports the unknown key itself (NG0303); the resolver must not
// silently redirect it onto some other input.
const spectator = createComponent({ props: { title: 'Hello', notAnInput: 'ignored' } });

expect(spectator.query('[data-test="props--title"]')!.innerHTML).toBe('Hello');
expect(spectator.query('[data-test="props--count"]')!.innerHTML).toBe('0');
});
});

describe('a key that is both a public name and a property name', () => {
@Component({
selector: 'app-root',
template: `
<div data-test="props--years">{{ numOfYears }}</div>
<div data-test="props--age">{{ age }}</div>
`,
standalone: true,
})
class DummyComponent {
// 'age' is the public name of this input...
@Input({ alias: 'age' }) public numOfYears = 'no-years';
// ...and the property name of this one. Both are strings on purpose: the
// type describes the property, so a collision is only expressible when the
// two inputs agree on their value type.
@Input({ alias: 'collided' }) public age = 'untouched';
}

const createComponent = createComponentFactory(DummyComponent);

it('props should resolve the key as a public name, not as a property name', () => {
const spectator = createComponent({ props: { age: 'set-via-public-name' } });

expect(spectator.query('[data-test="props--years"]')!.innerHTML).toBe('set-via-public-name');
expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('untouched');
});

it('props should still reach the shadowed input through its own public name', () => {
const spectator = createComponent({ props: { collided: 'reached' } });

expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('reached');
});
});

describe('no props', () => {
@Component({
selector: 'app-root',
template: `<div data-test="props--name">{{ name }}</div>`,
standalone: true,
})
class DummyComponent {
@Input('userName') public name = 'default';
}

const createComponent = createComponentFactory(DummyComponent);

it('props should be a no-op when it is explicitly undefined', () => {
const spectator = createComponent({ props: undefined });

expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default');
});

it('props should be a no-op when no overrides are passed at all', () => {
const spectator = createComponent();

expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default');
});

it('setInput should be a no-op when it is given nothing', () => {
const spectator = createComponent();

spectator.setInput(undefined as unknown as Record<string, unknown>);

expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default');
});
});

describe('createRoutingFactory', () => {
@Component({
selector: 'app-root',
template: `<div data-test="props--name">{{ name }}</div>`,
standalone: true,
})
class DummyComponent {
@Input('userName') public name = '';
}

const createComponent = createRoutingFactory(DummyComponent);

it('props should accept the property name of an aliased input', () => {
const spectator = createComponent({ props: { name: 'John' } });

expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John');
});

it('props should still accept the alias name', () => {
const spectator = createComponent({ props: { userName: 'John' } });

expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John');
});
});
});
9 changes: 7 additions & 2 deletions projects/spectator/src/lib/internals/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { DOMSelector } from '../dom-selectors';
import { isString, QueryOptions, QueryType } from '../types';
import { type Token } from '../token';

import { inputNameResolver } from './resolve-input-name';

export function getChildren<R>(debugElementRoot: DebugElement): (directiveOrSelector: QueryType, options?: QueryOptions<R>) => R[] {
return (directiveOrSelector: QueryType, options: QueryOptions<R> = { root: false, read: undefined }): R[] => {
if (directiveOrSelector instanceof DOMSelector) {
Expand All @@ -30,16 +32,19 @@ export function getChildren<R>(debugElementRoot: DebugElement): (directiveOrSele
/**
* @internal
* Set props on a component. This is used in `createComponent` and `createRoutingFactory` since we have direct access to the componentRef.
* Keys may be either the class property name or the input's public name, since `ComponentRef.setInput()` only accepts the latter.
*/
export function setProps<T, K extends string | number | symbol, V>(componentRef: ComponentRef<T>, key: K, value: V): T & { [KEY in K]: V };
export function setProps<T, KV>(componentRef: ComponentRef<T>, keyValues?: KV): T & KV;
export function setProps(componentRef: ComponentRef<any>, keyOrKeyValues: any, value?: any): any {
const resolveInputName = inputNameResolver(componentRef.componentType);

if (isString(keyOrKeyValues)) {
componentRef.setInput(keyOrKeyValues, value);
componentRef.setInput(resolveInputName(keyOrKeyValues), value);
} else {
// eslint-disable-next-line guard-for-in
for (const p in keyOrKeyValues) {
componentRef.setInput(p, keyOrKeyValues[p]);
componentRef.setInput(resolveInputName(p), keyOrKeyValues[p]);
}
Comment thread
nicobytes marked this conversation as resolved.
}

Expand Down
38 changes: 38 additions & 0 deletions projects/spectator/src/lib/internals/resolve-input-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { reflectComponentType, Type } from '@angular/core';

/**
* @internal
* Build a resolver that maps a key used in a test onto the name Angular expects.
*
* `ComponentRef.setInput()` addresses inputs by their public name, so an input
* declaring an alias — `@Input('userName')`, `input({ alias: 'userName' })` — is
* only reachable through that alias. Tests, however, are typed against the class
* property names, so both spellings have to work.
*/
export function inputNameResolver(componentType: Type<unknown>): (key: string) => string {
const inputs = reflectComponentType(componentType)?.inputs;

if (!inputs?.length) {
return (key) => key;
}

const publicNames = new Set(inputs.map((input) => input.templateName));
const publicNameByPropName = new Map(inputs.map((input) => [input.propName, input.templateName]));

return (key) => {
/**
* A public name always wins. A key can be the public name of one input and
* the property name of another, and resolving it as the public name is what
* `setInput()` has always done.
*/
if (publicNames.has(key)) {
return key;
}

/**
* Unknown keys are left untouched so that Angular still reports them
* (NG0303) instead of them being silently redirected onto another input.
*/
return publicNameByPropName.get(key) ?? key;
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
overridePipes,
SpectatorOverrides,
} from '../spectator/create-factory';
import { InferInputSignals, isType } from '../types';
import { InferInputProps, isType } from '../types';

import { SpyObject } from '../mock';
import { ActivatedRouteStub } from './activated-route-stub';
Expand Down Expand Up @@ -93,7 +93,7 @@ export function createRoutingFactory<C>(typeOrOptions: Type<C> | SpectatorRoutin
};
}

function createSpectatorRouting<C>(options: Required<SpectatorRoutingOptions<C>>, props?: InferInputSignals<C>): SpectatorRouting<C> {
function createSpectatorRouting<C>(options: Required<SpectatorRoutingOptions<C>>, props?: InferInputProps<C>): SpectatorRouting<C> {
const fixture = TestBed.createComponent(options.component, { bindings: options.bindings });
const debugElement = fixture.debugElement;

Expand Down
6 changes: 3 additions & 3 deletions projects/spectator/src/lib/spectator/create-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { BaseSpectatorOptions, BaseSpectatorOverrides } from '../base/options';
import { addMatchers } from '../core';
import { setProps } from '../internals/query';
import * as customMatchers from '../matchers';
import { InferInputSignals, isType } from '../types';
import { InferInputProps, isType } from '../types';

import { initialSpectatorModule } from './initial-module';
import { getSpectatorDefaultOptions, SpectatorOptions } from './options';
Expand All @@ -22,7 +22,7 @@ export type SpectatorFactory<C> = (options?: SpectatorOverrides<C>) => Spectator
*/
export interface SpectatorOverrides<C> extends BaseSpectatorOverrides {
detectChanges?: boolean;
props?: InferInputSignals<C>;
props?: InferInputProps<C>;
}

/**
Expand Down Expand Up @@ -185,7 +185,7 @@ export function createComponentFactory<C>(typeOrOptions: Type<C> | SpectatorOpti
};
}

function createSpectator<C>(options: Required<SpectatorOptions<C>>, props?: InferInputSignals<C>): Spectator<C> {
function createSpectator<C>(options: Required<SpectatorOptions<C>>, props?: InferInputProps<C>): Spectator<C> {
const fixture = TestBed.createComponent(options.component, { bindings: options.bindings });
const debugElement = fixture.debugElement;

Expand Down
Loading