0.9.6 • Published 3 years ago

page-o v0.9.6

Weekly downloads
55
License
MIT
Repository
github
Last release
3 years ago

page-object

Page Objects for concise, readable, reusable tests in React projects.

page-o is a small wrapper around @testing-library/react with the goal of making your tests more concise and readable while maintaining the main principles of Testing Library.

A Quick Example

import PageObject from 'page-o';
import MyComponent from './MyComponent.jsx';

describe('My Component', () => {
  let page;

  beforeEach(() => {
    // Create a Page Object with a selector for finding My Component in the DOM.
    // Reusing PageObjects across tests is the true power of `page-o` and we'll
    // show you some strategies for this later.
    page = new PageObject(null, {
      // This selector allows you to find MyComponent.
      // It will be available for use as `page.myComponent`.
      // The `page.myComponent` property has methods for testing
      // existance, visibility, text, value, and performing actions
      // like 'click', 'submit', etc.
      myComponent: '[data-test=myComponent]',
    });

    // Render MyComponent into the DOM.
    page.render(
      <MyComponent data-test="myComponent" />
    );
  });

  afterEach(() => {
    // Clean up the DOM after each test.
    page.destroySandbox();
  });

  it('should have been rendered.', () => {
    // Verify that the component exists in the DOM.
    expect(page.myComponent.exists).toBe(true);
  });

  it('should render the component text.', () => {
    // Your expectations read as sentances making for nice specifications.
    expect(page.myComponent.text).toEqual('Hello World');
  });

  it('should change the message after clicking.', () => {
    // You can easily interact with your component.
    page.myComponent.click();

    expect(page.myComponent.text).toEqual("Don't click so hard!");
  });
});

Why PageObjects?

Much like you centralize your UI logic into reusable components, we find it helpful to centralize our test logic into PageObjects. This makes refactoring easier because component interactions are not spread across multiple tests. Additionally, it makes your tests more readable because complex component interactions are no longer scattered amongst test setup routines, leaving simple, readable it and expect statements.

PageObjects have the benefit of:

  • Making component/page interactions reusable across tests such as between unit and integration tests.
  • Making coponent interactions composable in the same way you compose your components. For example, a PageObject representing MyComponent can be used across all tests that need to ineract with MyComponent.
  • Making tests easier to read by moving complex component interactions out of tests.
  • Making tests easier to read by providing an English like syntax for interacting with components under test.
  • Making refactoring easier by centralizing interactions and page queries.
  • Making component/page setup/destruction easier by providing convenient setup/teardown methods.

You don't need page-o to acheive the above but we find it helps us get up and running quickly with good testing principles with minimal boilerplate.

In fact, we used to create vanilla js objects to do everything page-o does and that worked just fine. After a while though, we noticed a lot of repeated code and boilerplate so we created page-o.

Usage

When using page-o, interactions with a component under test are performed through a PageObject instance. The PageObject exposes methods for finding elements in the DOM and then performing actions against those DOM elements.

Creating an PageObject

let page;

// It is best practice to create PageObjects in a `beforeEach` or `it` method in your tests.
// While this is not technically necessary because PageObjects are stateless, we find
// it a good principle to follow because it ensures a clean slate between tests.
beforeEach(() => {
  page = new PageObject(
    // The first parameter to PageObject is the root DOM node within which you want
    // to search for elements. Most of the time you can pass null/undefined/false, to
    // use the sandbox as your query root. However, we'll come back to some times
    // when passing a root here becomes useful.
    root,

    // The second parameter to PageObject is a list of elements in the DOM you want
    // to query for/interact with.
    {
      // You can use any standard DOM selector to find elements such as class, id or element selectors.
      todoInput: 'input',

      // However, we highly recommend using a dedicated test selector such as
      // `data-test` (ex: `<div data-test="something">`) or `data-testid`.
      saveTodoButton: '[data-test=saveTodoButton]'

      // Dedicated test selectors like this make your tests more resiliant to change
      // by isolating your tests from changes to styling or other markup.
      // We'll discuss this more in detail later or you can do a little reading at:
      // https://kentcdodds.com/blog/making-your-ui-tests-resilient-to-change

      // `@testing-library` suggests selecting elements based on their text value
      // which is also something we support and is described later.
    }
  );
});

Sandbox setup

Once you've created a PageObject, you can use it to setup the sandbox for your test. The sandbox is just a DOM element where components under test are rendered.

// You should setup the sandbox inside of a `beforeEach` or `it` statement so that
// you have a clean sansbox with every test.
beforeEach(() => {
  // To setup the sandbox, simply call `render` with the component
  // you want to render.
  page.render(
    <MyComponent />
  );
});

afterEach(() => {
  // You should also teardown the sandbox after each test.
  page.destroySandbox();
});

Querying your components

Once your sandbox is setup, you can interact with components on the page through the DOM. We use the DOM (as opposed to interacting with the React component instances themselves) because the DOM is how your users interact with your application.

This follows the @testing-library guiding principles. We also stay away from practices like shallow rendering of components because they make tests harder to debug, harder to grock and less resiliant to change.

it('should be visible.', () => {
  // Your page object exposes properties that allow you to ineract with
  // each of the selectors you defined for that page object.
  // For example, `page.myComponent` relates to the `myComponent` selector
  // you passed when you constructed `page` with
  // `new PageObject(root, {myComponent: '[data-test=myComponent]'})`;
  const myComponent = page.myComponent;

  // You can also access any other selectors you've passed to your `PageObject`.
  const input = page.myInput;

  // You can now query things about your component, such as...
  // Does it exist in the DOM:
  expect(myComponent.exists).toBe(true)

  // Determining what text is has rendered:
  expect(myComponent.text).toEqual('Hello World');

  // While our preference is to use `data-test` ids to select elements,
  // selecting by element text is a key guiding principle of
  // `@testing-library` so we support it as well.
  expect(page.buttonLabeled('Click Me').exists).toBe(true);

  // You can find full documentation of the query API below.

  // You can also access the DOM element directly:
  expect(myComponent.element).toEqual( document.querySelector('[data-test=myComponent]') );

  // You can also interact with DOM elements, such as...
  // Clicking on elements.
  myComponent.click();

  // Focusing elements:
  input.focus();

  // Setting their value:
  input.value = 'Foo Bar';

  // Submitting forms:
  page.myForm.submit();

  // Notice that your expectations read like sentances.
  // In this way, your tests become documentation
  // (ie the "specification" in `myComponent.spec`)
  // which follows well with BDD principles.
});

PageObject reuse

The true power of page-o comes from the reuse of your PageObjects and query selectors. As we mentioned earlier, you don't need page-o to encapsulate component interactons but we find it helps reduce a lot of boilerplate.

Reusing selectors

We generally create a *.page-object.js file that sits next to our *.spec.jsx file. From this file we export our reusable PageObject and DOM selectors. However, you could also define and export these from your spec file.

// MyComponent.page-object.js

// The first thing we export is a vanilla js object with our DOM selectors:
export const myComponentSelectors = {
  myComponent: '[data-test=myComponent]',
  nextButton: '[data-test=nextButton]',
};

// This can be good enough for most situations because it allows you quickly create
// PageObject instances in your tests:
beforeEach(() => {
  let page = new PageObject(null, myComponentSelectors);
});

PageObject subclassing

However, we generally also like to export a PageObject subclass that is specific to the component under test:

export class MyComponentPageObject extends PageObject {
  // If you specify a selectors property on your component PageObject,
  // you will not need to to pass that when constructing your component PageObject.
  selectors = myComponentSelectors;
}

// You can now do the following in your test:
beforeEach(() => {
  // This `page` is preconfigured with its selectors and ready
  // to query against the test sandbox.
  let page = new MyComponentPageObject();
});

Component specific interactions

For complex component interacts, you can now also add custom methods to MyComponentPageObject:

// Assuming your component has two inputs with the following `data-test` attributes.
export const myComponentSelectors = {
  name: '[data-test=nameInput]',
  location: '[data-test=locationInput]',
};

// You can now customize your component page object with custom interactions.
export class MyComponentPageObject extends PageObject {
  selectors = myComponentSelectors;

  // Fill all of the inputs elements in the MyComponent form.
  // @param {Object} values - An object with key/value pairs
  //   relating to the inputs in `MyComponent`.
  fillEntireForm(values) {
    for (key in values) {
      // Assuming the keys in the values object passed,
      // match keys in your PageObject selectors,
      // set the value for those inputs to the values passed.
      this[key].value = values[key];
    }
  }

  // Or do some async work...
  doSomethingSlow(done) {
    ...do slow stuff here.
    done();
  }
}

// This keeps your test code nice and readable.
// You can also easily fill out the form in multiple tests.
describe('after filling out the form', () => {
  beforeEach((done) => {
    page.fillEntireForm({
      name: 'Batman',
      location: 'Bat Cave',
    });

    page.doSomethingSlow(done);
  });

  it('should be done', () => {
    // validate
  });
})

PageObject composition

Another great way to reuse your PageObjects between tests is to compose PageObjects together.

Selector composition

One way to do that is to simple compose your selector objects:

import { myComponentSelectors } from '../my-compnent/MyComponent.page-object';

export const myOtherComponentSelectors = {
  // You could add the selectors for MyComponent to this PageObject:
  ...myComponentSelectors,
  // Or you can add specific selectors:
  nameInput: myComponentSelectors.name,
  // Or you can create more specific selectors:
  locationInput: '[data-test=myComponentRoot] ' + myComponentSelectors.location,
};
PageObject composition

You could also compose PageObject classes together.

import { MyComponentPageObject } from '../my-compnent/MyComponent.page-object';

export class MyOtherComponentPageObject extends PageObject {
  selectors = myOtherComponentSelectors;

  // You can easily instantiate PageObjects in other PageObjects.
  get nameInput() {
    const myComponent = new MyComponentPageObject();
    return myComponent.name;

    // Notice that we returned `myComponent.name` as opposed to
    // `myComponent.name.text`. This gives the PageObject user
    // the flexibility to interact with `nameInput` methods
    // like `page.nameInput.exists`.
  }

  // Or provide a getter to do access the MyComponentPageObject.
  get myComponent() {
    return new MyComponentPageObject();
  }

  // to use like this:
  get myLocation() {
    return this.myComponent.location;
  }

  // You can also customize the root DOM element in which a PageObject
  // interacts. This can be very useful when there may be multiple
  // instances of MyComponent on a page.
  get interactWithMyComponentAtIndex(index) {
    const componentRoot = this.someSelector.nth(index).element;
    const component = new MyComponentPageObject( componentRoot );
    return component.doComplexThing();
  }
}

PageObject inheritance

As you might expect, you could also inherit from another PageObject.

import { MyComponentPageObject } from '../my-compnent/MyComponent.page-object';

export class MyOtherComponentPageObject extends MyComponentPageObject {
  // Now this component can do anything MyComponentPageObject can do.
  // You can also override methods to the specific use case of this new component.
}

The API

The page-o API is divided into two peices:

  1. The PageObject API - methods available directly on a PageObject.
  2. The PageSelector API - methods available to objects returned by selector queries.

PageObject

The following API is exposed by PageObject instances:

PageSelector API

After defining the selectors available on a PageObject, you can interact with those selectors by referencing them as properties of your PageObject.

For example:

// given the following DOM...
<div id="sandbox">
  <div data-test="myComponent">
    Hello World
  </div>
</div>

// and the following PageObject...
let page = new PageObject(null, {
  myCommponent: '[data-test=myComponent]',
});

// You can interact with the `data-test=myComponent` div using
// the property `page.myComponent`...
page.myComponent.click();

In the example above, the property myComponent on page is an instance of a PageSelector whose API is as follows.

What's happening under the hood?

More docs coming...

Other useful strategies

barrel page-objects files

test selectors vs. element text

make your tests resiliant.

0.9.4

3 years ago

0.9.3

3 years ago

0.9.6

3 years ago

0.9.5

3 years ago

0.9.0

3 years ago

0.9.2

3 years ago

0.9.1

3 years ago

0.8.7

4 years ago

0.8.6

4 years ago

0.8.5

4 years ago

0.8.4

4 years ago

0.8.3

4 years ago

0.8.2

4 years ago

0.8.1

4 years ago

0.8.0

4 years ago

0.7.4

4 years ago

0.7.2

4 years ago

0.7.1

4 years ago

0.7.0

4 years ago

0.6.2

4 years ago

0.6.1

4 years ago

0.6.0

4 years ago

0.5.0

4 years ago

0.5.1

4 years ago

0.4.0

4 years ago

0.3.2

4 years ago

0.3.1

4 years ago

0.3.0

4 years ago

0.2.0

4 years ago

0.1.4

4 years ago

0.1.3

4 years ago

0.1.2

4 years ago

0.1.1

4 years ago

0.1.0

4 years ago

0.0.2

4 years ago