Application testing
Angular BDD E2E Testing Protractor SPA Typescript Visual Studio Code

How to Organize E2E Angular Tests with Page Objects

Welcome to today’s blog.

In today’s blog I will be showing how to implement end-to-end tests using Protractor Page Objects within an Angular application.

In our previous post, we implemented an end-to-end test that covered more than one form / page in our application. The resulting test suite was quite lengthy and had some repeated code and multiple specifications. This would violate the DRY principle in UI applications containing many pages. To be able to support re-use of our tests, it would be beneficial to split our test specifications into their own test suite so that a test for a particular page is isolated.

Recall the test suite we had implemented for a multi-page test scenario:

describe('Protractor Test - Angular Record List Form', function() {
  var heading = element(by.id('heading'));
  var newValue = undefined;

  beforeEach(function() {
    browser.get('http://localhost:4200/books');
  });

  it('should have a title', function() {
      expect(element(by.id('heading'))
        .getText())
        .toEqual('Book List');
    }
  );

  it('record can be updated', function() {
	… lengthy test script
  });  

  it('record update is consistent', function() {
	… lengthy test script
  });  
});

The test suite spanned well over 90 lines of script.

I will split this into three-page objects. I will show how this is done.

Recall that the first page was a Book List, and the second page was a Book Detail.

We will add a login page LoginForm and other two pages BookListPage and BookDetailPage.

Before I show how the objects are implemented, recall how Angular provides built-in E2E scaffolding with an initial app page test. The structure is shown here:

What is does is provide a basic test that checks the title of the application.

Looking at the generated page object source will provide us with an idea on how to implement the remaining three forms we wish to test. It is just a basic class with a navigation method and a DOM element accessor:

import { browser, by, element } from 'protractor';

export class AppPage {
  navigateTo() {
    return browser.get(browser.baseUrl) as Promise<any>;
  }

  getTitleText() {
    return element(by.css('app-root h1'))
      .getText() as Promise<string>;
  }
}

So instead of including all our element accessor logic and browser actions into a test suite, we refactor those into a page class object.

Likewise, each spec is refactored into a self-contained own specification script file.

Moreover, it is in typescript, which gives us much richer options than JavaScript in terms of variable typing:

import { AppPage } from './app.po';
import { browser, logging } from 'protractor';

describe('workspace-project App', () => {
  let page: AppPage;

  beforeEach(() => {
    page = new AppPage();
  });

  it('should display welcome message', () => {
    page.navigateTo();
    console.log("checking page title..");
    page.getTitleText().then(txt => {
      console.log("page title=" + txt);
      expect(txt).toEqual('Angular Demo App');
    });
  });

  afterEach(async () => {
    // Assert that there are no errors emitted from the browser
    const logs = await browser   
      .manage()
      .logs()
      .get(logging.Type.BROWSER);
    expect(logs).not.toContain(jasmine.objectContaining({
      level: logging.Level.SEVERE,
    } as logging.Entry));
  });
});

To run the end to end tests in the E2E folder use the following command:

ng e2e

When this command is run, it will download, install the ChromeDriver, which the Selenium Web driver will run to automate the running of your E2E scripts.

After the scripts are run, if they are executed without issues then the following output will show in your console:

Implementing the tests for the three pages LoginForm, BookListPage and BookDetailPage

The login page object is shown with a navigator method and a number of element accessors:

import { browser, by, element } from 'protractor';

export class LoginForm
{ 
  getHeading() {
    return element(by.id('heading'));
  };

  getHeadingText() {
    return element(by.id('heading')).getText();
  };

  getAccountMenu()
  {
    return element(by.id('btnAccount'));
  }

  getMenuLoginBtn()
  {
    return element(by.id('btnMenuLogin'));
  }

  getMenuLogoutBtn()
  {
    return element(by.id('btnMenuLogout'));
  }

  getLoginId()
  {
    return element(by.id("email"));
  }

  getLoginPwd()
  {
    return element(by.id("pwd"));
  }

  getLoginBtn()
  {
    return element(by.id('btnLogin'));
  }

  getBrowser() {
    return browser.get('http://localhost:4200');
  };
};

Each accessor should correspond to a DOM element in our menu.

For example, the account menu item will be appropriately marked up as shown:

<mat-menu #loginMenu="matMenu">
  <div *ngIf="authService.isLoggedIn()">
    <button mat-menu-item id="welcomeMsg">
      Welcome {{authService.currLoggedInUserValue}}
    </button>
    <button mat-menu-item id="btnMenuLogout" (click)="logout()"> 
      <a>Logout</a>
    </button>
  </div>
  <div *ngIf="!authService.isLoggedIn()">
    <button mat-menu-item id="btnMenuLogin">
      <a routerLink="/login" routerLinkActive="activebutton">
        Login
      </a>
    </button>
  </div>
</mat-menu>

Next, we will define the spec that will automate the following test case:

  • User opens application.
  • User selects the account menu.
  • User enters login email.
  • User enters login password.
  • User hits the login button.
  • User checks they are logged in successful.

The Protractor test script is shown. I have included some console output and browser delays so that the automated test can be observed while running in the browser window, and console output can be observed later for additional diagnostic purposes. They can be removed once the tests run cleanly:

import { LoginForm } from './LoginForm.po';
import { protractor, browser, logging } from 'protractor';

describe('Protractor Test - Angular Login Form', function() {
  let loginForm: LoginForm;

  beforeEach(() => {
    loginForm = new LoginForm();
  });
  
  beforeEach(function() {
    loginForm.getBrowser();
  });

  it('should have a title', function() {
    loginForm.getHeadingText().then(txt => 
      {
        console.log("page title=" + txt);
        expect(loginForm.getHeadingText()).toEqual('Login Form');
      });
    }
  );

  it('user can login', function() {
    var acctMenu = loginForm.getAccountMenu();
    acctMenu.click();
    browser.sleep(2000);

    var email = '[email protected]';
    var pwd = '?????;

    var loginMenuBtn = loginForm.getMenuLoginBtn();
    loginMenuBtn.click();
    browser.sleep(2000);

    var loginEmail = loginForm.getLoginId();
    loginEmail.clear();
    loginEmail.sendKeys(email);
    browser.sleep(2000);   

    var loginPwd = loginForm.getLoginPwd();
    loginPwd.clear();
    loginPwd.sendKeys(pwd);
    browser.sleep(2000);   

    var loginFormBtn = loginForm.getLoginBtn();
    loginFormBtn.click();
    browser.sleep(2000);

    acctMenu.click();

    var btnMenuLogout = loginForm.getMenuLogoutBtn();
    expect(btnMenuLogout.isPresent()).toEqual(true);
  });  
});

Similarly, the page objects for the book list and book detail pages are shown:

Book List Page Object

import { browser, by, element } from 'protractor';

export class BookListPage
{ 
  getHeading() {
    return element(by.id('heading'));
  };

  getHeadingText() {
    return element(by.id('heading')).getText();
  };

  getListItems()
  {
    return element.all(by.css('.mat-list-item'));
  }

  getFirstBook()
  {
    return element(by.id("book_1"));
  }

  getBrowser() {
    return browser.get('http://localhost:4200/books');
  };
};

The book detail page object is defined below:

Book Detail Page Object

import { browser, by, element } from 'protractor';

export class BookDetailsPage
{
  getHeading() {
    return element(by.id('heading'));
  };

  getHeadingText() {
    return element(by.id('heading')).getText();
  };

  getBtnUpdate() {
    return element(by.id('btnUpdate'));
  };

  getISBN() {
    return element(by.id('isbn'));
  };

  getISBNText() {
    return element(by.id('isbn')).getText();
  };
};

The specification script that automates the test case is defined below:

  • User opens book list.
  • User selects the first book.
  • User clicks on ISBN field.
  • User enters new ISBN value.
  • User hits the update button.
  • User re-opens book list.
  • User re-selects the first book.
  • User compares the ISBN value has correct value.

The test is implemented below:

import { BookListPage } from './BookList.po';
import { BookDetailsPage } from '../book-detail/BookDetails.po';
import { protractor, browser, logging } from 'protractor';

describe('Protractor Test - Angular Record List Form', function() {
  let bookListPage: BookListPage;
  let bookDetailPage: BookDetailsPage;
  let newValue = undefined;

  beforeEach(() => {
    bookListPage = new BookListPage();
    bookDetailPage = new BookDetailsPage();
  });
  
  beforeEach(function() {
    bookListPage.getBrowser();
  });

  it('should have a title', function() {
      expect(bookListPage.getHeadingText())
        .toEqual('Book List');
    }
  );

  it('record can be updated', function() {
    bookListPage.getListItems().then(function(items) {
      expect(items.length).toBeGreaterThan(0);
      expect(items[1].getText())
        .toBe('The Lord of the Rings');

      // Select the first item
      var selectItem = bookListPage.getFirstBook();
      selectItem.click();

      // Test if the current form is named 
      expect(bookDetailPage.getHeadingText())
        .toEqual('Book Details'); 

      // Change ISBN value..
      var today = new Date();
      var increment = today.getSeconds();

      var isbnField = bookDetailPage.getISBN(); 
      var isbnValue = undefined;
      var EC = protractor.ExpectedConditions;
      browser.wait(EC.visibilityOf(isbnField), 5000);

      isbnField.getAttribute('value').then(text => {
        newValue = parseInt(text) + increment;
        isbnField.clear();
        isbnField.sendKeys(newValue.toString());
      });
      
      // update the record..
      console.log("updating record..");
      var btnUpdate = bookDetailPage.getBtnUpdate(); 
      btnUpdate.click();
      console.log("record updated!");
    });
  });  

  it('record update is consistent', function() {
    // reselect record and verify the ISBN field is the same 
    // as the updated value..
    browser.get('http://localhost:4200/books');

    bookListPage.getListItems().then(function(items) {
      expect(items.length).toBeGreaterThan(0);
      expect(items[1].getText()).toBe('The Lord of the Rings');
  
      // Select the first item
      var selectItem = bookListPage.getFirstBook();
      selectItem.click();
  
      // Test if the current form is named 
      expect(bookDetailPage.getHeadingText())
        .toEqual('Book Details'); 

      // Compare ISBN value from data source against value 
      // updated on an initial form. 
      var isbnField = bookDetailPage.getISBN();
      var EC = protractor.ExpectedConditions;
      browser.wait(EC.visibilityOf(isbnField), 5000);
 
      expect(isbnField.getAttribute('value'))
        .toEqual(newValue.toString());
      browser.sleep(2000); 
    });
  });  
});

In the above test we are simulating a user entering a new ISBN by generating a new value based on the current time.

There is also a browser wait we have applied for the ISBN value to be visible. This is because the API may has not completed retrieving the data before the rendering of the edit controls on the form.

Before we can start the E2E tests for the above, the protractor configuration file protractor.config.js will need the specs array to be updated with the new specifications:

const { SpecReporter } = require('jasmine-spec-reporter');

exports.config = {
  allScriptsTimeout: 11000,
  specs: [
    './src/**/*.e2e-spec.ts',
    './login/**/login-spec.ts',
    './book-list/**/book-list-spec.ts'
  ],
  capabilities: {
    'browserName': 'chrome'
  },
  directConnect: true,
  baseUrl: 'http://localhost:4200/',
  framework: 'jasmine',
  jasmineNodeOpts: {
    showColors: true,
    defaultTimeoutInterval: 30000,
    print: function() {}
  },
  onPrepare() {
    require('ts-node').register({
      project: require('path').join(__dirname, './tsconfig.json')
    });
    jasmine.getEnv().addReporter(new SpecReporter(
       { spec: { displayStacktrace: true } }));
  }
};

The folder structure for the above specifications should look as follows:

Once we re-run the tests if all is successful, then the output should be as shown:

Well done!

We have successfully implemented an end to end test using page objects.

What have we learned from this?

What have we learned from this?

  • Refactoring our UI page logic into classes.
  • Separating the test specifications into re-usable folders.
  • Making our pages and test specifications re-usable.

In addition, by automating some of the critical tests we have saved testers from manually running all the tedious tests.

I hope this post has been informative.

Social media & sharing icons powered by UltimatelySocial