Component Testing: @Input() and @Output() (DRAFT) 4.0

Send feedback
If you notice any issues with this page, please report them.

This section describes how to test components with @Input(), and @Output() properties.

Running example

The app from part 3 of the tutorial will be used as a running example to illustrate how to test a component with @Input() properties, specifically the HeroDetailComponent:

toh-3/lib/src/hero_detail_component.dart

import 'package:angular/angular.dart'; import 'package:angular_forms/angular_forms.dart'; import 'hero.dart'; @Component( selector: 'hero-detail', template: ''' <div *ngIf="hero != null"> <h2>{{hero.name}} details!</h2> <div><label>id: </label>{{hero.id}}</div> <div> <label>name: </label> <input [(ngModel)]="hero.name" placeholder="name"> </div> </div>''', directives: const [CORE_DIRECTIVES, formDirectives], ) class HeroDetailComponent { @Input() Hero hero; }

Here is the page object for this component:

toh-3/test/hero_detail_po.dart

import 'dart:async'; import 'package:pageloader/objects.dart'; class HeroDetailPO { @FirstByCss('div h2') @optional PageLoaderElement _title; // e.g. 'Mr Freeze details!' @FirstByCss('div div') @optional PageLoaderElement _id; @ByTagName('input') @optional PageLoaderElement _input; Future<Map> get heroFromDetails async { if (_id == null) return null; final idAsString = (await _id.visibleText).split(' ')[1]; final text = await _title.visibleText; final matches = new RegExp((r'^(.*) details!$')).firstMatch(text); return _heroData(idAsString, matches[1]); } Future clear() => _input.clear(); Future type(String s) => _input.type(s); Map<String, dynamic> _heroData(String idAsString, String name) => {'id': int.parse(idAsString, onError: (_) => -1), 'name': name}; }

The app component template contains a <hero-detail> element that binds the hero property to the app component’s selectedHero:

toh-3/lib/app_component.dart (template)

template: ''' <h1>{{title}}</h1> <h2>My Heroes</h2> <ul class="heroes"> <li *ngFor="let hero of heroes" [class.selected]="hero == selectedHero" (click)="onSelect(hero)"> <span class="badge">{{hero.id}}</span> {{hero.name}} </li> </ul> <hero-detail [hero]="selectedHero"></hero-detail> ''',

The tests shown below use the following target hero data:

toh-3/test/hero_detail_test.dart (targetHero)

const targetHero = const {'id': 1, 'name': 'Alice'};

@Input(): No initial value

This case occurs when either of the following is true:

  • The input is bound to an initial null value, such as when app component’s selectedHero is null above.
  • A component uses a <hero-detail> element without a hero property:
    <hero-detail></hero-detail>
    

When a component is created, its inputs are left uninitialized, so basic page object setup is sufficient to test for this case:

toh-3/test/hero_detail_test.dart (no initial hero)

group('No initial @Input() hero:', () { setUp(() async { fixture = await testBed.create(); po = await fixture.resolvePageObject(HeroDetailPO); }); test('has empty view', () async { expect(fixture.rootElement.text.trim(), ''); expect(await po.heroFromDetails, isNull); }); /* . . . */ });

@Input(): Non-null initial value

Initialization of an input property with a non-null value must be done when the test fixture is created. Provide an initialization callback as the named parameter beforeChangeDetection of the NgTestBed.create() method:

toh-3/test/hero_detail_test.dart (initial hero)

group('${targetHero['name']} initial @Input() hero:', () { /* . . . */ setUp(() async { fixture = await testBed.create( beforeChangeDetection: (c) => c.hero = new Hero(targetHero['id'], targetHero['name'])); po = await fixture.resolvePageObject(HeroDetailPO); }); test('show hero details', () async { expect(await po.heroFromDetails, targetHero); }); /* . . . */ });

@Input(): Change value

To emulate an input binding’s change in value, use the NgTestFixture.update() method. This applies whether or not the input property was explicitly initialized:

toh-3/test/hero_detail_test.dart (transition to hero)

group('No initial @Input() hero:', () { setUp(() async { fixture = await testBed.create(); po = await fixture.resolvePageObject(HeroDetailPO); }); /* . . . */ test('transition to ${targetHero['name']} hero', () async { fixture.update((comp) { comp.hero = new Hero(targetHero['id'], targetHero['name']); }); po = await fixture.resolvePageObject(HeroDetailPO); expect(await po.heroFromDetails, targetHero); }); });

@Output() properties

An @Output() property allows a component to raise custom events in response to a timeout or input event. Output properties are visible from a component’s API as public Stream fields.

You can test an output property by first triggering a change. Then wait for an expected update on the output property’s stream, from inside the callback passed as argument to the NgTestFixture.update() method.

For example, you might test the font sizer component, from the Two-way binding section of the Template Syntax page, as follows:

template-syntax/test/sizer_test.dart (Output after inc)

group('inc:', () { const expectedSize = initSize + 1; setUp(() => po.inc()); test('font size is $expectedSize', () async { /* . . . */ }); test('@Output $expectedSize size event', () async { fixture.update((c) async { expect(await c.sizeChange.first, expectedSize); }); }); });

In this test group, the setUp() method initiates a font increment event, and the output test awaits for the updated font size to appear on the sizeChange stream.

Here is the full test file along with other relevant files and excerpts:

@Tags(const ['aot']) @TestOn('browser') import 'dart:async'; import 'package:angular/angular.dart'; import 'package:angular_test/angular_test.dart'; import 'package:template_syntax/src/sizer_component.dart'; import 'package:test/test.dart'; import 'sizer_po.dart'; NgTestFixture<SizerComponent> fixture; SizerPO po; @AngularEntrypoint() void main() { const initSize = 16; final testBed = new NgTestBed<SizerComponent>(); setUp(() async { fixture = await testBed.create(); po = await fixture.resolvePageObject(SizerPO); }); tearDown(disposeAnyRunningTest); test('initial font size', () => _expectSize(initSize)); const inputSize = 10; test('@Input() size ${inputSize} as String', () async { fixture.update((c) => c.size = inputSize.toString()); po = await fixture.resolvePageObject(SizerPO); await _expectSize(inputSize); }); test('@Input() size ${inputSize} as int', () async { fixture.update((c) => c.size = inputSize); po = await fixture.resolvePageObject(SizerPO); await _expectSize(inputSize); }); group('dec:', () { const expectedSize = initSize - 1; setUp(() => po.dec()); test('font size is $expectedSize', () async { await _expectSize(expectedSize); }); test('@Output $expectedSize size event', () async { fixture.update((c) async { expect(await c.sizeChange.first, expectedSize); }); }); }); group('inc:', () { const expectedSize = initSize + 1; setUp(() => po.inc()); test('font size is $expectedSize', () async { await _expectSize(expectedSize); }); test('@Output $expectedSize size event', () async { fixture.update((c) async { expect(await c.sizeChange.first, expectedSize); }); }); }); } Future<Null> _expectSize(int size) async { expect(await po.fontSizeFromLabelText, size); expect(await po.fontSizeFromStyle, size); } import 'dart:async'; import 'package:pageloader/objects.dart'; class SizerPO { @ByTagName('label') PageLoaderElement _fontSize; @ByTagName('button') @WithVisibleText('-') PageLoaderElement _dec; @ByTagName('button') @WithVisibleText('+') PageLoaderElement _inc; Future dec() async => _dec.click(); Future inc() async => _inc.click(); Future<int /*?*/ > get fontSizeFromLabelText async { final text = await _fontSize.visibleText; final matches = new RegExp((r'^FontSize: (\d+)px$')).firstMatch(text); return _toInt(matches[1]); } Future<int /*?*/ > get fontSizeFromStyle async { final text = await _fontSize.attributes['style']; final matches = new RegExp((r'^font-size: (\d+)px;$')).firstMatch(text); return _toInt(matches[1]); } int /*?*/ _toInt(String s) => int.parse(s, onError: (_) => -1); } import 'dart:async'; import 'dart:math'; import 'package:angular/angular.dart'; const _minSize = 8; const _maxSize = _minSize * 5; @Component( selector: 'my-sizer', template: ''' <div> <button (click)="dec()" [disabled]="size <= minSize">-</button> <button (click)="inc()" [disabled]="size >= maxSize">+</button> <label [style.font-size.px]="size">FontSize: {{size}}px</label> </div>''', ) class SizerComponent { // TODO: under Angular 4 we will be able to just export the const final int minSize = _minSize, maxSize = _maxSize; int _size = _minSize * 2; int get size => _size; @Input() void set size(/*int | String */ val) { int z = val is int ? val : int.parse(val, onError: (_) => null); if (z != null) _size = min(maxSize, max(minSize, z)); } final _sizeChange = new StreamController<int>(); @Output() Stream<int> get sizeChange => _sizeChange.stream; void dec() => resize(-1); void inc() => resize(1); void resize(int delta) { size = size + delta; _sizeChange.add(size); } } <my-sizer [(size)]="fontSizePx" #mySizer></my-sizer> <div [style.font-size.px]="mySizer.size">Resizable Text</div>