Component Testing: Page Objects (DRAFT) 5.1


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

As components and their templates become more complex, you’ll want to separate concerns and isolate testing code from the detailed HTML encoding of page elements in templates.

You can achieve this separation by creating page object (PO) classes having APIs written in terms of application-specific concepts, such as “title”, “hero ID”, and “hero name”. A PO class encapsulates details about:

  • HTML element access, for example, whether a hero name is contained in a heading element or a <div>
  • Type conversions, for example, from String to int, as you’d need to do for a hero ID

Pubspec configuration

The angular_test package recognizes page objects implemented using annotations from the pageloader package.

Add the package to the pubspec dependencies:

{toh-0 → toh-1}/pubspec.yaml
9
10
dev_dependencies:
10
11
angular_test: ^2.0.0
11
12
build_runner: ^1.0.0
12
13
build_test: ^0.10.2
13
14
build_web_compilers: ^0.4.0
15
+ pageloader: ^3.0.0
14
16
test: ^1.0.0

Imports

Include these imports at the top of your page object file:

toh-1/test/app_po.dart (imports)

import 'dart:async';

import 'package:pageloader/pageloader.dart';

Update the imports at the top of your test file:

{toh-0 → toh-1}/test/app_test.dart
1
1
@TestOn('browser')
2
2
import 'package:angular_test/angular_test.dart';
3
3
import 'package:angular_tour_of_heroes/app_component.dart';
4
4
import 'package:angular_tour_of_heroes/app_component.template.dart' as ng;
5
+ import 'package:pageloader/html.dart';
5
6
import 'package:test/test.dart';
7
+ import 'app_po.dart';
8
+
6
9
void main() {

Running example

As running examples, this page uses the Hero Editor and Heroes List apps from parts 1 and 2 of the tutorial, respectively.

You’ll first see POs and tests for the tutorial’s simple Hero Editor. Before proceeding, review the final code of the tutorial part 1 and take note of the app component template:

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

template: '''
  <h1>{{title}}</h1>
  <h2>{{hero.name}}</h2>
  <div><label>id: </label>{{hero.id}}</div>
  <div>
    <label>name: </label>
    <input [(ngModel)]="hero.name" placeholder="name">
  </div>
''',

You can use a single page object for an entire app when it is as simple as the Hero Editor. You might use such a page object to test the title like this:

toh-1/test/app_test.dart (title)

test('title', () {
  expect(appPO.title, 'Tour of Heroes');
});

PO class

Pageloader recognizes POs that satisfy the following conditions.

  • Source file:
    • The file contains a part statement referring to the filename but with a .g.dart suffix.
    • The file doesn’t contain any Angular annotations (like @Component or @GenerateInjector) that would trigger the Angular builder. This is a temporary limitation; for details, see pageloader issue #134.
  • PO class (declared in the source file):
    • @PageObject() annotates the class.
    • The class is abstract.
    • The class has these constructors:
      • A default constructor.
      • A factory constructor defined as shown below.

Here’s an example of a valid page object implementation:

toh-1/test/app_po.dart (excerpt)

part 'app_po.g.dart';

@PageObject()
abstract class AppPO {

  AppPO();
  factory AppPO.create(PageLoaderElement context) = $AppPO.create;
  // ···
}

During the build process, pageloader generates an implementation for your abstract PO class based on the fields you declare, and saves the implementation to the *.g.dart file. The generated code contains factory methods like $AppPO.create.

PO field annotation basics

You can declaratively identify HTML elements that occur in a component’s template by adorning PO class getters with pageloader annotations like @ByTagName('h1'). For example, an initial version of AppPO might look like this:

toh-1/test/app_po.dart (AppPO initial)

@PageObject()
abstract class AppPO {

  AppPO();
  factory AppPO.create(PageLoaderElement context) = $AppPO.create;

  @ByTagName('h1')
  PageLoaderElement get _title;
  // ···
  String get title => _title.visibleText;
  // ···
}

Because of its @ByTagName() annotation, the _h1 field will get bound to the app component template’s <h1> element.

Other basic tags, which you’ll soon see examples of, include:

The PO title field returns the heading element’s text.

PO instantiation

Create PO instances using the PO factory constructor. PO fields are lazily initialized from a context passed as an argument to the constructor. Create a context from the fixture’s rootElement as shown below. Since most page objects are shared across tests, they are generally initialized during setup:

toh-1/test/app_test.dart (appPO setup)

final testBed =
    NgTestBed.forComponent<AppComponent>(ng.AppComponentNgFactory);
NgTestFixture<AppComponent> fixture;
AppPO appPO;

setUp(() async {
  fixture = await testBed.create();
  final context =
      HtmlPageLoaderElement.createFromElement(fixture.rootElement);
  appPO = AppPO.create(context);
});

Using POs in tests

When the Hero Editor app loads, it displays data for a hero named Windstorm having id 1. Here’s how you might test for this:

toh-1/test/app_test.dart (hero)

const windstormData = <String, dynamic>{'id': 1, 'name': 'Windstorm'};

test('initial hero properties', () {
  expect(appPO.heroId, windstormData['id']);
  expect(appPO.heroName, windstormData['name']);
});

After looking at the app component’s template, you might define the PO heroId and heroName fields like this:

toh-1/test/app_po.dart (AppPO hero)

abstract class AppPO {
  // ···
  @First(ByCss('div'))
  PageLoaderElement get _id; // e.g. 'id: 1'

  @ByTagName('h2')
  PageLoaderElement get _heroName;
  // ···
  int get heroId {
    final idAsString = _id.visibleText.split(':')[1];
    return int.tryParse(idAsString) ?? -1;
  }

  String get heroName => _heroName.visibleText;
  // ···
}

The page object extracts the id from text that follows the “id:” label in the first <div>, and the hero name from the <h2> text.

PO field bindings are lazily initialized and final

PO fields are bound when the field is first accessed, based on the state of the fixture’s root element. Once bound, they do not change.

PO List fields

The app from part 2 of the tutorial displays a list of heros, generated using an ngFor applied to an <li> element:

toh-2/lib/app_component.html

<h2>Heroes</h2>
<ul class="heroes">
  <li *ngFor="let hero of heroes"
      [class.selected]="hero === selected"
      (click)="onSelect(hero)">
    <span class="badge">{{hero.id}}</span> {{hero.name}}
  </li>
</ul>

To define a PO field that collects all generated <li> elements, use the annotations introduced earlier, but declare the field to be of type List<PageLoaderElement>:

toh-2/test/app_po.dart (_heroes)

@ByTagName('li')
List<PageLoaderElement> get _heroes;

When bound, the _heroes list will contain an element for each <li> in the view. If the displayed heroes list is empty, then _heroes will be an empty list — List<PageLoaderElement> PO fields are never null.

You might render hero data (as a map) from the text of the <li> elements like this:

toh-2/test/app_po.dart (heroes)

Iterable<Map> get heroes =>
    _heroes.map((el) => _heroDataFromLi(el.visibleText));

// ···
Map<String, dynamic> _heroDataFromLi(String liText) {
  final matches = RegExp((r'^(\d+) (.*)$')).firstMatch(liText);
  return _heroData(matches[1], matches[2]);
}

PO optional fields

Only once a hero is selected from the Heroes List, are the selected hero’s details displayed using this template fragment:

toh-2/lib/app_component.html

<div *ngIf="selected != null">
  <h2>{{selected.name}}</h2>
  <div><label>id: </label>{{selected.id}}</div>
  <div>
    <label>name: </label>
    <input [(ngModel)]="selected.name" placeholder="name">
  </div>
</div>

Declare an optional field like any other field:

toh-2/test/app_po.dart (hero detail ID)

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

To determine whether an optionally displayed page element is present, test its exists property:

toh-2/test/app_po.dart (heroFromDetails)

Map get heroFromDetails {
  if (!_heroDetailId.exists) return null;
  // ···
}