2.3.0 • Published 2 days ago

@hammzj/cypress-page-object v2.3.0

Weekly downloads
-
License
MIT
Repository
github
Last release
2 days ago

cypress-page-object

A set of template classes and guides to help with developing component and page objects in Cypress.

import {
    ElementCollection, //The generic class for defining element selectors
    ComponentObject, //Represents individual components made of element selectors and nested component objects
    PageObject, //Represents a webpage and its collection of both element selectors and component objects
} from "@hammzj/cypress-page-object";

class FooterObject extends ComponentObject {
    public elements: Elements;

    constructor() {
        super(() => cy.get(`footer`));
        this.addElements = {
            copyright: () => this.container().find(`p.MuiTypography-root`),
        };
    }
}

class ExamplePageObject extends PageObject {
    public elements: Elements;
    public components: NestedComponents;

    constructor() {
        super();
        this.addElements = {
            appBar: () => cy.get(`.MuiAppBar-root`),
            appLink: (label: title) => this.elements.appBar().contains("a.MuiLink-root", label),
        };
        this.addNestedComponents = {
            FooterObject: (fn) => this.performWithin(this.container(), new FooterObject(), fn),
        }
    }
}

const examplePageObject = new ExamplePageObject();
examplePageObject.elements.appBar().should("exist");
examplePageObject.elements.appLink("Features").should("exist");
examplePageObject.components.FooterObject((footerObject) => {
    footerObject.elements.copyright().should("have.text", "Copyright @2024");
});

Installation

This package is hosted on both the npmjs registry and the GitHub npm package repository.

If installing from GitHub, please add or update your .npmrc file with the following:

@hammzj:registry=https://npm.pkg.github.com

Then, install as normal:

npm install @hammzj/cypress-page-object
#or
yarn add @hammzj/cypress-page-object

The base class: ElementCollection

Note: All examples below have selectors for MaterialUI root classes.

An "element collection" is what the name implies: it is a collection of element selectors, class properties, utility functions, and application actions, that define how Cypress can interact with a component or page in an application.

Each instance is located by its base container

Every element collection should be referenced by a base container function that is totally unique. This means that it should only find a single type of component on the page, so the base container function should be as strong as possible to limit it to what you want!

const { ElementCollection } = require("@hammzj/cypress-page-object");

class AddressForm extends ElementCollection {
    constructor() {
        //This is the base container function for an "address" form
        super(() => cy.get("form#address"));
    }
}

class SearchForm extends ElementCollection {
    constructor() {
        //This is the base container function for a "search" form
        //It is different from the above because using just cy.get(`form`) would also return the address form! Not good!
        super(() => cy.get("form#search-for-location"));
    }
}

Note: If a base container function is not supplied, the scope is rendered to the entire HTML document.

Place element selectors in an elements object

Base elements are locators for HTML elements on the webpage. They should exist as chained from the base container, or another element selector in the collection.

These are defined in this.elements. Add new element selectors in the constructor by calling the "set" method, this.addElements. This allows classes to inherit other elements from their base class when extending them.

You can also extend the original elements with this.elements = { ...this.elements, ... } or use Object.assign(this.elements, { ... }) inside the class constructor, but this might produce warnings with using this.elements before initialization.

Note: this.elements defaults to being a protected method so that elements are only used in app action functions! If you want to access elements outside of app action, add public elements: Elements to your class.

class NewUserForm extends ElementCollection {
    constructor() {
        //This is the base container function for the address form
        super(() => cy.get("form#new-user"));
        this.addElements = {
            //An element selector chained from another element selector -- selects the first found "input"
            usernameField: () => this.container().find(`input`).first(),
            passwordField: () => this.elements.usernameField().next(),
            //Some selectors can return many elements at once!
            fieldErrors: () => {
                //Assumes that multiple field errors can be present on submission, so it has the possiblity to return many elements!
                //For example, you can use this.fieldErrors.eq(i) to find a single instance of the error.
                //@see https://docs.cypress.io/api/commands/eq

                return this.container().find(`div.error`);
            },
        };
    }
}

These might produce warnings!

Using spread syntax

class NewUserForm extends ElementCollection {
    constructor() {
        //This is the base container function for the address form
        super(() => cy.get("form#new-user"));
    }

    public elements = {
        ...this.elements,
        //An element selector chained from another element selector -- selects the first found "input"
        usernameField: () => this.container().find(`input`).first(),
        passwordField: () => this.elements.usernameField().next(),
        //Some selectors can return many elements at once!
        fieldErrors: () => {
            //Assumes that multiple field errors can be present on submission, so it has the possiblity to return many elements!
            //For example, you can use this.fieldErrors.eq(i) to find a single instance of the error.
            //@see https://docs.cypress.io/api/commands/eq

            return this.container().find(`div.error`);
        },
    };
}

Using Object.assign()

class NewUserForm extends ElementCollection {
    constructor() {
        //This is the base container function for the address form
        super(() => cy.get("form#new-user"));
        Object.assign(this.elements, {
            //An element selector chained from another element selector -- selects the first found "input"
            usernameField: () => this.container().find(`input`).first(),
            passwordField: () => this.elements.usernameField().next(),
            //Some selectors can return many elements at once!
            fieldErrors: () => {
                //Assumes that multiple field errors can be present on submission, so it has the possiblity to return many elements!
                //For example, you can use this.fieldErrors.eq(i) to find a single instance of the error.
                //@see https://docs.cypress.io/api/commands/eq

                return this.container().find(`div.error`);
            },
        });
    }
}

Find specific element selectors with a parameterized function

If multiple element selectors exist, you can limit the scope using parameters.

For example, finding a radio button in a list of selections:

class SelectAnShippingOptionObject extends ElementCollection {
    constructor() {
        super(() => cy.get(`form#select-a-shipping-partner`));
        this.addElements = {
            //Finds the radio button based on its text
            radioButton: (text) => this.container().contains(`button[type="radio"]`, text),
        };
    }
}

Specify scope to exist within the base object using nested component objects

Nested components that have their own element selectors and app actions can exist on a parent component. There is a special function notation that allows us to work with it, either using a utility function or Cypress' .within command chained off of a base element.

See more here: WORKING_WITH_NESTED_OBJECTS.md.

Perform application actions as functions

App action functions allow you to execute flows and perform validations.

const { ComponentObject } = require("@hammzj/cypress-page-object");

class SearchForm extends ComponentObject {
    constructor() {
        super(() => cy.get(`form#location-search-form`));
        this.addElements = {
            inputField: () => this.container().find(`input[type="text"]`),
            submitButton: () => this.container().find(`button[type="submit"]`),
        };
    }

    //An app action to search for text using the form
    search(text, submit = true) {
        this.elements.inputField().type(text);
        if (submit === true) {
            this.elements.submitButton().click();
        }
    }
}

//...
const searchForm = new SearchForm();
searchForm.search("events happening in New York City");

The ComponentObject class

This class is a basic extension of an ElementCollection. It defines a web component and the elements that exist within it.

Useful hints

Updating the base container function to use a parameterized component object

this.updateBaseContainerFunction allows you to conditionally update the base container function, either based on a parameterized constructor, or elsewhere in your test framework:

const { ComponentObject } = require("@hammzj/cypress-page-object");

class PaymentTypeButton extends ComponentObject {
    //Select only the button with the specified label text
    #BASE_CONTAINER_SELECTOR = 'button[id="payment-type"]';

    constructor(buttonText) {
        super(() => cy.get(this.#BASE_CONTAINER_SELECTOR));
        //Conditionally update the base container function to find the button based on its text
        if (buttonText) {
            this.updateBaseContainerFunction = (origFn) => {
                //Make sure the base container function references the base container again!
                return origFn.contains("span", buttonText).parents(this.#BASE_CONTAINER_SELECTOR).first();
            };
        }
    }
}

Before calling super in the constructor, you can set the contents of the base container function, and then pass it in to super:

const { ComponentObject } = require("@hammzj/cypress-page-object");

//Select only the button with the specified label text
class PaymentTypeButton extends ComponentObject {
    #BASE_CONTAINER_SELECTOR = 'button[id="payment-type"]';

    constructor(buttonText) {
        let baseContainerFn = () => cy.get(this.#BASE_CONTAINER_SELECTOR);
        //Only change the function if the constructor has a parameter
        if (buttonText != null) {
            //Make sure the base container function references the base container again!
            baseContainerFn = () => {
                return baseContainerFn().contains("span", buttonText).parents(this.#BASE_CONTAINER_SELECTOR);
            };
        }

        //Then pass it to super
        super(baseContainerFn);
    }
}

The PageObject class

A page object represents an entire page of an application, which can consist of page metadata, element selectors, nested components, and their own app actions and assertions that can utilize multiple nested components at once. They also have their own url paths that can be set and defined. URLs with replaceable path variables are also allowed, and functions exist to assist with constructing them.

Using a custom URL path

When supplying a URL path with variables to a PageObject constructor, make sure the path variables are written as /:STRING_TO_REPLACE. Then, you can call .url(...pathInputs) to create a usable URL! However, make sure you supply exactly every input that needs substitution.

Example 1: a path with variables to replace

const { PageObject } = require("@hammzj/cypress-page-object");

class UserPostsPage extends PageObject {
    constructor() {
        super({ path: `/user/:userId/post/:postId`, baseUrl: `http://localhost:3000` });
    }
}

const userPostsPage = new UserPostsPage();
userPostsPage.url("1234", "post-9876"); //=> "http://localhost:3000/user/1234/post/post-9876"

Example 2: a path without variables

const { PageObject } = require("@hammzj/cypress-page-object");

//Cypress.config().baseUrl = `http://localhost:3000`;

class PrivacySettingsPage extends PageObject {
    constructor() {
        super({ path: `/settings/privacy` });
    }
}

const privacySettingsPage = new PrivacySettingsPage();
//Works, but will log an error to the console since there are no variables, or not enough variables, to replace
privacySettingsPage.url("1234"); //=> "http://localhost:3000/settings/privacy"

Development

Installation

  • Run npm install or yarn
  • Run the script, dev:postinstall to install git hooks

Example tests

Examples of using page objects and component objects can be found in /tests/cypress/e2e. The spec contains many guidelines and different ways for how you can create meaningful test classes for PageObject and ComponentObject types. It is directly run in Cypress to see how it works in action.

The tests use a bundled example website built with React, MaterialUI, and Gatsby, that must have its own dependencies installed. To be able to run the example tests, do the following:

# Install Cypress
yarn dev:cypress:install

# Installs website dependencies under /tests/example_website
yarn dev:install-example-website

# Website is served at "http://localhost:8000/"
# This must stay active while testing is being performed!
yarn dev:start-example-website

# Opens Cypress: you can now view the tests in the testrunner!
yarn test:cypress:open:e2e

# After testing, end the server with "Ctrl + C"

TypeScript support

All typings are included in /src/types, but class types are found in their corresponding source file like ElementCollection in /src/element.collection.

Notes

Cypress advises using App Actions, but in my time working with Cypress, I've found app actions can actually be used within page objects! Actions that occur within a page can be contained in the PageObject class, and actions that navigate through multiple PageObject or ComponentObject instances can exist as organized helper functions within your application. However, the code styling and preference is up to you!

2.3.0

2 days ago

2.2.0

19 days ago

2.1.2

23 days ago

2.1.1

30 days ago

2.1.0-hotfix-1

1 month ago

2.1.0

1 month ago

2.0.3

1 month ago

2.0.4

1 month ago

2.0.2

1 month ago

2.0.1

1 month ago

2.0.0

1 month ago