Friday, December 19, 2014

On Polymer, Protractor, and Page Objects


Tonight, I continue to abuse Protractor, forcing it to perform end-to-end (e2e) tests against Polymer instead of its preferred AngularJS. Protractor provides some facilities for testing non-Angular applications, but is hampered in testing Polymer elements since the underlying WebDriver does not support the Shadow DOM on which Polymer relies heavily. Even so, it has already provided useful insights into my Polymer element—and made for some pretty tests.

The Polymer element that is being tested remains an early version of the <x-pizza> pizza builder used extensively in Patterns in Polymer:



Last night's test veered a bit away from pretty tests, mostly thanks to a call to Protractor's browser.executeScript(), which I use to workaround WebDriver's lack of shadow DOM support. Said script comes in the form of a brutal string:
describe('<x-pizza>', function(){
  beforeEach(function(){
    browser.get('http://localhost:8000');
    expect($('[unresolved]').waitAbsent()).toBeTruthy();
  });
  it('updates value when internal state changes', function() {
    var selector = 'x-pizza';
    browser.executeScript(
      'var el = document.querySelector("' + selector + '"); ' +
      'var options = el.shadowRoot.querySelectorAll("#firstHalf option"); ' +
      'options[1].selected = true; ' +
      '' +
      'var e = document.createEvent("Event"); ' +
      'e.initEvent("change", true, true); ' +
      'options[1].dispatchEvent(e); ' +
      '' +
      'button = el.shadowRoot.querySelector("#firstHalf button"); ' +
      'button.click(); '
    );
    expect($(selector).getAttribute('value')).
      toMatch('pepperoni');
  });
  // ...
});
The code in there is ugly enough to begin with, but why-oh-why does Protractor force me to build strings like that?!

Er… it doesn't.

It turns out that I am as guilty as anyone else in finding a solution when scrambling to meet a deadline. And I am just as guilty here in never bothering to check the documentation which is perfectly clear. At any rate, I can clean that code up a bit with:
  it('updates value when internal state changes', function() {
    var selector = 'x-pizza';

    browser.executeScript(function(selector){
      var el = document.querySelector(selector);
      var options = el.shadowRoot.querySelectorAll("#firstHalf option");
      options[1].selected = true;

      var e = document.createEvent("Event");
      e.initEvent("change", true, true);
      options[1].dispatchEvent(e);

      button = el.shadowRoot.querySelector("#firstHalf button");
      button.click();
    }, [selector]);

    expect($(selector).getAttribute('value')).
      toMatch('pepperoni');
  });
That is still not pretty, but the ugly is down by an order of magnitude. It is a little annoying supplying the "x-pizza" selector in the list of arguments at the end of the executeScript(), but the real ugly remains in the form of that createEvent() inside the executeScript() that triggers a Polymer-required event.

It sure would be nice if I could click() the <select>, then <click> the correct <option>. Alas, this is WebDriver shadow DOM limitation in action. If I grab the <select> from executeScript(), then try to click it:
  it('updates value when internal state changes', function() {
    var selector = 'x-pizza';
    var toppings = browser.executeScript(function(selector){
      var el = document.querySelector(selector);
      return el.shadowRoot.querySelector('#firstHalf select');
    }, [selector]);

    toppings.then(function(toppings){
      toppings.click();
    });
    // ...
  });
I get ye olde stale element error:
1) <x-pizza> updates value when internal state changes
   Message:
     StaleElementReferenceError: stale element reference: element is not attached to the page document
  (Session info: chrome=39.0.2171.95)
  (Driver info: chromedriver=2.12.301324 (de8ab311bc9374d0ade71f7c167bad61848c7c48),platform=Linux 3.13.0-37-generic x86_64) (WARNING: The server did not provide any stacktrace information)
Command duration or timeout: 10 milliseconds
For documentation on this error, please visit: http://seleniumhq.org/exceptions/stale_element_reference.html
Build info: version: '2.44.0', revision: '76d78cf', time: '2014-10-23 20:02:37'
System info: host: 'serenity', ip: '127.0.0.1', os.name: 'Linux', os.arch: 'amd64', os.version: '3.13.0-37-generic', java.version: '1.7.0_65'
Session ID: c91503b6a80a9931478f6908f80f16d4
Driver info: org.openqa.selenium.chrome.ChromeDriver
Capabilities [{platform=LINUX, acceptSslCerts=true, javascriptEnabled=true, browserName=chrome, chrome={userDataDir=/tmp/.com.google.Chrome.Dfu9DJ}, rotatable=false, locationContextEnabled=true, mobileEmulationEnabled=false, version=39.0.2171.95, takesHeapSnapshot=true, cssSelectorsEnabled=true, databaseEnabled=false, handlesAlerts=true, browserConnectionEnabled=false, webStorageEnabled=true, nativeEvents=true, applicationCacheEnabled=false, takesScreenshot=true}]
The usual state element fixes have no effect since this element is still attached—but attached in the unreachable (for WebDriver) shadow DOM.

So I am stuck with the ugly. Or am I?

Page Objects to the rescue:
  it('updates value when internal state changes', function() {
    new XPizzaComponent().
      addFirstHalfTopping('pepperoni');

    expect($('x-pizza').getAttribute('value')).
      toMatch('pepperoni');
  });
To make that work, I need to define the object in the Protractor onPrepare(), which resides in the configuration file. First, I need to add the object to Protractor's global namespace:
exports.config = {
  seleniumAddress: 'http://localhost:4444/wd/hub',
  specs: ['tests/XPizzaSpec.js'],
  onPrepare: function() {
    browser.ignoreSynchronization = true;

    global.XPizzaComponent = XPizzaComponent;
    function XPizzaComponent() {
      this.selector = 'x-pizza';
    }
  }
};
Then I only need to define the addFirstHalfTopping() method, which mostly comes from the "ugly" test code:
    XPizzaComponent.prototype = {
      addFirstHalfTopping: function(topping) {
        browser.executeScript(function(selector, v){
          var el = document.querySelector(selector);

          var select = el.$.firstHalf.querySelector("select"),
              button = el.$.firstHalf.querySelector("button");

          var index = -1;
          for (var i=0; i<select.length; i++) {
            if (select.options[i].value == v) index = i;
          }
          select.selectedIndex = index;

          var event = document.createEvent('Event');
          event.initEvent('change', true, true);
          select.dispatchEvent(event);

          button.click();
        }, this.selector, topping);
      }
    };
Since I "hide" this ugly in a page object, I have less compunction over using Polymer properties or methods. In this case, I use the dollar sign property to get the container element with the ID of firstHalf.

In the end, this is very similar to the page object work that I did in Karma. Only now I can rely on Protractor's promises to wait for the element's value property to be present instead of the async() callback craziness that was required for Karma. That is an exciting win.

Now the ugly, at least what is left of it, all resides in the Page Object defined in the configuration. I can definitely live with that. More than that—I may be starting to like Protractor as a Polymer testing solution.


Day #29

No comments:

Post a Comment