Component Testing: Page Objects (DRAFT) 4.0


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

Imports

The angular_test package recognizes page objects implemented using annotations from the pageloader package. Include these imports at the top of any page object class:

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

import 'dart:async'; import 'package:pageloader/objects.dart';

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}} details!</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', () async { expect(await appPO.title, 'Tour of Heroes'); });

PO field annotation basics

You can declaratively identify HTML elements that occur in a component’s template by adorning PO class fields with pageloader annotations like @ByTagName('h1'). During test execution, the package binds such fields to the DOM element(s) specified by the annotation. For example, an initial version of AppPO might look like this:

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

class AppPO { @ByTagName('h1') PageLoaderElement _title; // ··· Future<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. Access to page elements is asynchronous, which is why title is of type Future, and the “title” test shown earlier is marked as async.

PO instantiation

Get a PO instance from the fixture’s resolvePageObject() method, passing the PO type as argument. Since most page objects are shared across tests, they are generally initialized during setup:

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

final testBed = new NgTestBed<AppComponent>(); NgTestFixture<AppComponent> fixture; AppPO appPO; setUp(() async { fixture = await testBed.create(); appPO = await fixture.resolvePageObject(AppPO); });
PO field binds are final

PO fields are bound at the time the PO instance is created, based on the state of the fixture’s component’s view. Once bound, they do not change.

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 = const <String, dynamic>{'id': 1, 'name': 'Windstorm'}; test('initial hero properties', () async { expect(await appPO.heroId, windstormData['id']); expect(await 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_test.dart (AppPO hero)

class AppPO { // ··· @FirstByCss('div') PageLoaderElement _id; // e.g. 'id: 1' @ByTagName('h2') PageLoaderElement _heroName; // e.g. 'Mr Freeze details!' // ··· Future<int> get heroId async { final idAsString = (await _id.visibleText).split(':')[1]; return int.parse(idAsString, onError: (_) => -1); } Future<String> get heroName async { final text = await _heroName.visibleText; return text.substring(0, text.lastIndexOf(' ')); } // ··· }

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, dropping the “details!” suffix.

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 (styled heroes)

<h2>My Heroes</h2> <ul class="heroes"> <li *ngFor="let hero of heroes"> <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> _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<Future<Map>> get heroes => _heroes.map((el) async => _heroDataFromLi(await el.visibleText)); // ··· Map<String, dynamic> _heroDataFromLi(String liText) { final matches = new 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 (optional hero details)

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

To access optionally displayed page elements like these, use the @optional annotation:

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

@FirstByCss('div h2') @optional PageLoaderElement _heroDetailHeading; // e.g. 'Mr Freeze details!'

When no hero details are present in the view, then _heroDetailHeading will be null.

Getting optional POs after view updates

Initially, there is no selected hero in the Heroes List. After selecting a hero by simulating a user click, you’ll want to check that the proper hero details are shown in the updated view. You’ll need to fetch a new PO (since the old PO has null optional fields):

await appPO.selectHero(5); appPO = await fixture.resolvePageObject(AppPO); // ··· expect(await appPO.selectedHero, targetHero);

You’ll most likely have more than one test over the selected hero. One way to address this is to create a test group with its own setup method, which selects the hero and gets a new PO.

toh-2/test/app_test.dart (show hero details)

const targetHero = const {'id': 16, 'name': 'RubberMan'}; setUp(() async { await appPO.selectHero(5); appPO = await fixture.resolvePageObject(AppPO); }); test('is selected', () async { expect(await appPO.selectedHero, targetHero); }); test('show hero details', () async { expect(await appPO.heroFromDetails, targetHero); });