Demo Autotests Project

This is a demo autotest project implemented with pomcorn package for PyPI web site.
To start tests in this project you need to prepare a python virtual environment and install according driver for Chrome browser.

You can get a demo project from package repository.

Setup

Environment

The simplest way to configure a proper Python version and virtual environment is using pyenv and poetry.

  1. Prepare python interpreter:

$ pyenv install 3.12
$ pyenv shell $(pyenv latest 3.12)
  1. Install dependencies

$ pip install -U poetry
$ poetry config virtualenvs.in-project true && poetry install --only main,demo && poetry shell
  1. Install Chrome webdriver.

Running Autotests

To run tests, use invoke command pytest:

$ inv pytest.run

About the project

This project is a mini autotesting system for the PyPI website. It implements the basic structure of pages and tests according to Page Object Model pattern.

The project structure looks like this (__init__.py files were skipped to simplify the structure):

demo/
├── pages/
│   ├── base/
│   │   ├── base_components.py
│   │   └── base_page.py
│   ├── common/
│   │   ├── navigation_bar.py
│   │   └── search.py
│   ├── search_page/
│   │   ├── components/
│   │   │   ├── package_list.py
│   │   │   └── package.py
│   │   └── search_page.py
│   ├── help_page.py
│   ├── index_page.py
│   └── package_details_page.py
├── tests
│   ├── test_logo.py
│   └── test_search.py
└── conftest.py

Pages

This folder contains the page and component classes required to represent PyPI web pages. These classes contain web page interaction logic to make tests free of that implementation.

Base Folder

Basic classes for PyPI pages and components are implemented here.

Base Components
from typing import TypeAlias

from pomcorn import Component

from .base_page import PyPIPage

# In order not to specify generics every time, it's comfy to create your own
# type alias for `Component`.
PyPIComponent: TypeAlias = Component[PyPIPage]
Base Page

Note

The check_page_is_loaded method and the APP_ROOT attribute require special attention here.

from __future__ import annotations

from typing import TYPE_CHECKING

from selenium.webdriver.remote.webdriver import WebDriver

from pomcorn import Page, locators

if TYPE_CHECKING:
    from demo.pages import IndexPage
    from demo.pages.common import Navbar


class PyPIPage(Page):
    """Base representation of the page for PyPI.

    This is the base page for all following pages, so here we have to implement
    only properties and methods common to all pages of application.

    """

    # Be sure to redefine this attribute:
    # specify the base domain of your app here.
    APP_ROOT = "https://pypi.org/"

    def __init__(
        self,
        webdriver: WebDriver,
        *,
        app_root: str | None = None,
        # Next arguments have default values, so you can delete/specify them.
        wait_timeout: int = 5,
        poll_frequency: float = 0.01,
    ):
        super().__init__(
            webdriver,
            app_root=app_root,
            wait_timeout=wait_timeout,
            poll_frequency=poll_frequency,
        )

        # The Logo will be on all the pages of the application so we initialize
        # it in base page class.
        self.logo = self.init_element(
            # The ``locator=`` keyword is optional here, but we recommend using
            # it to be consistent with the method of the same name in
            # ``Component``. Same with ``init_elements``.
            locator=locators.ClassLocator("site-header__logo"),
        )

    # We recommend adding components to the page as properties, because it
    # helps us to run `waits_until_visible` method every time this component is
    # accessed. But if you need to perform some actions before manipulating
    # this component (e.g. clicking, hovering, etc.), it's better to create
    # opening methods on page (like `open_navbar`).
    @property
    def navbar(self) -> Navbar:
        """Get a component for working with the page navigation panel."""
        from demo.pages.common import Navbar

        return Navbar(self)

    # Some pages can be slow to load and cause problems checking for unloaded
    # items. To be sure the page is loaded, this method should return the
    # result of checking for the slowest parts of the page.
    def check_page_is_loaded(self) -> bool:
        """Return the result of checking that the page is loaded.

        Check that `main` tag is displayed.

        """
        # Be careful with the elements you use to check page load. If you only
        # use them to check loading, it's better to initiate them directly in
        # this method. Otherwise, it is better to define them as page
        # properties or initiate them in the `__init__` method above the
        # `super().__init__` call. This is necessary because the
        # `wait_until_loaded` method will be called in `super().__init__`, and
        # it depends on `check_page_is_loaded`.
        return self.init_element(
            locator=locators.TagNameLocator("main"),
        ).is_displayed

    def click_on_logo(self) -> IndexPage:
        """Click on the logo and redirect to `IndexPage`."""
        from demo.pages import IndexPage

        self.logo.click()
        return IndexPage(self.webdriver)

Common

This folder contains components common to multiple pages.

Search component

This class represents a search field that can be placed on multiple pages.

Search field examples

Index page

Search field on PyPI index page

Navbar

Search field on PyPI navigation bar
from __future__ import annotations

from typing import TYPE_CHECKING

from selenium.webdriver.common.keys import Keys

from demo.pages import PyPIComponent
from pomcorn import locators

if TYPE_CHECKING:
    from demo.pages.search_page import SearchPage


class Search(PyPIComponent):
    """Component representing the search input field."""

    # If you are not going to write anything in ``__init__`` and only want
    # to set up ``base_locator``, you can specify it as a class attribute
    base_locator = locators.IdLocator("search")

    def find(self, text: str) -> SearchPage:
        """Paste the text into the search field and send `Enter` key.

        Redirect to `SearchPage` and return its instance.

        """
        from demo.pages.search_page import SearchPage

        self.body.fill(text)
        self.body.send_keys(Keys.ENTER)
        return SearchPage(self.webdriver)

Search Page Folder

Search Page Components

Because a number of additional components needed to be created to implement the search page, a separate folder was created for this page. This is where the page itself and its dependent components are stored.

Package List

This class represents a list of found packages on the PyPI search page.

PyPI Package List
from demo.pages import PyPIPage
from pomcorn import ListComponent, locators

from .package import Package


class PackageList(ListComponent[Package, PyPIPage]):
    """Represent the list of search results on `SearchPage`."""

    # By default `ListComponent` have `item_class` attribute with stored first
    # Generic variable (Package in current case). This attribute is responsible
    # for the class that will be used for list items.

    base_locator = locators.PropertyLocator(
        prop="aria-label",
        value="Search results",
    )

    # Set up ``relative_item_locator`` or ``item_locator`` is required.
    # Use ``relative_item_locator`` - if you want locator nested within
    # ``base_locator``, ``item_locator`` - otherwise."
    # You also may override ``base_item_locator`` property.
    relative_item_locator = locators.ClassLocator(
        class_name="package-snippet",
        container="a",
    )
Package

This class represents one found package listed on the PyPI search page.

PyPI Package
from __future__ import annotations

from typing import TYPE_CHECKING

from demo.pages import PyPIComponent
from pomcorn import locators

if TYPE_CHECKING:
    from demo.pages import PackageDetailsPage


class Package(PyPIComponent):
    """Represent the single search result (package) on `SearchPage`."""

    @property
    def name(self) -> str:
        """Get the package name."""
        return self.init_element(
            relative_locator=locators.ClassLocator("package-snippet__name"),
        ).get_text()

    def open(self) -> PackageDetailsPage:
        """Click on the package and open its details page."""
        from demo.pages import PackageDetailsPage

        # The property `body` is available because the package is descendant of
        # `Component`. It allows us to interact with the body of the component
        # and we can check that the package is clickable.
        self.body.click()
        return PackageDetailsPage(self.webdriver)
Search Page

This class represents the PyPI search page.

PyPI Search Page
from __future__ import annotations

from selenium.webdriver.remote.webdriver import WebDriver

from demo.pages import IndexPage, PyPIPage

from .components import PackageList


class SearchPage(PyPIPage):
    """Representation of the page with search results."""

    @classmethod
    def open(
        cls,
        webdriver: WebDriver,
        *,
        app_root: str | None = None,
    ) -> SearchPage:
        """Open the search page."""
        # Open `IndexPage` and search for an empty query.
        # This will redirect us to the `SearchPage'.
        return IndexPage.open(webdriver).search.find("")

    @property
    def results(self) -> PackageList:
        """Get the component for work with the found packages."""
        return PackageList(self)

Help Page

This class represents the PyPI help page. The title property is implemented here to show how you can use page properties to implement the check_page_is_loaded method.

Help page title
from __future__ import annotations

from selenium.webdriver.remote.webdriver import WebDriver

from demo.pages.base import PyPIPage
from pomcorn import Element, locators


class HelpPage(PyPIPage):
    """Represent the help page."""

    # Define element for title
    title_element = Element(locators.ClassLocator("page-title"))

    @classmethod
    def open(
        cls,
        webdriver: WebDriver,
        *,
        app_root: str | None = None,
    ) -> HelpPage:
        """Open the help page via the index page."""
        from demo.pages.index_page import IndexPage

        # Reusing already implemented methods of opening a page instead of
        # overriding `app_root` allows us to be independent from URK changes:
        # we move from one page to another, interacting with the page as the
        # user does.
        return IndexPage.open(webdriver, app_root=app_root).navbar.open_help()

    def check_page_is_loaded(self) -> bool:
        """Return the check result that the page is loaded.

        Return whether help page title element are displayed or not.

        """
        return self.title_element.is_displayed

Index Page

This class represents the PyPI start page. This page shows the use of the class Search component.

Search field on PyPI index page
from selenium.webdriver.remote.webdriver import WebDriver

from demo.pages.base import PyPIPage
from demo.pages.common import Search


class IndexPage(PyPIPage):
    """Represent the index page."""

    def __init__(
        self,
        webdriver: WebDriver,
        *,
        app_root: str | None = None,
        wait_timeout: int = 5,
        poll_frequency: float = 0.01,
    ):
        super().__init__(
            webdriver,
            app_root=app_root,
            wait_timeout=wait_timeout,
            poll_frequency=poll_frequency,
        )

    @property
    def search(self) -> Search:
        """Get the search component.

        Allows to work with the search field at the center of the page.

        """
        return Search(self)

Package Details Page

This class represents the PyPI package details page.

Package details page
from __future__ import annotations

from selenium.webdriver.remote.webdriver import WebDriver

from demo.pages.base import PyPIPage
from pomcorn import locators


class PackageDetailsPage(PyPIPage):
    """Represent the package details page."""

    @property
    def header(self) -> str:
        """Get the header text."""
        return self.init_element(
            locator=locators.ClassLocator("package-header__name"),
        ).get_text()

    @classmethod
    def open(
        cls,
        webdriver: WebDriver,
        package_name: str,
        *,
        app_root: str | None = None,
    ) -> PackageDetailsPage:
        """Search and open the package details page by its name."""
        from demo.pages import IndexPage

        search_page = IndexPage.open(
            webdriver,
            app_root=app_root,
        ).search.find(package_name)
        return search_page.results.get_item_by_text(package_name).open()

Note

You don’t have to implement the check_page_is_loaded page method if this property is set on the base page and is appropriate for the current page.

Tests

This folder contains autotests that use pages prepared in fixtures to reproduce some scenarios of user interaction with the site.

Conftest

Here are the implemented base fixtures for implemented PyPI pages. This is useful practice to avoid duplicating page opening calls in each test. Also, the webdriver fixture with a given window size (1920×1080) is implemented here.

from selenium import webdriver as selenium_webdriver
from selenium.webdriver.remote.webdriver import WebDriver

import pytest

from demo.pages import HelpPage, IndexPage, SearchPage


# You can implement your own logic to initialize a webdriver.
# An example of Chrome initialization is described below.
@pytest.fixture(scope="session")
def webdriver() -> WebDriver:
    """Initialize `Chrome` webdriver."""
    options = selenium_webdriver.ChromeOptions()

    # Set browser's language to English
    prefs = {"intl.accept_languages": "en,en_U"}
    options.add_experimental_option("prefs", prefs)

    webdriver = selenium_webdriver.Chrome(options)
    webdriver.set_window_size(1920, 1080)
    return webdriver


@pytest.fixture
def index_page(webdriver: WebDriver) -> IndexPage:
    """Open index page of PyPI and return instance of it."""
    return IndexPage.open(webdriver)


@pytest.fixture
def help_page(webdriver: WebDriver) -> HelpPage:
    """Open help page of PyPI and return instance of it."""
    return HelpPage.open(webdriver)


@pytest.fixture
def results_page(webdriver: WebDriver) -> SearchPage:
    """Open search results page of PyPI and return instance of it."""
    return SearchPage.open(webdriver)