ADOBE COMMERCE

UI Test Automation in Adobe Commerce

Anna Shepilova

Little Robot sitting on a red bench testing the UI of an Adobe Commerce Storefront UI Test Automation in Adobe Commerce

Automatisierte Tests sind ein wichtiger Bestandteil eines jeden Softwareentwicklungszyklus. Beim Testen von UI-Komponenten hat jeder schon einmal unerwartete Fehler nach der Bereitstellung erlebt. Manchmal ist es eine betroffene Funktionalität, die nicht durchdacht wurde, manchmal sind es bestimmte Fälle in einer Datenbank und viele andere mögliche Gründe.

Aus diesem Grund sind automatisierte Tests ein wichtiger Bestandteil jedes Softwareentwicklungszyklus. Beim Testen von UI-Komponenten ist es von großem Vorteil, nach jeder Bereitstellung oder besser noch nach jedem Merge Request (MR) einen Smoke-Test durchzuführen, um sicherzustellen, dass der Grund für ein Problem vorhanden ist. Wenn Sie mit Adobe Commerce (Magento) arbeiten, haben Sie wahrscheinlich schon von dem Magento Functional Testing Framework (MFTF), die auch in den CI/CD-Prozess integriert werden können.

MFTF bietet eine große Liste von Tests, um eine Vielzahl von Einstellungen in verschiedenen Konfigurationen zu überprüfen und auf das Frontend (FE) zu wirken. Leider hat es auch einen Nachteil: Die Tests wurden für Vanilla Magento erstellt und es kann einige Zeit dauern, sie an Ihr Projekt anzupassen, besonders wenn Sie viele Erweiterungen verwenden.

Um also flexibler zu sein und Tests zu haben, die genau zu unseren Projekten passen, haben wir beschlossen, unsere eigenen UI-Tests von Grund auf zu entwickeln.

Architektur

Pytest ist ein Python-Testframework, mit dem sich alle Arten von Tests erstellen lassen, von einfachen bis hin zu komplexen und skalierbaren Testfällen. Pytest kann leicht mit Selenium kombiniert werden, um das Page Object-Muster zu implementieren. Page Object ist ein Entwurfsmuster, das sich in der Testautomatisierung durchgesetzt hat, um die Wartung von Tests zu verbessern und Code-Duplizierung zu reduzieren.

Ein page object ist eine objektorientierte Klasse, die als Schnittstelle zu einer Seite in Ihrer AUT dient. Die Tests verwenden dann die Methoden dieser Seitenobjektklasse, wenn sie mit der Benutzeroberfläche dieser Seite interagieren müssen. Der Vorteil ist, dass bei einer Änderung der Benutzeroberfläche der Seite die Tests selbst nicht geändert werden müssen, sondern nur der Code innerhalb des Seitenobjekts. Alle Änderungen zur Unterstützung der neuen Benutzeroberfläche erfolgen dann an einer Stelle.

Das Page Object bietet die folgenden Vorteile:

Das Muster passt perfekt zu einer Website, die auf Magento basiert, da sie in der Regel hochspezialisierte Seiten mit festen und spezifizierten Elementen hat, z. B. hat eine Produktseite in der Regel einen Preis, eine Menge und eine Schaltfläche "In den Warenkorb", die auf keiner anderen Seite angezeigt werden, andererseits werden E-Mail-/Login- und Passwortfelder nur auf der Login-Seite angezeigt.

Dennoch haben alle Seiten einige gemeinsame Elemente wie Kopfzeile, Fußzeile und Meldungen. Diese gemeinsamen Elemente können in der Hauptklasse Page und seitenbezogene Elemente in geerbten Klassen beschrieben werden.

Bevor wir eine Seitenschnittstelle beschreiben, müssen wir den Webtreiber starten und die Seite aufrufen. Zu diesem Zweck implementieren wir den nächsten Konstruktor und die grundlegenden Methoden, die auf jeder Seite verwendet werden können:

from abc import ABC
from selenium.webdriver.remote.webdriver import WebDriver as RemoteWebDriver

class Page(ABC):
    browser: RemoteWebDriver

    def __init__(self, browser, host, wait_time):
        self.browser = browser
        self.host = host
        self.url = ""
        self.body_class = ""
        self.wait_time = wait_time

    def load(self):
        self.browser.get(self.host + self.url)

    def is_current_page(self):
        if self.body_class != "":
            return self.find_element("body.{}".format(self.body_class))
        else:
            return True

    def find_element(self, selector):
        try:
             return self.browser.find_element_by_css_selector(selector)
        except (NoSuchElementException, ElementNotInteractableException):
            return None

Ein sehr gutes Tutorial zur Erstellung von UI-Tests mit Pytest und Selenium finden Sie hier: Web UI Testing Made Easy with Python, Pytest and Selenium WebDriver - TestProject

In diesem Artikel werde ich nur erklären, wie man den im Tutorial beschriebenen Ansatz auf die Bedürfnisse von Magento-Anwendungen anwendet. In kurzen Worten hier "Browser" - ist ein Objekt von WebDriver (wir können verschiedene WebDriver unterstützen und unsere Tests auf verschiedenen Browsern jedes Mal ausführen, die in der json-Datei eingerichtet sind).

Wir implementieren auch die am häufigsten verwendete Methode, die prüft, ob ein Element auf der aktuellen Seite angezeigt wird:

from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.wait import WebDriverWait

def is_displayed(element, wait_time):
    try:
        WebDriverWait(conftest.browser, timeout=wait_time).until(EC.visibility_of(element))
        return True

    except (AttributeError, StaleElementReferenceException, TimeoutException):
        return False

Nun können wir mit der Implementierung des Page Object Patterns beginnen und zunächst eine einfache Frontend-Seite mit Elementen beschreiben, die jede Magento-Seite enthält oder enthalten kann, z.B. Header, Footer, Erfolgs- und Fehlermeldungen:

class FePage(Page):
    @property
    def header(self):
        return self.find_element(".page-header")
    @property
    def footer(self):
        return self.find_element(".page-footer")

    @property
    def success_message(self):
        return self.find_element("#maincontent > div.page.messages div.message-success")

    @property
    def error_message(self):
        return self.find_element("#maincontent > div.page.messages div.message-error")

Jetzt können wir immer überprüfen, ob die Kopf- und Fußzeile auf der Seite vorhanden sind. Die essayistischen Tests für eine Homepage sind:

from common.pages import FePage
from common.pages import is_displayed


def test_home_page_header(browser, config_host, wait_time):
    home_page = FePage(browser, config_host, wait_time)
    home_page.load()
    assert is_displayed(home_page.cms_home_block, wait_time), ".cms_home_block not found"


def test_home_page_footer(browser, config_host, wait_time):
    home_page = FePage(browser, config_host, wait_time)
    home_page.load()
    assert is_displayed(home_page.footer, wait_time), "Footer is not found"

Der nächste Schritt besteht darin, andere Seiten und ihre spezifischen Eigenschaften und Methoden zu beschreiben. Wenn wir zum Beispiel über die Login-Seite sprechen, sind die Felder Benutzername/E-Mail und Passwort sowie die Login-Schaltfläche nur für diese Seite spezifisch:

class LoginPage(FePage):
    def __init__(self, browser, host, wait_time):
        self.browser = browser
        self.host = host
        self.url = LOGIN_URL
        self.body_class = "customer-account-login"
        self.wait_time = wait_time

    @property
    def email_field(self):
        return self.find_element("#email")

    @property
    def pass_field(self):
        return self.find_element("#pass")

    @property
    def login_button(self):
        return self.find_element("button.action.login")

    @property
    def remind_link(self):
        return self.find_element(".remind")

Auch die Aktionsanmeldung kann nur auf dieser Seite durchgeführt werden, da diese Methode ein Teil der Seite ist:

def fill_and_submit_login_form(self, user_email, user_pass):

        try:
            self.email_field.clear()
            self.email_field.send_keys(user_email)
            self.pass_field.send_keys(user_pass)
            self.login_button.click()
            return True

        except (NoSuchElementException, ElementNotInteractableException, AttributeError):
            return False

Nun können wir die nächsten Tests implementieren, um die Elemente auf der Anmeldeseite und ihre Funktionalität zu überprüfen:

from common.pages import LoginPage
from common.pages import is_displayed


def test_login_page_email_field(browser, config_host, wait_time):
    login_page = LoginPage(browser, config_host, wait_time)
    login_page.load()
    assert login_page.email_field is not None, "Email field not found"

def test_login_page_password_field(browser, config_host, wait_time):
    login_page = LoginPage(browser, config_host, wait_time)
    login_page.load()
    assert login_page.pass_field is not None, "Password field not found"

def test_login_page_login_button(browser, config_host, wait_time):
    login_page = LoginPage(browser, config_host, wait_time)
    login_page.load()
    assert login_page.login_button is not None, "Login button field not found"

def test_login_with_incorrect_password(browser, config_host, registered_user, wait_time):
    login_page = LoginPage(browser, config_host, wait_time)
    login_page.load()
    login_form_submit_success = login_page.fill_and_submit_login_form(registered_user["email"], registered_user["password"] + "make it incorrect")
    assert is_displayed(login_page.error_message, wait_time), "No error message on incorrect password"

Der zum Test übergebene registered_user ist ein Fixture. Mehr darüber können Sie hier lesen: About fixtures — pytest documentation Nehmen wir an, es handelt sich um eine Magie, die vor dem Testlauf einige benötigte Vorgänge durchführt oder bestimmte Werte definiert. Der Punkt ist, dass wir sowohl positive als auch negative Tests erstellen können. In diesem Fall haben wir überprüft, dass bei einem falschen Passwort eine Fehlermeldung angezeigt wird.

Beim nächsten Versuch werden wir die korrekte E-Mail und das korrekte Passwort verwenden und prüfen, ob die Erfolgsmeldung erscheint und das Kundenmenü in der Kopfzeile anstelle des welcome label verfügbar wird:

def test_login_with_correct_password(browser, config_host, registered_user, wait_time):
    login_page = LoginPage(browser, config_host, wait_time)
    login_page.load()

    login_form_submit_success = login_page.fill_and_submit_login_form(registered_user["email"], registered_user["password"])

    assert is_displayed(login_page.success_message, wait_time), "No success message on log in with correct password"
    assert not is_displayed(login_page.error_message, wait_time), "An error message on log in with correct password"
    assert login_page.is_customer_logged_in(), "Customer menu in header after login not found"

Im letzten Beispiel haben wir ein paar erwartete Ergebnisse nach nur einer Aktion. Wenn eine Assert fehlschlägt, wird der Test sofort als fehlgeschlagen und beendet markiert, so dass die darauf folgenden Aktionen und Asserts nicht ausgeführt werden. In unserem Beispiel bedeutet dies, dass wir nicht wissen, ob eine Erfolgsmeldung angezeigt wurde, der Benutzer aber angemeldet ist, da die letzte Assert nicht überprüft wird. Um mehrere Bedingungen unabhängig voneinander zu prüfen, verwenden wir ein delayed assert, das es uns ermöglicht, Erwartungen zu sammeln und sie alle auf einmal zu prüfen. Der Test wird also auf diese Weise umstrukturiert:

def test_login_with_correct_password(browser, config_host, registered_user, wait_time):

    login_page = LoginPage(browser, config_host, wait_time)
    login_page.load()

    login_form_submit_success = login_page.fill_and_submit_login_form(registered_user["email"], registered_user["password"])

    expect(is_displayed(login_page.success_message, wait_time), "No success message on log in with correct password")
    expect(not is_displayed(login_page.error_message, wait_time), "An error message on log in with correct password")
    expect(login_page.is_customer_logged_in(), "Customer menu in header after login not found")

    assert_expectations()

Dieser Ansatz ermöglicht es, beliebige seitenbezogene Elemente und Aktionen zu beschreiben und komplexe Workflows und User Journeys zu erstellen.

Mehr über das Page Object Pattern in pytest erfahren Sie hier: Develop Page Object Selenium Tests Using Python - TestProject

Reporting

Nachdem alle Tests abgeschlossen sind, gibt pytest standardmäßig nur ein Fehlerprotokoll aus. Um mehr Informationen über den Testablauf zu erhalten und die Ergebnisse den Managern und Kunden präsentieren zu können, haben wir Allure Reports hinzugefügt. Allure Framework ist ein flexibles, leichtgewichtiges, mehrsprachiges Testreport-Tool, das nicht nur eine sehr übersichtliche Darstellung der Tests in einem übersichtlichen Webreport-Formular anzeigt, sondern es allen am Entwicklungsprozess Beteiligten ermöglicht, ein Maximum an nützlichen Informationen aus der täglichen Ausführung von Tests zu extrahieren.

Standardmäßig zeigt Allure alle Tests mit Ausgabe und Status von pytest an. Durch Hinzufügen einer einzigen Codezeile zu einem @pytest.mark.hookwrapper können wir automatisch alle Daten bei jedem Testabschluss speichern, es ist auch möglich, einen Screenshot zu speichern, selbst wenn die Tests im Headless-Modus laufen. Im nächsten Beispiel werden Screenshot, URL und HTML der Seite gespeichert, wobei der Zustand zum Zeitpunkt des Testendes erhalten bleibt:

@pytest.mark.hookwrapper

def pytest_runtest_makereport(item):
    outcome = yield
    report = outcome.get_result()

    if report.when == 'call':
        request = item.funcargs['request']
        driver = request.getfixturevalue('browser')
        allure.attach(driver.get_screenshot_as_png(), attachment_type=allure.attachment_type.PNG)
        allure.attach(driver.page_source, attachment_type=allure.attachment_type.TEXT)
        allure.attach(driver.current_url, attachment_type=allure.attachment_type.TEXT)

Am Ende erhalten wir den nächsten Bericht mit gespeicherten Daten zu jedem Test, was die Validierung und Fehlersuche bei Tests erleichtert:

Screenshot of the testresults in the Allure Reporting Suite Allure Reporting Suite

Allure bietet Ihnen eine Vielzahl von Optionen zum Sortieren von Testfällen und zum Überprüfen der Zeitleiste. Mit einfachen @allure-Anmerkungen können Sie benutzerfreundliche Testtitel, den Schweregrad, die Aufteilung des Tests auf die einzelnen Schritte und viele andere mögliche Anpassungen festlegen.

Fazit

Wie eingangs beschrieben, wollten wir ein Tool haben, das bei jeder MR prüft, ob sie sich auf die allgemeine Funktionalität des Shops auswirken kann, denn wenn wir mehrere Änderungen gleichzeitig an einem Server vornehmen, ist es manchmal schwer herauszufinden, warum genau eine bestimmte Funktionalität nicht mehr funktioniert. Das ist der Grund dafür:

Photo by Andrea De Santis on Unsplash