Skip to content

Instantly share code, notes, and snippets.

@ro0gr
Created June 11, 2018 18:53
Show Gist options
  • Save ro0gr/b04d48180aeb99d80d772c132856f093 to your computer and use it in GitHub Desktop.
Save ro0gr/b04d48180aeb99d80d772c132856f093 to your computer and use it in GitHub Desktop.
PO: Quickstart

This is a short guide to get you started writing page objects and using them in your tests.

The Challenge

Suppose we have a simple login form component with the following result markup:

<form>
  <span data-test-error></span>

  <div data-test-username class="has-error">
    <label for="username">Username:</label>
    <input id="username" />
    <span class="error-message"></span>
  </div>

  <div data-test-password class="has-error">
    <label for="password">Password:</label>
    <input id="password" />
    <span class="error-message"></span>
  </div>

  <button [data-test-save]>Save</button>
</form>

First let's take a look on a typical Ember component tests for this form:

test('it requires username and password', async function(assert) {
  await render(hbs`{{login-form}}`);

  await click('[data-test-save]');

  assert.dom('[data-test-username]').hasClass('has-error');
  assert.dom('[data-test-username] .error-message').hasText('Username is required');

  assert.dom('[data-test-password]').hasClass('has-error');
  assert.dom('[data-test-password] .error-message').hasText('Password is required');
});

test('it successfully submits', async function(assert) {
  await render(hbs`{{login-form}}`);

  await fillIn('[data-test-username] input', 'Username')
  await fillIn('[data-test-password] input', 'Password')

  await click('[data-test-save]');

  assert.dom('[data-test-username]').doesNotHaveClass('has-error');
  assert.dom('[data-test-username] .error-message').doesNotExist();

  assert.dom('[data-test-password]').doesNotHaveClass('has-error');
  assert.dom('[data-test-password] .error-message').doesNotExist();
});

That's quite a straightforward and simple test. So why do we even need to use Page Object here?

In the nutshell the issue with this kind of tests is that it heavily relies on css selectors.

For the instance let's take a look on the username field from the testing standpoint:

  • All the username operation are scoped with [data-test-username].
  • In order to check if it's highlighed as an error we check if the has-error class exists.
  • In order to check an error message we access [data-test-username] .error-message.
  • In order to fill in the value we access [data-test-username] input.

The complexity grows for more sophisticated components or more complex components hierarchy. In addition in cases when the implementation or selectors changes you may end up updating all the related tests.

So how can we improve this with Page Object?

Page Object helps you to encapsulate difficulties of the UI layer in a declarative and composable way. Let's try it now!

Modeling Components

First, we have to create a Page Object component:

$ ember generate page-object-component login-form

installing
  create tests/pages/components/login-form.js

Let's describe the login form structure on our new login-form component object:

export const LoginForm = {
  scope: 'form',

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

    errorMessage: {
      scope: '.error-message'
    }
  },

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

    errorMessage: {
      scope: '.error-message'
    }
  },

  saveButton: {
    scope: '[data-test-save]'
  }
};

As you can see Page Object component can be represented a plain javascript object. It can also contain deeply nested components like username or username.errorMessage. The only requirement for component is to have a scope property with a CSS selector value which allows us to map components to the DOM.

Out of the box each component has a set of supplied helpers like isVisible, click, fillIn, text and others. This allows us to do some cool things with out page object right now:

import { create } from 'ember-cli-page-object';
import { LoginForm } from 'my-app/tests/pages/components/login-form';

// before usage page should be created from the definition
const loginForm = create(LoginForm);

test("Compoent built-ins demo", async function() {
  await render(hbs`{{login-form}}`)

  assert.ok(loginForm.isVisible);

  await loginForm.submitButton.click();

  assert.equal(loginForm.username.errorMessage.text, 'Username is required');
})

Great! We've got closer.

Now we have to improve definitions for the username and password in order to be able to check for has-error class existance and filling it in of course.

In advance to built-in component helpers Page Object also provides you with a set of properties which you can use to extend the component functionality. We would use hasClass helper to check if the username is invalid:

import { hasClass } from 'ember-cli-page-object';

export const LoginForm = {
  scope: 'form',

  username: {
    scope: '[data-test-username]'

    // Add `hasError` boolean property
    hasError: hasClass('has-error'),

    errorMessage: {
      scope: '.error-message'
    }
  },

  // ...
}

Now hasError can be used as a username getter:

assert.ok(loginForm.username.hasError);

The last missing part is filling an input with a value.

As mentioned above there is a built-in fillIn component property supplied for each component. Howerver if we call fillIn on the username right now it would fail because the username comforms to the div[data-test-username] which can't be filled in. We need instruct fillIn to deal with an input children node.

import { hasError, fillable } from 'ember-cli-page-object';

export const LoginForm = {
  scope: 'form',

  username: {
    scope: '[data-test-username]'

    hasError: hasClass('has-error'),

    // All the parent scopes are getting prepended to the final selector
    // so the final selector would become "form [data-test-username] input"
    fillIn: fillable('input'),

    errorMessage: {
      scope: '.error-message'
    }
  },

  // ...
}

That's it. Our username is ready to be used in the tests. But what about the password? Should we copy-paste all the username implementation into it?

Well, the only difference between username and password is a scope selector. It could be annoying to repeat the whole field definition accross all the similar fields.

In order to reduce the duplication we can extract a field definition creation to the macros and re-use it for any input field component in our project:

/**
 * Assembles a regular input with a configurable scope
 */
function formInput(scope) {
  return {
    scope,

    fillIn: fillable('input'),

    hasError: hasClass('has-error')

    errorMessage: {
      scope: '.error-message'
    },
  },
}

With this change the final version of our login-form component definition would look like:

import { inputField } from '../macros/input-field';

export const LoginForm = {
  scope: 'form',

  username: inputField('[data-test-username]'),

  password: inputField('[data-test-password]'),

  saveButton: {
    scope: '[data-test-save]'
  }
};

And the test looks like:

import { create } from 'ember-cli-page-object';
import LoginForm from 'frontend/tests/pages/components/login-form';

const form = create(LoginForm);

test('it requires username and password', async function(assert) {
  await render(hbs`{{login-form}}`);

  await form.submitButton.click();

  assert.ok(form.username.hasError);
  assert.equal(form.username.errorMessage.text, 'Username is required');

  assert.ok(form.password.hasError);
  assert.equal(form.password.errorMessage.text, 'Password is required');
});

test('it successfully submits', async function(assert) {
  await render(hbs`{{login-form}}`);

  await form.username.fillIn('Username')
  await form.password.fillIn('Invalid Password')
  await form.submitButton.click();

  assert.notOk(form.username.hasError);
  assert.notOk(form.username.errorMessage.isVisible);

  assert.notOk(form.password.hasError);
  assert.notOk(form.password.errorMessage.isVisible);

  assert.dom('[data-test-error]').doesNotExist();
});

Now all the DOM implementation details of the login-form are abstracted away with a Page Object. It improves test readability and feel safer while refactoring.

Of course we can always go a step further and describe the steps of the test using a higher level of abstraction. For example in our particular case we can make a shorthand for the form submission:

import { inputField } from '../macros/input-field';

export const LoginForm = {
  scope: 'form',

  username: inputField('[data-test-username]'),

  password: inputField('[data-test-password]'),

  saveButton: {
    scope: '[data-test-save]'
  },

  async submit(data = {}) {
    await this.username.fillIn(data.username);
    await this.password.fillIn(data.password);

    await this.saveButton.click();
  }
};

Then

  await form.username.fillIn('Username');
  await form.password.fillIn('Password');
  await form.submitButton.click();

can be re-written as:

  await form.submit({
    username: 'Username',
    password: 'Invalid Password'
  });
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment