Component Testing: @Input() and @Output() (DRAFT) 6.0
- Component testing
- Running component tests
-
Writing component tests
- Basics: pubspec config, test API fundamentals
- Page objects: field annotation, initialization, and more
- Simulating user action: click, type, clear
- Services: local, external, mock, real
@Input()
and@Output()
- Routing components
- Appendices - coming soon
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 ahero
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: