Skip to content

Instantly share code, notes, and snippets.

@NullVoxPopuli
Last active January 11, 2020 23:47
Show Gist options
  • Save NullVoxPopuli/245917f5d9b1ca7271b9d1a04ad90378 to your computer and use it in GitHub Desktop.
Save NullVoxPopuli/245917f5d9b1ca7271b9d1a04ad90378 to your computer and use it in GitHub Desktop.
Generic Adaptable Page Objects

Goals:

  • page objects should be cross-ecosystem, cross-technology
  • simple API
  • interop with Ember, and WebDriver (via adapters, which means testing-library would be supported, if anyone wanted to use that).
  • all async/awaitable
  • componentizable -- encourage separation of structure from the actual page object creation.
  • all properties lazily evaluated

Nomenclature:

  • Adapter: Converts meaning in to behavior -- "click" means different things to ember and webdriver
  • Page Object: a literal object that represents how one would interact with the page

Proposed API:

import { setAdapter, create } from 'unknown package';
import { EmberAdapter } from 'unknown package/ember';

setAdapter(EmberAdapter);

const definition = {
  login: {
    username: '[data-test-username]',
    password: '[data-test-password]',
    submit: '[data-test-submit]',
  },

  list: {
    scope: '[data-test-list]',

    rows: collection('[data-test-row]', {
      click: '[data-test-row-clicker]'
    });
  }
};

const page = create(definition);

// Usage
await page.login.username.click();
page.login.username.isFocussed; // true
await page.login.username.fillIn('text'); // username is now "text"
page.login.username.dom // <input type='text' data-test-username ... >

page.login.submit.fillIn('text'); // Error!: buttons are not fillable
await page.login.submit.click();

// for typescript, types are optional
// by default, all methods and properties will be available, and will throw runtime errors
// but the type of the interactable will still auto-complete for all methods and properties.
interface Definition {
  login: {
    submit: ButtonInteractor;
  }
};
const typedPage = create<Definition>(definition);
// by default, every entry in the page object would be of type "OmniInteractor"

Usage with qunit-dom

assert.dom(page.login.username.dom).hasClass(...);
@sukima
Copy link

sukima commented Jan 9, 2020

Separate the Ember-specific bits so the core (and vast majority of the code) lives in a framework-independent library

Using the current page-object API in non-ember projects would be heaven. Currently I have to write my own by hand.

class Page {
  constructor(testContext) {
    this.context = testContext;
  }
  find(selector) {
    return this.context.querySelector(`#my-scope ${selector}`);
  }
  get isVisible() {
    return this.find().style.display !== 'none' && ...;
  }
  doSomething() {
    this.find('button').dispatchEvent(new MouseEvent('click'));
  }
}

Total PITA.

@sukima
Copy link

sukima commented Jan 9, 2020

Another great example of a good use of a framework agnostic API is Sinon. It does this by offering its own suite of assertions and then all you do is link the pass()/fail() to the test framework in question. In this way sinon can express complex assertions otherwise unavailable if it is done by hand. Another example would be chai.

I think @NullVoxPopuli's point is that QUnit's assertion API... ehhemm.... SUCKS compared to things like qunit-dom. It would be wonderful if e-c-p-o exposed a rich assertion API that could tap into QUnit/qunit-dom but keep the behavior expressiveness that Page Objects aspire to be.

@ro0gr
Copy link

ro0gr commented Jan 11, 2020

I'm a big fan of most of the listed suggestions.

page objects should be cross-ecosystem, cross-technology

Yes. Adapters seems to be the most reasonable strategy to achieve this. I've made an attempt to extract Adapters in ember-cli-page-object. I like simpliefied API and possibilities it unlocks. However, in ec-page-object, it's currently possible to perform action against the multiple DOM elements, which makes it pretty hard to integrate adapters in V1 of ec-page-object.

pushAdapter()/popAdapter()

I'm afraid that can lead to an extra layer of indirection and learnability issues. Cause adapters detection can be order dependant.
I think setAdapter is good and clear enough. So in case, if you have a test suite mixed from, let's say, setupTest and moduleFor, you can set your mode explicitly before each test. And if you have a "clean" test suite, you can just do it once in the test-helper.js.

String as a scope

const definition = {
  login: {
    username: '[data-test-username]',
    password: '[data-test-password]',
    submit: '[data-test-submit]',
  },

I've explored this area a bit. I think it's a good idea.
But, in ec-page-object, we also have testContainer, which is also a string. Also we might want to add some string configuration in the future, which would not be a forward compatible feature..
I believe, in order to make it safe, we should change API to isolate all the query options like:

const definition = {
  withAStringScope: '.scope',

  withAScope: {
    scope: '.scope'
  },

  compWithAComplexQuery: {
    scope {
      selector '.scope',
      testContainer: '.testContainer'
    } 
  }
}

I think, with an ability to pass all the query options to the scope object we can safely treat all the string options as a scope.

@ro0gr
Copy link

ro0gr commented Jan 11, 2020

@sukima your sinon framework agnostic idea looks interesting. I think it's worth to be explored.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment