Using WebdriverIO v9 for Effective Cross-Platform End-to-End Testing

Preface
In a previous article from the start of this year, I mentioned various frameworks that can be used for mobile test automation. The one that came up repeatedly was WebdriverIO, which I have been using actively for over two years now.
It is a shame that there are no really comprehensive articles on how to use WebdriverIO, especially when it comes to automating complex scenarios such as cross-platform testing of iOS, Android and Web at the same time.
Thanks to Cloudflight's courtesy, I was able to open-source the WebdriverIO sandbox that I created for internal use and knowledge sharing. I will use this as the basis for this article.
https://github.com/cloudflightio/cross-platform-test-framework
The demo setup of the sandbox framework is based on the well-known Wikipedia application.
Getting Started
Want to try out the framework?
The complete setup guide with all prerequisites, installation steps, and your first test run is available in the project README.
Quick start for the impatient
yarn install
yarn run wdio:web:edge
The framework supports Web (Edge), Android, and iOS testing out of the box. Detailed platform-specific setup instructions are in the README.
Deep dive
Why yet another Boilerplate Project?
Before we take a closer look at the features of our sandbox, I would like to briefly explain why I felt the need to create it in the first place.
When you start working with the WebdriverIO, the Boilerplate Projects page is one of the first places you’ll visit. Although there are a plethora of different setups, I couldn’t find anything that suited my personal needs.
You might be wondering what those needs were. Here are the features that I couldn't find in any of the available boilerplate projects:
Standardised, simple selectors handling — consistent patterns that work across all platforms.
The Page Object Model pattern — an industry standard with which every test automation engineer should be familiar. It's effective because it's simple and intuitive.
Simple, maintainable architecture — avoiding over-engineering and façade frameworks (like Cucumber) that add disproportionate complexity without delivering equivalent practical value. In my 6+ years of test automation experience, over-engineered frameworks consistently become maintenance burdens when the original developers leave, often requiring teams to reverse engineer their own codebase.
Write-once-run-everywhere test spec files — we'll explore this concept in detail later in this article.
Simple reporting integrated directly into the boilerplate — no additional setup required.
A true cross-platform example implementation covering all major platforms (Web, Android, and iOS) that works out-of-the-box.
Now that we've outlined these pain points, let's examine the most important ones and see how our framework solves them.
Selectors Handling
The select() Function Overview
The word simple that I'm using here might seem subjective at first glance. Especially, if you take a look at the implementation of the code, where we have the following:
// 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,
}),
It looks anything but simple.
However, when you consider the alternatives—how would you approach this differently?—you can see why this solution is actually elegant and robust.
To illustrate this, here's how you might approach cross-platform selectors in a naive way:
// *************
// 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"]',
},
}
}
With just one selector, this might not seem like much of an improvement, but add a few more selectors and the naive approach becomes ridiculous compared to our standardised way:
// *************
// 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,
}),
And don't even get me started on handling the Android and iOS selection methods—just look at the id:, -ios class chain:, and -ios predicate string: prefixes in the naive approach.
The selectArray() Function Overview
Since we thoroughly covered select(), I don't think we need to delve deeply into selectArray() since it functions similarly. However, it's important to explain why we need it.
The best time to use the selectArray() function is generally when you need to count the number of elements.
Some might argue that it's useful for extracting an array of WebElements. That's a fair point, but I strongly recommend getting familiar with using variables in selectors (covered in the next section). For me, the select() function combined with variables is sufficient 99% of the time.
One more thing: select() and selectArray() are simply facades over regular $ and $$ WebdriverIO’s commands, and since they return the ChainablePromiseElement and ChainablePromiseArray, you can refer to the official WebdriverIO documentation for more information on how to use them in more complex cases:
Selectors with Variables
Sometimes, we want to use selectors with variables. The most common scenario would be an attribute that has a dynamically generated counter (e.g., item-0-dropdown, item-1-dropdown etc.).
Without Variables
Let's start again with the naive, straightforward approach—we might end up with a hardcoded selector like this.
selectors.ts file combined with the select() function for selector management.// *************
// THE NAIVE WAY
// *************
// test/common/selectors.ts - example without variables
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 - example without variables
firstDropdownItem: () => select(selectors.homePage.firstDropdownItem)
secondDropdownItem: () => select(selectors.homePage.secondDropdownItem)
thirdDropdownItem: () => select(selectors.homePage.thirdDropdownItem)
// test/pageobjects/home.page.ts - example code usage
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(`Invalid dropdown item index: ${itemIndex}.`);
}
},
With Variables
The second method allows us to provide dynamic data, and make our tests more robust.
To use variables, create a selector providing the variable in curly braces {{itemIndex}}, then pass the variable to the select() or selectArray() function within your Page Object Model file.
mobileBrowser selector is the same as the web selector you can use just the web one, as it will propagate the value to mobileBrowser as well.// ********************
// 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();
},
Simple & Maintainable Architecture
End-to-end tests are not regular applications, period.
While abstractions, inheritance, SOLID, DRY, and design patterns have their place in test automation, you must adjust your mindset. In most cases, you'll be working with less experienced developers or testers fresh from programming courses who lack battle-tested experience. Over-engineering your test framework with advanced patterns may seem elegant, but it often creates a maintenance nightmare for the very people who need to work with it daily.
If you reach the point where you feel your test scripts need their own tests to keep everything in place—you're doing something wrong.
Overly complex frameworks only work when you have senior test automation engineers on the team—but from an economic perspective, most companies can't justify hiring multiple senior-level testers. Moreover, since senior test automation engineers possess skills comparable to senior software developers, they often transition into software development roles for better career opportunities unless they have a genuine passion for testing.
The solution? Focus on the KISS principle
Keep It Stupidly Simple (KISS)—write code that anyone can work with.
The ideal scenario: your test scripts should be simple enough that new tests can be created through straightforward copy-paste with minimal modifications.
Build from the bottom-up: write the simplest code that works first, then refactor. That's why our framework uses straightforward building blocks:
Page Object Models — encapsulate page interactions and behaviors
Centralized Selectors — single source of truth for element identification
Test Scripts — contain your test logic and assertions
Configuration Files — pre-configured settings that let you start testing immediately
Shared Commands — utility functions you can freely modify depending on your needs, avoiding dependencies on inflexible third-party libraries that require forking to extend
Custom Matchers — extend default WebdriverIO assertions with custom message logging for better reporting and easier debugging
Flows (optional, for larger frameworks) — reusable test sequences (learn more)

Write-Once-Run-Everywhere Test Spec Files
When I first encountered cross-platform automation, I was completely clueless about how it should work. But as I started writing my first test cases, I quickly realized there was potential for standardization.
If your application looks and behaves the same across all platforms, it's super easy to automate using our framework. The main effort is extracting selectors for each platform (DevTools for web, Appium Inspector for mobile). If your application behaves slightly differently but shares most functionality, simple conditional statements handle the variations.
The best part? You can reuse the same test code across all platforms, thanks to WebdriverIO's extreme versatility.
Below is an excerpt from our Wikipedia test example, demonstrating how platform-specific variations are handled while keeping the script readable and contained in a single file:
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');
})
})
Simple Reporting Integrated Directly into the Boilerplate
I don't know about you, but I really dislike having too many choices when setting up a new technology. I want something decided for me, with the flexibility to change it if needed.
The reason? A shorter ramp-up period. When you have a clear picture of what's what, you can learn it quickly and then fine-tune. Simply put—I just want to drive a car, not assemble it from a DIY kit.
That's why our framework has reporting baked in out of the box, with three layers:
Basic logs — WebdriverIO's standard console output
Extended log steps — Custom logs embedded in shared commands that output test steps descriptions to both the console and Allure reports
Allure Report — Post-test HTML report that can be launched locally with
allure serveor hosted (e.g., on GitHub Pages)
Wrapping Up
If you made it this far, thank you for your time. I hope you now have a clearer understanding of what our Test Automation Framework can do for you and how it can speed up your WebdriverIO-based cross-platform end-to-end setup.
If you have any questions, feel free to leave a comment. Contributions are welcome—feel free to open pull requests. While we don't have a formal code of conduct for contributors yet, that's something I plan to add in the future.
Thanks and happy coding!





