Multiple browsers and the Page Object pattern

2k views Asked by At

We are using the Page Object pattern to organize our internal AngularJS application tests.

Here is an example page object we have:

var LoginPage = function () {
    this.username = element(by.id("username"));
    this.password = element(by.id("password"));

    this.loginButton = element(by.id("submit"));
}

module.exports = LoginPage;

In a single-browser test, it is quite clear how to use it:

var LoginPage = require("./../po/login.po.js");

describe("Login functionality", function () {
    var scope = {};

    beforeEach(function () {
        browser.get("/#login");

        scope.page = new LoginPage();
    });

    it("should successfully log in a user", function () {
        scope.page.username.clear();
        scope.page.username.sendKeys(login);
        scope.page.password.sendKeys(password);
        scope.page.loginButton.click();

        // assert we are logged in
    });
});

But, when it comes to a test when multiple browsers are instantiated and there is the need to switch between them in a single test, it is becoming unclear how to use the same page object with multiple browsers:

describe("Login functionality", function () {
    var scope = {};

    beforeEach(function () {
        browser.get("/#login");

        scope.page = new LoginPage();
    });

    it("should warn there is an opened session", function () {
        scope.page.username.clear();
        scope.page.username.sendKeys(login);
        scope.page.password.sendKeys(password);
        scope.page.loginButton.click();

        // assert we are logged in

        // fire up a different browser and log in
        var browser2 = browser.forkNewDriverInstance();

        // the problem is here - scope.page.username.clear() would be applied to the main "browser"
    });
});

Problem:

After we forked a new browser, how can we use the same Page Object fields and functions, but applied to a newly instantiated browser (browser2 in this case)?

In other words, all element() calls here would be applied to browser, but needed to be applied to browser2. How can we switch the context?


Thoughts:

  • one possible approach here would be to redefine the global element = browser2.element temporarily while being in the context of browser2. The problem with this approach is that we also have browser.wait() calls inside the page object functions. This means that browser = browser2 should be also set. In this case, we would need to remember the browser global object in a temp variable and restore it once we switch back to the main browser context..

  • another possible approach would be to pass the browser instance into the page object, something like:

    var LoginPage = function (browserInstance) {
        browser = browserInstance ? browserInstance : browser;
        var element = browser.element;
    
        // ...
    }
    

    but this would probably require to change every page object we have..

Hope the question is clear - let me know if it needs clarification.

2

There are 2 answers

0
Xotabu4 On BEST ANSWER

Look at my solution. I simplified example, but we are using this approach in current project. My app has pages for both user permissions types, and i need to do some complex actions same time in both browsers. I hope this might show you some new, better way!

"use strict";

//In config, you should declare global browser roles. I only have 2 roles - so i make 2 global instances
//Somewhere in onPrepare() function
global.admin = browser;
admin.admin = true;

global.guest = browser.forkNewDriverInstance();
guest.guest = true;

//Notice that default browser will be 'admin' example:
// let someElement = $('someElement'); // this will be tried to be found in admin browser.



class BasePage {
    //Other shared logic also can be added here.
    constructor (browser = admin) {
        //Simplified example
        this._browser = browser
    }
}

class HomePage extends BasePage {
    //You will not directly create this object. Instead you should use .getPageFor(browser)
    constructor(browser) {
        super(browser);

        this.rightToolbar = ToolbarFragment.getFragmentFor(this._browser);
        this.chat = ChatFragment.getFragmentFor(this._browser);
        this.someOtherNiceButton = this._browser.$('button.menu');
    }

    //This function relies on params that we have patched for browser instances in onPrepare();
    static getPageFor(browser) {
        if (browser.guest) return new GuestHomePage(browser);
        else if (browser.admin) return new AdminHomePage(browser);
    }

    openProfileMenu() {
        let menu = ProfileMenuFragment.getFragmentFor(this._browser);
        this.someOtherNiceButton.click();

        return menu;
    }
}


class GuestHomePage extends RoomPage {
    constructor(browser) {
        super(browser);
    }

    //Some feature that is only available for guest
    login() {
        // will be 'guest' browser in this case.
        this._browser.$('input.login').sendKeys('sdkfj'); //blabla
        this._browser.$('input.pass').sendKeys('2345'); //blabla
        this._browser.$('button.login').click();
    }
}


class AdminHomePage extends RoomPage {
    constructor(browser) {
        super(browser);
    }

    acceptGuest() {
        let acceptGuestButton = this._browser.$('.request-admission .control-btn.admit-user');
        this._browser.wait(EC.elementToBeClickable(acceptGuestButton), 10000,
                'Admin should be able to see and click accept guest button. ' +
                'Make sure that guest is currently trying to connect to the page');

        acceptGuestButton.click();
        //Calling browser directly since we need to do complex action. Just example.
        guest.wait(EC.visibilityOf(guest.$('.central-content')), 10000, 'Guest should be dropped to the page');
    }

}

//Then in your tests
let guestHomePage = HomePage.getPageFor(guest);
guestHomePage.login();
let adminHomePage = HomePage.getPageFor(admin);
adminHomePage.acceptGuest();
adminHomePage.openProfileMenu();
guestHomePage.openProfileMenu();
2
Lajos Veres On

Maybe you could write few functions to make the the browser registration/start/switch smoother. (Basically it is your first option with some support.)

For example:

var browserRegistry = [];

function openNewBrowser(){
  if(typeof browserRegistry[0] == 'undefined'){
    browseRegistry[0] = {
      browser: browser,
      element: element,
      $: $,
      $$: $$,
      ... whatever else you need.
    }
  }
  var tmp = browser.forkNewDriverInstance();
  var id = browserRegistry.length;
  browseRegistry[id] = {
      browser: tmp,
      element: tmp.element,
      $: tmp.$,
      $$: tmp.$$,
      ... whatever else you need.
  }
  switchToBrowserContext(id);
  return id;
}
function switchToBrowserContext(id){
  browser=browseRegistry[id].browser;
  element=browseRegistry[id].element;
  $=browseRegistry[id].$;
  $$=browseRegistry[id].$$;
}

And you use it this way in your example:

describe("Login functionality", function () {
    var scope = {};

    beforeEach(function () {
        browser.get("/#login");
        scope.page1 = new LoginPage();
        openNewBrowser();
        browser.get("/#login");
        scope.page2 = new LoginPage();
    });

    it("should warn there is an opened session", function () {
        scope.page1.username.clear();
        scope.page1.username.sendKeys(login);
        scope.page1.password.sendKeys(password);
        scope.page1.loginButton.click();

        scope.page2.username.clear();
        scope.page2.username.sendKeys(login);
        scope.page2.password.sendKeys(password);
        scope.page2.loginButton.click();    
    });
}); 

So you can leave your page objects as they are.

To be honest I think your second approach is cleaner... Using global variables can bite back later. But if you don't want to change your POs, this can also work.

(I did not test it... sorry for the likely typos/errors.) (You can place the support functions to your protractor conf's onprepare section for example.)