Thursday, March 20, 2014

Testing Polymers with Page Objects (which I hate)


I hate everything about Page Objects. So surely I can be an impartial evaluator at applying them to testing Polymer, right?

OK, I don't hate everything about Page Objects. I loathe code reuse and indirection in tests. I am a testing fiend, but would rather let my code go untested than introduce indirection into my tests. I cannot count the number of hours that I have traced through other coder's helpers, behaves-likes, and attempts at DRYing up tests. I am decidedly of the opinion that test code should be un-DRY, procedural and entirely contained in the test or associated setup block. Simply put, debugging test helpers drives me absolutely nuts.

So why even consider Page Objects, which expose page- or component-level interaction methods? Mostly because I wound up introducing a helper for selecting items from a Polymer element's <select> last night:
  describe('adding a whole topping', function(){
    it('updates the pizza state accordingly', function(){
      var toppings = el.$.wholeToppings,
          select = toppings.$.ingredients,
          button = toppings.$.add;

      helpers.selectOption(select, 'green peppers');
      button.click();
      // The expectations are set later...
    });
  });
I need that helper because Polymer elements need actual native events to trigger bound variable updates:
var helpers = {
  selectOption: function(el, v) {
    var index = -1;
    for (var i=0; i<el.length; i++) {
      if (el.options[i].value == v) index = i;
    }
    el.selectedIndex = index;
    var event = document.createEvent('Event');
    event.initEvent('change', true, true);
    el.dispatchEvent(event);
  }
};
That code was sufficiently ugly enough to compel me, an test indirection hater, to introduce indirection into my test. And really, how much worse could Page Objects be?

I think it makes sense to leave the Polymer element creation to the test setup, so I define the Page Object constructor as accepting the element in question:
function XPizzaComponent(el) {
  this.el = el;
}
So the setup code is then responsible for adding the necessary <x-pizza> element (which builds custom pizzas) to the page for testing and then instantiating the XPizzaComponent object:
describe('', function(){
  var container, xPizza;

  beforeEach(function(){
    container = document.createElement("div");
    var el = document.createElement("x-pizza");
    container.appendChild(el);
    document.body.appendChild(container);

    xPizza = new XPizzaComponent(el);
    // Additional setup...
  });
  
  // Tests here...
});
Next, I need to define methods for my Page Object that return current information about the element and represent different ways that a user might interact with the element. Of late, I have been adding whole toppings to the pizza:
function XPizzaComponent(el) {
  this.el = el;
}
XPizzaComponent.prototype = {
  addWholeTopping: function(v) {
    var toppings = this.el.$.wholeToppings,
        select = toppings.$.ingredients,
        button = toppings.$.add;

    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();

    return this;
  }
};
That is a less generalized version of last night's helpers.selectOption(). It may be less generalized, but it is far more meaningful in the context of my page / web component. Also YAGNI.

In addition to representing how a user interacts with my Polymer element, I also need to expose methods that can interrogate the element for current state. Of late, I have been working to test the current display state of the pizza, which is a JSON bound variable inside a <pre> tag. So I add a method for that as well to my Page Object:
function XPizzaComponent(el) {
  this.el = el;
}
XPizzaComponent.prototype = {
  addWholeTopping: function(v) { /* ... */ },
  currentPizzaStateDisplay: function() {
    if (this.el.$ === undefined) return '';
    return this.el.$.state.textContent;
  }
};
With that, and with my setup already instantiating this XPizzaComponent Page Object as xPizza, I can rewrite my tests as:
  describe('defaults', function(){
    it('has no toppings anywhere', function() {
      var no_toppings = JSON.stringify({
        firstHalfToppings: [],
        secondHalfToppings: [],
        wholeToppings: []
      });

      expect(xPizza.currentPizzaStateDisplay()).
        toEqual(no_toppings);
    });
  });
I grudgingly admit that this is fairly nice.

I am unsure how best to handle asynchronous code with this approach. The bound variable is not updated immediately after a user selects it from the drop-down. Rather, the browser needs to run through an event loop before Polymer updates bound variables. For now, I leave that in the hands of my tests rather than trying to force it into the Page Object:
  describe('adding a whole topping', function(){
    it('updates the pizza state accordingly', function(){
      xPizza.addWholeTopping('green peppers');

      // Wait a single event loop to allow Polymer to update the element…
      waits(0);
      runs(function(){
        var with_toppings = JSON.stringify({
          firstHalfToppings: [],
          secondHalfToppings: [],
          wholeToppings: ['green peppers']
        });

        expect(xPizza.currentPizzaStateDisplay()).
          toEqual(with_toppings);
      });
    });
  });
That aside, I again grudgingly admit that adding a topping and interrogating the display state are nicer with Page Objects. Darn it.

There may be something to this approach after all. But if anyone tries to clean up my addWholeToppings() method or factor any of this out into a separate library, there's gonna be trouble. Trouble I say!


Day #9

No comments:

Post a Comment