In the previous chapters, we focused on the implementation of different user stories and the Ionic framework itself. In this chapter, we’ll focus on some nonfunctional requirements, including end-to-end test with Protractor and build with Headless Chrome and GitLab CI. These steps are essential for real-world app development. After reading this chapter, you should know how to apply these practices.
End-to-End Test with Protractor
Protractor (
http://www.protractortest.org/
) is an end-to-end test framework for Angular applications. The name “Protractor” comes from the instrument that is used to measure angles, indicating its close relationship with Anguar. Most of the end-to-end testing tools are using Selenium WebDriver (
http://www.seleniumhq.org/projects/webdriver/
) to drive a browser as a user would. You can use the WebDriver API directly, or use tools like Nightwatch.js (
http://nightwatchjs.org/
) or WebdriverIO (
http://webdriver.io/
), which wrap the WebDriver API to provide an easier-to-use API. Protractor also uses WebDriver API, but it has a close integration with Angular to make find elements much easier.
We use npm to install Protractor and the dependent package
webdriver-manager that manages the Selenium server for testing.
$ npm i -D protractor webdriver-manager
Then we need to update
webdriver-manager to install drivers for different browsers. This is done by running the command
update of
webdriver-manager. Because we didn’t install the package
webdriver-manager globally, we must use the full-qualified path to access the binary of
webdriver-manager.
$ node_modules/webdriver-manager/bin/webdriver-manager update
After the command
update is finished, we can start the Selenium server using the command below.
$ node_modules/webdriver-manager/bin/webdriver-manager start
To make sure that webdriver-manager is updated, we can add a postinstall script to package.json to run webdriver-manager update after the packages installation.
Protractor Configuration
Protractor is configured using the file
protractor.conf.js in the directory
e2e; see Listing
14-1. This file contains different settings. Refer to this page (
http://www.protractortest.org/#/api-overview
) for more information about the
configuration.
seleniumAddress - Set the Selenium server address. http://localhost:4444/wd/hub is the default Selenium address, so the property seleniumAddress can be omitted.
allScriptsTimeout - Set the timeout settings to 15 seconds.
specs - Set the pattern to find all specs files. Here we use all the files with the suffix .e2e_spec.ts in the directory src.
capabilities - Set the capabilities requirements for Selenium server. Here we require using Chrome.
directConnect - Connect directly to the browser without using a Selenium server. This option can be enabled for Chrome and Firefox to speed up the execution of test scripts. When this property is set to true, Selenium-related settings are ignored.
baseUrl - Set the base URL for testing.
framework - Set the testing framework. Jasmine is the recommended framework.
jasmineNodeOpts - Set the options for Jasmine.
beforeLaunch - Set the action to run before any environment setup. Here we register the directory e2e to ts-node, so ts-node compiles TypeScript code on-the-fly during the execution of the script.
onPrepare - Set the action to run before the specs are executed. Here we configure Jasmine to add one reporter. SpecReporter is used to output the testing result to the console.
const { SpecReporter } = require('jasmine-spec-reporter');
exports.config = {
allScriptsTimeout: 15000,
specs: [
'./src/**/*.e2e-spec.ts'
],
capabilities: {
browserName: 'chrome',
},
directConnect: true,
baseUrl: 'http://localhost:4200/',
framework: 'jasmine',
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 30000,
print: function() {}
},
beforeLaunch() {
require('ts-node').register({
project: 'e2e/tsconfig.e2e.json'
});
},
onPrepare() {
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
browser.waitForAngularEnabled(false);
// The css_sr selector is copied from https://github.com/angular/protractor/issues/4367.
// The related PR (https://github.com/angular/protractor/pull/4786/) is not merged yet.
by.addLocator('css_sr', (cssSelector, opt_parentElement, opt_rootSelector) => {
let selectors = cssSelector.split('::sr');
if (selectors.length === 0) {
return [];
}
let shadowDomInUse = (document.head.createShadowRoot || document.head.attachShadow);
let getShadowRoot = (el) => ((el && shadowDomInUse) ? el.shadowRoot : el);
let findAllMatches = (selector, targets, firstTry) => {
let using, i, matches = [];
for (i = 0; i < targets.length; ++i) {
using = (firstTry) ? targets[i] : getShadowRoot(targets[i]);
if (using) {
if (selector === “) {
matches.push(using);
} else {
Array.prototype.push.apply(matches, using.querySelectorAll(selector));
}
}
}
return matches;
};
let matches = findAllMatches(selectors.shift().trim(), [opt_parentElement || document], true);
while (selectors.length > 0 && matches.length > 0) {
matches = findAllMatches(selectors.shift().trim(), matches, false);
}
return matches;
});
}
};
Listing 14-1
protractor.conf.js
The baseUrl we use for testing is http://localhost:4200, which is the URL of the Angular dev server. In the function onPrepare, we also call browser.waitForAngularEnabled(false) to disable the waiting for pending asynchronous tasks to finish in Angular (
https://github.com/angular/protractor/blob/master/docs/timeouts.md#waiting-for-angular
). Otherwise, all the tests will fail with timeout errors: Timed out waiting for asynchronous Angular tasks to finish after 15 seconds while waiting for element with locator. This is because Firebase uses WebSocket to connect to the server and the connection is treated as in-progress asynchronous task.
In the function onPrepare, we also add a custom Protractor locator css_sr. This selector enables selection of elements inside of shadow trees. Ionic Core components uses Shadow DOM by default. The DOM elements inside of their shadow roots cannot be selected using existing CSS selectors in Protractor. There is already a pending GitHub pull request (
https://github.com/angular/protractor/pull/4786/
) to add this selector to Protractor. Once this pull request is merged, we no longer need to add it in the function onPrepare.
The file package.json already has the script e2e to run Protractor. We can run npm run e2e or ng e2e to start the Protractor tests.
Top Stories Page Test
After Protractor is configured, we add the first end-to-end test suite for the top stories page. The spec file is written with Jasmine, so we can leverage what we already know in writing unit tests; see Listing 14-2. The first test spec verifies that there should be 20 stories. The method browser.get(“) loads the page in the browser. Because we already configure the baseUrl in protractor.conf.js, the empty string means loading the base URL. The functional call browser.wait(EC.presenceOf(element(by.css('app-top-stories'))), 5000) uses the ExpectedConditions (
https://www.protractortest.org/#/api?view=ProtractorExpectedConditions
) provided by Protractor to wait for certain conditions to be met. The condition presenceOf means the element matching the given CSS selector must exist in the DOM. Here we wait for the element app-top-stories to be created. The method element.all(by.css('app-item')) finds all <app-item> elements, then we verify the number to be 20.
In the second spec, we use the method
browser.executeScript() to execute some JavaScript code in the browser. The script
document.getElementsByTagName("ion-infinite-scroll")[0].scrollIntoView(); makes the content to scroll to show the component
ion-infinite-scroll, which triggers the loading of more items. We then verify the number of items should be
40. The method
browser.sleep(5000) makes the tests to sleep 5 seconds to allow the loading to finish.
import { browser, element, by, ExpectedConditions as EC } from 'protractor';
describe('top stories page', () => {
it('should show 20 stories', () => {
browser.get(“);
browser.wait(EC.presenceOf(element(by.css('app-top-stories'))), 5000);
const stories = element.all(by.css('app-item'));
expect(stories.count()).toEqual(20);
});
it('should show more stories when scrolling down', () => {
browser.get(“);
browser.wait(EC.presenceOf($('app-top-stories')), 5000);
let stories = element.all(by.css('app-item'));
expect(stories.count()).toEqual(20);
browser.executeScript('document.getElementsByTagName("ion-infinite-scroll")[0].scrollIntoView();');
browser.sleep(5000);
stories = element.all(by.css('app-item'));
expect(stories.count()).toEqual(40);
});
});
Listing 14-2Test spec for the top stories page
Page Objects and Suites
In the test specs of Listing 14-2, we can see a lot of code duplication. We can refactor the test code using the Page Objects pattern (
https://martinfowler.com/bliki/PageObject.html
). A page object describes a page and encapsulates all logic related to this page. These page objects can be reused in different tests.
In Listing
14-3,
TopStoriesPage is the class for the top stories page. The method
get() loads the page,
scrollDown() scrolls down to trigger the loading of more items,
getStoriesCount() gets the number of stories in the page. TypeScript files of page objects usually have suffix
.po.ts. Here we use two convenient shortcuts,
$ and
$$, to replace the usage of
element and
by in Listing
14-2.
$ is the shortcut of
element(by.css()), while
$$ is the shortcut of
element.all(by.css()).
import { $, $$, browser, ExpectedConditions as EC } from 'protractor';
export class TopStoriesPage {
get() {
browser.get(“);
browser.wait(EC.presenceOf($('app-top-stories')), 5000);
}
scrollDown() {
browser.executeScript('document.getElementsByTagName("ion-infinite-scroll")[0].scrollIntoView();');
browser.sleep(5000);
}
getStoriesCount() {
browser.wait(EC.presenceOf($('app-item')), 5000);
return $$('app-top-stories app-item').count();
}
}
Listing 14-3Page object of the top stories page
Listing
14-4 is the actual
test spec for the top stories page. In the method
beforeEach(), an object of
TopStoriesPage is created for each test spec and used to interact with the page.
import { browser, element, by } from 'protractor';
import { TopStoriesPage } from './top-stories.po';
describe('top stories page', () => {
beforeEach(() => {
this.topStoriesPage = new TopStoriesPage();
this.topStoriesPage.get();
});
it('should show 20 stories', () => {
expect(this.topStoriesPage.getStoriesCount()).toEqual(20);
});
it('should show more stories when scrolling down', () => {
expect(this.topStoriesPage.getStoriesCount()).toEqual(20);
this.topStoriesPage.scrollDown();
expect(this.topStoriesPage.getStoriesCount()).toEqual(40);
});
});
Listing 14-4Test spec of top stories page
We can also group test specs into different suites. In the
protractor.conf.js, we can use the property
suites to configure suites and their included spec files; see Listing
14-5.
exports.config = {
suites: {
top_stories: './src/top_stories.e2e_spec.ts',
},
};
Listing 14-5Protractor test suites
Then we can use
--suite to specify the suites to run.
$ protractor --suite top_stories
User Management Test
We continue to add test specs for user management-related
features
. The class
LogInPage in Listing
14-6 is the page object for the login page. In the method
get(), we go to the index page and call the method
gotoLogin() to navigate to the login page. In the method
logIn(), we use
sendKeys() to input the test user’s email and password, then click the button to log in. Here we use the selector
css_sr added in Listing
14-1 to select the
input elements inside of
ion-input components.
::sr in the
css_rs is used to separate the selector used in the main DOM tree and selector used in the shadow root. In the method
canLogIn(), we check the existence of the login button. In the method
isLoggedIn(), we check the existence of the logout button. In the method
logOut(), we click the logout button to log out the current user.
import { browser, element, by, $ } from 'protractor';
export class LogInPage {
get() {
browser.get(“);
browser.sleep(5000);
gotoLogin() {
$('#btnShowLogin').click();
browser.sleep(2000);
}
element(by.css_sr('ion-input::sr input[name=email]')).sendKeys(email);
element(by.css_sr('ion-input::sr input[name=password]')).sendKeys(password);
$('#btnLogin').click();
browser.sleep(5000);
}
canLogIn() {
return $('#btnShowLogin').isPresent();
}
isLoggedIn() {
return $('#btnLogout').isPresent();
}
logOut() {
$('#btnLogout').click();
browser.sleep(1000);
}
}
Listing 14-6Page object of login page
With the class
LogInPage, the test suite for user management is very simple; see Listing
14-7. When the page is loaded, we check the user can do the login. After we call the method logIn(), we check the user is logged in. The we call the method logOut() and check the user can do the login again.
import { LogInPage } from './login.po';
describe('user', () => {
it('should be able to log in and log out', () => {
const loginPage = new LogInPage();
loginPage.get();
expect(loginPage.canLogIn()).toBe(true);
loginPage.logIn();
expect(loginPage.isLoggedIn()).toBe(true);
loginPage.logOut();
expect(loginPage.canLogIn()).toBe(true);
});
});
Listing 14-7Test spec of login page
Favorites Page Test
The page
object
of the favorites page in Listing
14-8 uses the class
LogInPage to handle the login. In the method
addToFavorite(), we find the first item element with a Like button, which is the item to add to the favorites. We click the Like button to add it and return the item’s title. In the method
isInFavorites(), we go to the favorites page and check that the title of the newly liked item is in the array of the titles of all items.
import { browser, by, $, $$ } from 'protractor';
import { LogInPage } from './login.po';
export class FavoritesPage {
logInPage: LogInPage = new LogInPage();
get() {
this.logInPage.get();
this.logInPage.logIn();
}
viewFavorites() {
$('#btnShowFavorites').click();
browser.sleep(5000);
}
addToFavorite() {
const itemElem = $$('app-top-stories app-item').filter(elem => {
return elem.$('.btnLike').isPresent();
}).first();
if (itemElem) {
const title = itemElem.$('h2').getText();
itemElem.$('.btnLike').click();
browser.sleep(2000);
return title;
}
return null;
}
isInFavorites(title: string) {
this.viewFavorites();
expect($$('app-favorites-list h2').map(elem => elem.getText())).toContain(title);
}
}
Listing 14-8Page object of the favorites page
Listing
14-9 is the test spec of the favorites page. The test spec checks that items can be added to the favorites.
import { FavoritesPage } from './favorites.po';
describe('favorites page', () => {
beforeEach(() => {
this.favoritesPage = new FavoritesPage();
this.favoritesPage.get();
});
it('should add stories to the favorites', () => {
const title = this.favoritesPage.addToFavorite();
if (!title) {
fail('No stories can be added.');
}
this.favoritesPage.isInFavorites(title);
});
});
Listing 14-9Favorites page test
Build
After we add unit tests and end-to-end tests, we need to introduce the concept of continuous integration that makes sure every commit is tested.
Headless Chrome for Tests
Currently we use Chrome to run unit tests, which is good for local development and debugging, but it makes the continuous integration harder as it requires managing external browser processes. It’s not recommended to use PhantomJS for tests any more. A better choice is to use the headless browser Headless Chrome (
https://developers.google.com/web/updates/2017/04/headless-chrome
).
Switching from Chrome to Headless Chrome is an easy task; we just need to update the settings browsers in karma.conf.js to be ['ChromeHeadless']. When running for continuous integration, we should use the option --single-run to close the browser after the test.
We can configure Headless
Chrome to use different options. In Listing
14-10, the browser
CustomHeadlessChrome extends from
ChromeHeadless with different flags. The flag
--no-sandbox is required if Chrome is running as the root user. It’s better to create a new Karma configuration file
karma.ci.conf.js to be used when running on continuous servers. In this case, we can add a new script
ci:test to run the command
ng test --karma-config src/karma.ci.conf.js.
module.exports = function (config) {
config.set({
browsers: ['CustomHeadlessChrome'],
customLaunchers: {
CustomHeadlessChrome: {
base: 'ChromeHeadless',
flags: ['--disable-translate', '--disable-extensions', '--no-sandbox']
}
},
});
};
Listing 14-10Configure Headless Chrome
For end-to-end tests, we also need to update Protractor to use Headless Chrome. Listing
14-11 shows the Protractor configuration file
protractor.ci.conf.js for continuous integration. This file extends from standard
protractor.conf.js and overrides the configuration for Chrome. The option
--headless makes Chrome running in headless mode.
const parentConfig = require('./protractor.conf').config;
exports.config = Object.assign(parentConfig, {
capabilities: {
browserName: 'chrome',
chromeOptions: {
args: ['--headless', '--disable-gpu', '--window-size=800x600', '--no-sandbox'],
}
}
});
Listing 14-11protractor.conf.js for CI
To use this Protractor configuration file, we need to create a new script
ci:e2e to run the following command.
ng e2e --protractor-config e2e/protractor.ci.conf.js
Gitlab CI
We can integrate the app build process with different continuous integration servers. Here Gitlab
CI is used as an example. If you have a local Gitlab installation, you need to install Gitlab Runner (
https://docs.gitlab.com/runner/
) to run CI jobs.
Gitlab uses Docker to run continuous integrations. We add the file
.gitlab-ci.yml in the root directory to enable CI on Gitlab; see Listing
14-12. We use the
alexcheng/ionic as the Docker image to run. In the
before_script, we use npm to install the dependencies. The first job
unit-test runs the unit tests using
npm run ci:test; the second job
e2e-test runs the end-to-end tests using
npm run ci:e2e.
image: alexcheng/ionic:latest
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
unit-test:
script:
- npm run ci:test
e2e-test:
script:
- npm run ci:e2e
Listing 14-12.gitlab-ci.yml
Summary
In this chapter, we discussed how to use Protractor to test Ionic 4 apps and use Gitlab CI to run continuous integration. Apart from the unit tests we added in the previous chapters, end-to-end tests are also very important in testing Ionic 4 apps to make sure that different components are integrated correctly. Continuous integration is also important for the whole development life cycle that guarantees every code commit is tested. In the next chapter, we’ll discuss some topics about publishing the app.