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


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_component.dart

import 'package:ngdart/angular.dart';
import 'package:ngforms/ngforms.dart';

import 'hero.dart';

@Component(
  selector: 'my-hero',
  template: '''
    <div *ngIf="hero != null">
      <h2>{{hero!.name}}</h2>
      <div><label>id: </label>{{hero!.id}}</div>
      <div>
        <label>name: </label>
        <input [(ngModel)]="hero!.name" placeholder="name">
      </div>
    </div>''',
  directives: [coreDirectives, formDirectives],
)
class HeroComponent {
  @Input()
  Hero? hero;
}

Here is the page object for this component:

toh-3/test/hero_detail_po.dart

import 'dart:async';

import 'package:ngpageloader/pageloader.dart';

part 'hero_detail_po.g.dart';

@PageObject()
abstract class HeroDetailPO {
  HeroDetailPO();
  factory HeroDetailPO.create(PageLoaderElement context) = $HeroDetailPO.create;

  @First(ByCss('div h2'))
  PageLoaderElement get _title;

  @First(ByCss('div div'))
  PageLoaderElement get _id;

  @ByTagName('input')
  PageLoaderElement get _input;

  Map? get heroFromDetails {
    if (!_id.exists) return null;
    final idAsString = _id.visibleText.split(':')[1];
    return _heroData(idAsString, _title.visibleText);
  }

  Future<void> clear() => _input.clear();
  Future<void> type(String s) => _input.type(s);

  Map<String, dynamic> _heroData(String idAsString, String name) =>
      {'id': int.tryParse(idAsString) ?? -1, 'name': name};
}

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

toh-3/lib/app_component.html (my-hero)

<my-hero [hero]="selected"></my-hero>

The tests shown below use the following target hero data:

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

const targetHero = {'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 <my-hero> element without a hero property:
    <my-hero></my-hero>
    

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();
    final context =
        HtmlPageLoaderElement.createFromElement(fixture.rootElement);
    po = HeroDetailPO.create(context);
  });

  test('has empty view', () {
    expect(fixture.rootElement.text!.trim(), '');
    expect(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 = Hero(
              targetHero['id'] as int,
              targetHero['name'] as String,
            ));
    final context =
        HtmlPageLoaderElement.createFromElement(fixture.rootElement);
    po = HeroDetailPO.create(context);
  });

  test('show hero details', () {
    expect(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();
    final context =
        HtmlPageLoaderElement.createFromElement(fixture.rootElement);
    po = HeroDetailPO.create(context);
  });
  // ···

  test('transition to ${targetHero['name']} hero', () async {
    await fixture.update((comp) {
      comp.hero = Hero(
        targetHero['id'] as int,
        targetHero['name'] as String,
      );
    });
    expect(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',
      () => 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:

|@TestOn('browser') | |import 'dart:async'; | |import 'package:angular_test/angular_test.dart'; |import 'package:pageloader/html.dart'; |import 'package:template_syntax/src/sizer_component.dart'; |import 'package:test/test.dart'; | |import 'sizer_test.template.dart' as ng; |import 'sizer_po.dart'; | |NgTestFixture<SizerComponent> fixture; |SizerPO po; | |void main() { | ng.initReflector(); | | const initSize = 16; | | final testBed = NgTestBed<SizerComponent>(); | | setUp(() async { | fixture = await testBed.create(); | final context = | HtmlPageLoaderElement.createFromElement(fixture.rootElement); | po = SizerPO.create(context); | }); | | tearDown(disposeAnyRunningTest); | | test('initial font size', () => _expectSize(initSize)); | | const inputSize = 10; | | test('@Input() size ${inputSize} as String', () async { | await fixture.update((c) => c.size = inputSize.toString()); | await _expectSize(inputSize); | }); | | test('@Input() size ${inputSize} as int', () async { | await fixture.update((c) => c.size = inputSize); | 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', | () => 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', | () => fixture.update((c) async { | expect(await c.sizeChange.first, expectedSize); | })); | }); |} | |Future<void> _expectSize(int size) async { | expect(await po.fontSizeFromLabelText, size); | expect(await po.fontSizeFromStyle, size); |} import 'dart:async'; import 'package:pageloader/pageloader.dart'; part 'sizer_po.g.dart'; @PageObject() abstract class SizerPO { SizerPO(); factory SizerPO.create(PageLoaderElement context) = $SizerPO.create; @ByTagName('label') PageLoaderElement get _fontSize; @ByTagName('button') @WithVisibleText('-') PageLoaderElement get _dec; @ByTagName('button') @WithVisibleText('+') PageLoaderElement get _inc; Future<void> dec() async => _dec.click(); Future<void> inc() async => _inc.click(); int get fontSizeFromLabelText { final text = _fontSize.visibleText; final matches = RegExp((r'^FontSize: (\d+)px$')).firstMatch(text); return _toInt(matches[1]); } int get fontSizeFromStyle { final text = _fontSize.attributes['style']; final matches = RegExp((r'^font-size: (\d+)px;$')).firstMatch(text); return _toInt(matches[1]); } int _toInt(String s) => int.tryParse(s) ?? -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>''', exports: [minSize, maxSize], ) class SizerComponent { int _size = minSize * 2; int get size => _size; @Input() void set size(/*String|int*/ val) { int z = val is int ? val : int.tryParse(val); if (z != null) _size = min(maxSize, max(minSize, z)); } final _sizeChange = 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>