Vorwort

In einem früheren Artikel vom Anfang dieses Jahres habe ich verschiedene Frameworks erwähnt, die für die mobile Testautomatisierung verwendet werden können. Das, das immer wieder auftauchte, war WebdriverIO, das ich nun seit über zwei Jahren aktiv verwende.

Es ist schade, dass es keine wirklich umfassenden Artikel darüber gibt, wie man WebdriverIO verwendet – insbesondere wenn es um die Automatisierung komplexer Szenarien wie das plattformübergreifende Testen von iOS, Android und Web gleichzeitig geht.

Dank der Freundlichkeit von Cloudflight konnte ich die WebdriverIO-Sandbox, die ich für den internen Gebrauch und den Wissensaustausch erstellt habe, als Open Source veröffentlichen. Ich werde das als Grundlage für diesen Artikel verwenden.

https://github.com/cloudflightio/cross-platform-test-framework

Das Demo-Setup des Sandbox-Frameworks basiert auf der bekannten Wikipedia-Anwendung.

Erste Schritte

Möchtet ihr das Framework ausprobieren?

Die vollständige Einrichtungsanleitung mit allen Voraussetzungen, Installationsschritten und eurem ersten Testlauf ist in der Projekt-README verfügbar.

Schnellstart für Ungeduldige

yarn install
yarn run wdio:web:edge

Das Framework unterstützt Web (Edge), Android und iOS-Tests out-of-the-box. Detaillierte plattformspezifische Einrichtungsanweisungen befinden sich in der README.

Tiefer Einblick

Warum noch ein weiteres Boilerplate-Projekt?

Bevor wir uns die Features unserer Sandbox genauer ansehen, möchte ich kurz erklären, warum ich das Bedürfnis hatte, sie überhaupt zu erstellen.

Wenn ihr mit WebdriverIO anfangt zu arbeiten, ist die Seite mit den Boilerplate-Projekten einer der ersten Orte, die ihr besuchen werdet. Obwohl es eine Fülle verschiedener Setups gibt, konnte ich nichts finden, das meinen persönlichen Bedürfnissen entsprach.

Ihr fragt euch vielleicht, was diese Bedürfnisse waren. Hier sind die Features, die ich in keinem der verfügbaren Boilerplate-Projekte finden konnte:

  • Standardisierte, einfache Selector-Behandlung – konsistente Muster, die plattformübergreifend funktionieren.
  • Das Page Object Model Pattern – ein Industriestandard, mit dem jeder Testautomatisierungsingenieur vertraut sein sollte. Es ist effektiv, weil es einfach und intuitiv ist.
  • Einfache, wartbare Architektur – Over-Engineering und Fassaden-Frameworks (wie Cucumber) vermeiden, die unverhältnismäßige Komplexität hinzufügen, ohne gleichwertigen praktischen Wert zu liefern. In meinen 6+ Jahren Testautomatisierungserfahrung werden übermäßig entwickelte Frameworks konsequent zu Wartungslasten, wenn die ursprünglichen Entwickler gehen und Teams oft gezwungen sind, ihre eigene Codebasis zu reverse-engineeren.
  • Write-once-run-everywhere-Testspezifikationsdateien – dieses Konzept werden wir später in diesem Artikel im Detail erkunden.
  • Einfaches Reporting direkt in das Boilerplate integriert – kein zusätzliches Setup erforderlich.
  • Eine echte plattformübergreifende Beispielimplementierung, die alle wichtigen Plattformen (Web, Android und iOS) abdeckt und out-of-the-box funktioniert.

Nachdem wir diese Schmerzpunkte skizziert haben, lassen wir uns die wichtigsten untersuchen und sehen, wie unser Framework sie löst.

Selector-Behandlung

Überblick über die select()-Funktion

Das Wort „einfach”, das ich hier verwende, mag auf den ersten Blick subjektiv erscheinen. Besonders wenn man sich die Implementierung des Codes ansieht, wo wir Folgendes haben:

// test/common/sharedCommands.ts
export function select(selector: Selector): ChainablePromiseElement {}
export function selectArray(selector: SelectorArray): ChainablePromiseArray {}
// test/common/selectors.ts
export const selectors = {
    homePage: {
        searchField: {
            android: 'org.wikipedia:id/search_container',
            ios: '**/XCUIElementTypeSearchField[`label == "Search Wikipedia"`]',
            web: '(//input[@name="search"])[1]',
            mobileBrowser: '//form[@id="minerva-overlay-search"]//input[@name="search"]',
        },
    }
}
// test/pageobjects/home.page.ts
searchField: () => select({
    ...selectors.homePage.searchField,
    iosSelectionMethod: getByClassChain,
}),

Das sieht alles andere als einfach aus.

Wenn ihr jedoch die Alternativen in Betracht zieht – wie würdet ihr das anders angehen? – könnt ihr sehen, warum diese Lösung tatsächlich elegant und robust ist.

Um das zu veranschaulichen, hier ist, wie ihr plattformübergreifende Selektoren auf naive Weise angehen könntet:

// *************
// THE NAIVE WAY
// *************
// test/pageobjects/home.page.ts - example without selectors handling
searchField: async () => {
    if (browser.isNativeContext) {
        return browser.isAndroid ?
            await browser.$('(//android.widget.TextView[@text="Appium"])[1]') :
            await browser.$('-ios class chain:**/XCUIElementTypeStaticText[`label == "Appium"`][2]');
    } else {
        return browser.isMobile ?
            await browser.$('//input[@name="search"])[1]') :
            await browser.$('//form[@id="minerva-overlay-search"]//input[@name="search"]');
    }
}
// ********************
// THE STANDARDISED WAY
// ********************
// test/pageobjects/home.page.ts
searchField: () => select({
    ...selectors.homePage.searchField,
    iosSelectionMethod: getByClassChain,
}),
// test/common/selectors.ts
export const selectors = {
    homePage: {
        searchField: {
            android: 'org.wikipedia:id/search_container',
            ios: '**/XCUIElementTypeSearchField[`label == "Search Wikipedia"`]',
            web: '(//input[@name="search"])[1]',
            mobileBrowser: '//form[@id="minerva-overlay-search"]//input[@name="search"]',
        },
    }
}

Mit nur einem Selektor mag das keine große Verbesserung erscheinen, aber fügt ein paar weitere Selektoren hinzu, und der naive Ansatz wird im Vergleich zu unserem standardisierten Weg lächerlich:

// *************
// THE NAIVE WAY
// *************
// test/pageobjects/home.page.ts - example without selectors handling
searchField: async () => {
    if (browser.isNativeContext) {
        return browser.isAndroid ?
            await browser.$('org.wikipedia:id/search_container') :
            await browser.$('-ios class chain:**/XCUIElementTypeSearchField[`label == "Search Wikipedia"`]`][2]');
    } else {
        return browser.isMobile ?
            await browser.$('//input[@name="search"])[1]') :
            await browser.$('//form[@id="minerva-overlay-search"]//input[@name="search"]');
    }
},
searchResultItem: async () => {
    if (browser.isNativeContext) {
        return browser.isAndroid ?
            await browser.$('id:org.wikipedia:id/page_list_item_title') :
            await browser.$('-ios predicate string:label == "Appium"');
    } else {
        return browser.isMobile ?
            await browser.$('//li[@title="Appium"]') :
            await browser.$('//li[@title="Appium"]//a');
    }
}
// ********************
// THE STANDARDISED WAY
// ********************
// test/pageobjects/home.page.ts - an actual excerpt from the codebase
searchField: () => select({
    ...selectors.homePage.searchField,
    androidSelectionMethod: getById,
    iosSelectionMethod: getByClassChain,
}),
searchResultItem: () => select({
    ...selectors.homePage.searchResultItem,
    androidSelectionMethod: getById,
    iosSelectionMethod: getByPredicateString,
}),

Und fangen wir gar nicht erst damit an, den Android- und iOS-Auswahlmethoden zu behandeln – schaut euch nur die Präfixe id:, -ios class chain: und -ios predicate string: im naiven Ansatz an.

Überblick über die selectArray()-Funktion

Da wir select() ausführlich behandelt haben, glaube ich nicht, dass wir tief in selectArray() eintauchen müssen, da es ähnlich funktioniert. Es ist jedoch wichtig zu erklären, warum wir es brauchen.

Der beste Zeitpunkt für die Verwendung der selectArray()-Funktion ist im Allgemeinen dann, wenn die Anzahl der Elemente gezählt werden muss.

Manche werden argumentieren, dass es nützlich ist, ein Array von WebElements zu extrahieren. Das ist ein fairer Punkt, aber ich empfehle dringend, sich mit der Verwendung von Variablen in Selektoren vertraut zu machen (im nächsten Abschnitt behandelt). Für mich ist die select()-Funktion kombiniert mit Variablen in 99 % der Fälle ausreichend.

Noch eine Sache: select() und selectArray() sind einfach Fassaden ueber die regulären $- und $$-WebdriverIO-Befehle, und da sie ChainablePromiseElement und ChainablePromiseArray zurückgeben, könnt ihr die offizielle WebdriverIO-Dokumentation für weitere Informationen zur Verwendung in komplexeren Fällen konsultieren: https://webdriver.io/docs/selectors, https://webdriver.io/docs/api/browser/$, https://webdriver.io/docs/api/browser/$$

https://webdriver.io/docs/api/browser/$

https://webdriver.io/docs/api/browser/$$

https://webdriver.io/docs/selectors

Selektoren mit Variablen

Manchmal möchten wir Selektoren mit Variablen verwenden. Das häufigste Szenario wäre ein Attribut mit einem dynamisch generierten Zähler (z. B. item-0-dropdown, item-1-dropdown usw.).

Ohne Variablen

Beginnen wir erneut mit dem naiven, unkomplizierten Ansatz – wir könnten mit einem hartcodierten Selektor wie diesem enden:

💡 Hinweis: Der Einfachheit halber verwende ich den standardisierten Ansatz mit der Datei selectors.ts in Kombination mit der Funktion select() für die Selektorverwaltung.

// *************
// DER NAIVE WEG
// *************

// test/common/selectors.ts – Beispiel ohne Variablen
homePage: {
firstDropdownItem: {
ios: ‘//XCUIElementTypeStaticText[@name, “item-0-dropdown”]’,
android: ‘//android.widget.TextView[@text, “item-0-dropdown”]’,
web: ‘//*[@data-testid=”item-0-dropdown”]’,
mobileBrowser: ‘//*[@data-testid=”item-0-dropdown”]//span’,
},
secondDropdownItem: {
ios: ‘//XCUIElementTypeStaticText[@name, “item-1-dropdown”]’,
android: ‘//android.widget.TextView[@text, “item-1-dropdown”]’,
web: ‘//*[@data-testid=”item-1-dropdown”]’,
mobileBrowser: ‘//*[@data-testid=”item-1-dropdown”]//span’,
},
thirdDropdownItem: {
ios: ‘//XCUIElementTypeStaticText[@name, “item-2-dropdown”]’,
android: ‘//android.widget.TextView[@text, “item-2-dropdown”]’,
web: ‘//*[@data-testid=”item-2-dropdown”]’,
mobileBrowser: ‘//*[@data-testid=”item-2-dropdown”]//span’,
}
}

// test/pageobjects/home.page.ts – Beispiel ohne Variablen
firstDropdownItem: () => select(selectors.homePage.firstDropdownItem)
secondDropdownItem: () => select(selectors.homePage.secondDropdownItem)
thirdDropdownItem: () => select(selectors.homePage.thirdDropdownItem)

// test/pageobjects/home.page.ts – Beispiel zur Verwendung des Codes
async selectDropdownItemByIndex(itemIndex: number): Promise<void> {
switch (itemIndex) {
case 0:
await this.firstDropdownItem().click();
break;
case 1:
await this.secondDropdownItem().click();
break;
case 2:
await this.thirdDropdownItem().click();
break;
default:
throw new Error(`Ungültiger Dropdown-Element-Index: ${itemIndex}.`);
}
},

Mit Variablen

Die zweite Methode ermöglicht es uns, dynamische Daten bereitzustellen und unsere Tests robuster zu machen.

Um Variablen zu verwenden, einen Selektor erstellen, der die Variable in geschweiften Klammern enthält ({{itemIndex}}), und dann die Variable an die select()– oder selectArray()-Funktion in der Page-Object-Model-Datei übergeben.

💡 Hinweis: Wenn der mobileBrowser-Selektor derselbe wie der web-Selektor ist, verwendet ihr einfach den web-Selektor, da er den Wert auch auf mobileBrowser überträgt.

// ********************
// THE STANDARDISED WAY
// ********************
// test/common/selectors.ts - example with variables
homePage: {
    dropdownItem: {
        ios: '//XCUIElementTypeStaticText[@name, "item-{{itemIndex}}-dropdown"]',
        android: '//android.widget.TextView[@text, "item-{{itemIndex}}-dropdown"]', 
        web: '//*[@data-testid="item-{{itemIndex}}-dropdown"]'
    }
}
// test/pageobjects/home.page.ts - example with variables
nthDropdownItem: (dropdownItemIndex: number) =>
    select({
        ...selectors.homePage.dropdownItem,
        variables: { itemIndex: `${dropdownItemIndex}` },
    }),
// test/pageobjects/home.page.ts - example code usage
async selectDropdownItemByIndex(itemIndex: number): Promise<void> {
  await this.nthDropdownItem(itemIndex).click();
},

Einfache und wartbare Architektur

End-to-End-Tests sind keine regulären Anwendungen – Punkt.

Während Abstraktionen, Vererbung, SOLID, DRY und Designmuster ihren Platz in der Testautomatisierung haben, müsst ihr eure Denkweise anpassen. In den meisten Fällen werdet ihr mit weniger erfahrenen Entwicklern oder Testern frisch aus Programmierkursen arbeiten, denen kampferprobte Erfahrung fehlt. Euer Test-Framework mit fortgeschrittenen Mustern zu übertechnisieren mag elegant erscheinen, schafft aber oft einen Wartungsalptraum für genau die Leute, die täglich damit arbeiten müssen.

Wenn ihr an dem Punkt angelangt seid, an dem ihr das Gefühl habt, dass eure Testskripte ihre eigenen Tests brauchen, um alles im Griff zu behalten – macht ihr etwas falsch.

Übermäßig komplexe Frameworks funktionieren nur, wenn ihr Senior-Testautomatisierungsingenieure im Team habt – aber aus wirtschaftlicher Sicht können sich die meisten Unternehmen nicht leisten, mehrere Senior-Tester einzustellen. Da Senior-Testautomatisierungsingenieure zudem Fähigkeiten vergleichbar mit Senior-Softwareentwicklern besitzen, wechseln sie oft in Softwareentwicklungsrollen für bessere Karrieremöglichkeiten, es sei denn, sie haben eine echte Leidenschaft für das Testen.

Die Lösung? Fokus auf das KISS-Prinzip

Keep It Stupidly Simple (KISS) – schreibt Code, mit dem jeder arbeiten kann.

Das ideale Szenario: Eure Testskripte sollten so einfach sein, dass neue Tests durch unkompliziertes Copy-Paste mit minimalen Änderungen erstellt werden können.

Von unten nach oben aufbauen: Zuerst den einfachsten Code schreiben, der funktioniert, dann refaktorisieren. Deshalb verwendet unser Framework unkomplizierte Bausteine:

  • Page Object Models – kapseln Seiteninteraktionen und -verhalten
  • Zentralisierte Selektoren – einzige Quelle der Wahrheit für Element-Identifikation
  • Testskripte – enthalten eure Testlogik und Assertions
  • Konfigurationsdateien – vorkonfigurierte Einstellungen, die euch sofortiges Testen ermöglichen
  • Shared Commands – Utility-Funktionen, die ihr frei nach Bedarf ändern könnt, um Abhängigkeiten von unflexiblen Drittanbieter-Bibliotheken zu vermeiden, die geforkert werden müssen
  • Custom Matchers – Standard-WebdriverIO-Assertions mit benutzerdefiniertem Nachrichten-Logging für besseres Reporting und einfacheres Debugging erweitern
  • Flows (optional, für größere Frameworks) – wiederverwendbare Testsequenzen (mehr erfahren)
Flowchart of a test framework structure for end-to-end testing, showing interaction between Page Object Models, Centralised Selectors, Test Scripts, Framework Basis, Cross-Platform Config for cross-platform testing, Shared Commands, and Custom Matchers using WebdriverIO v9. Blue and yellow boxes with arrows illustrate the connections.

Write-Once-Run-Everywhere-Testspezifikationsdateien

Als ich zum ersten Mal auf plattformübergreifende Automatisierung stieß, hatte ich absolut keine Ahnung, wie es funktionieren sollte. Aber als ich anfing, meine ersten Testfälle zu schreiben, erkannte ich schnell das Potenzial für Standardisierung.

Wenn eure Anwendung auf allen Plattformen gleich aussieht und sich gleich verhält, ist die Automatisierung mit unserem Framework super einfach. Der Hauptaufwand besteht darin, Selektoren für jede Plattform zu extrahieren (DevTools für Web, Appium Inspector für Mobile). Wenn eure Anwendung sich leicht unterschiedlich verhält, aber die meiste Funktionalität teilt, bewältigen einfache bedingte Anweisungen die Variationen.

Das Beste daran? Ihr könnt denselben Testcode auf allen Plattformen wiederverwenden, dank der extremen Vielseitigkeit von WebdriverIO.

Unten ist ein Auszug aus unserem Wikipedia-Testbeispiel, der zeigt, wie plattformspezifische Variationen behandelt werden, während das Skript lesbar bleibt und in einer einzigen Datei enthalten ist:

describe(`Wikipedia`, () => {
  it('Search for an article about "Test"', async () => {
    await addTestId('TEST-1');
    if (!browser.isNativeContext) {
      await homePage.openUrl();
    }
    if (browser.isNativeContext) {
      await homePage.pressSkipButton();
    }
    await homePage.enterTextToSearchField('Test');
    await homePage.pressFirstSearchResultItem();
    await wikipediaGamesModal.closeModal();
    await articlePage.waitForPageLoad();
    const pageTitle = await articlePage.getPageTitle();
    await expect(pageTitle).toEqualString('Test');
    await takeScreenshotWithTitle('Successful test - Page title with the "Test" value');
  })
})

Einfaches Reporting direkt in das Boilerplate integriert

Ich weiß nicht, wie es euch geht, aber ich mag es wirklich nicht, zu viele Auswahlmöglichkeiten beim Einrichten einer neuen Technologie zu haben. Ich möchte, dass etwas für mich entschieden wird, mit der Flexibilität, es bei Bedarf zu ändern.

Der Grund? Eine kürzere Einarbeitungszeit. Wenn ihr ein klares Bild davon habt, was was ist, könnt ihr es schnell erlernen und dann feinjustieren. Einfach gesagt – ich möchte einfach ein Auto fahren, es nicht aus einem Bausatz zusammenbauen.

Deshalb hat unser Framework Reporting out-of-the-box eingebaut, mit drei Ebenen:

  • Grundlegende Logs – WebdriverIOs Standard-Konsolenausgabe
  • Erweiterte Log-Schritte – Benutzerdefinierte Logs, eingebettet in Shared Commands, die Testschrittbeschreibungen sowohl in die Konsole als auch in Allure-Reports ausgeben
  • Allure Report – HTML-Bericht nach dem Test, der lokal mit allure serve gestartet oder gehostet werden kann (z. B. auf GitHub Pages)

Zusammenfassung

Wenn ihr es bis hierher geschafft habt, danke ich euch für eure Zeit. Ich hoffe, ihr habt jetzt ein klareres Verständnis davon, was unser Test-Automation-Framework für euch tun kann und wie es euer WebdriverIO-basiertes plattformübergreifendes End-to-End-Setup beschleunigen kann.

Wenn ihr Fragen habt, hinterlasst gerne einen Kommentar. Beiträge sind willkommen – fühlt euch frei, Pull Requests zu öffnen. Obwohl wir noch keinen formalen Verhaltenskodex für Beitragende haben, plane ich, das in der Zukunft hinzuzufügen.

Danke und frohes Coden!