Browse the Knowledge Hub211 resources
WebdriverIO review: tutorial, pros and cons
WebdriverIO is an open-source Node.js test framework that automates browsers through W3C WebDriver and WebDriver BiDi, native iOS and Android apps through Appium, and desktop apps built on Electron or Tauri. It bundles a test runner, auto-waiting, an assertion library and a large set of services and reporters. It is an OpenJS Foundation project under the MIT licence.
QAble has evaluated this tool and works alongside it. The factual sections are complete; our view is bounded to what we have actually seen rather than a long-term verdict.
- last reviewed
- 13 October 2026
- version covered
- WebdriverIO 10
- licence
- Open source
At a glance
WebdriverIO at a glance
- Vendor
- OpenJS Foundation
- Category
- Web automation, Mobile automation
- Licence
- Open source (MIT)
- Platforms
- Chrome, Firefox, Edge, Safari, Android, iOS
- Languages
- JavaScript, TypeScript
- Integrates with
- Appium, Sauce Labs, BrowserStack, Cucumber, Allure
- First released
- 2015
- Version reviewed
- WebdriverIO 10
Verdict
Our verdict on WebdriverIO
Strengths
- One runner and API for browsers, native iOS and Android via Appium, and Electron or Tauri desktop apps
- Real Chrome, Firefox, Edge and Safari through the W3C protocols each browser vendor implements
- Parallel workers and a --shard flag spread runs across cores and CI machines with no paid service
- Multiremote drives several browsers or devices in one test, which suits chat, calls and two-user flows
- Drivers download automatically, and any Selenium Grid or cloud provider accepts its sessions
- Free under MIT and governed by the OpenJS Foundation rather than a single vendor
Weaknesses
- Version 10 is new and breaks a lot: strict $, Appium 3 only, renamed config keys
- Every command returns a promise; a missing await fails later, somewhere unrelated, or not at all
- Network mocking needs a WebDriver BiDi session, which some cloud grids do not offer
- Many tutorials still show removed APIs, including sync mode and commands deleted in v10
- Breadth means more configuration decisions than Playwright or Cypress before the first useful run
Our take
WebdriverIO earns its place where one JavaScript framework has to cover desktop browsers, native mobile through Appium and desktop apps, with one runner, one assertion library and one reporting pipeline. It also suits teams that want W3C standards, real Safari and the freedom to point the same suite at a Selenium Grid or any cloud provider, without building a Java framework around Selenium. QAble has evaluated it rather than maintained a long-running suite on it, so treat this as a bounded take. Version 10 shipped on 5 October 2026 and changes a lot at once: strict $, Appium 3 only, removed commands and renamed configuration keys. For an existing suite, stay on 9.x until the migration guide has been worked through on a branch, and expect most answers online to describe v9 or earlier for some time.
We have evaluated this tool rather than maintained a long-running suite on it, so this is a bounded view rather than a long-term verdict. The rest of this page is factual.
How it works
What is WebdriverIO and how does it work?
Two W3C protocols, BiDi first
WebdriverIO speaks WebDriver Classic, one HTTP request per command, and WebDriver BiDi, the newer bidirectional protocol that browser vendors are standardising over a WebSocket. Since v9 it tries to open a BiDi session by default; the wdio:enforceWebDriverClassic capability opts out. BiDi is what enables network mocking, browsing contexts as values and fast accessibility-tree lookups, so the same suite can have more features against local Chrome or Firefox than against a remote grid that lacks BiDi. Direct Chrome DevTools Protocol control remains available as an escape hatch: getPuppeteer returns a Puppeteer instance for Chromium, though not for component tests or cloud sessions.
A runner built on worker processes
The wdio testrunner reads wdio.conf, combines the spec list with the capabilities list, and runs each spec file for each capability in its own worker process with its own browser session, up to the maxInstances limit (10 in the generated config). Ten specs across three browsers can mean thirty processes. Parallelism is therefore per file, isolated by process and free. Services hook into the lifecycle to start Appium, a virtual display, a tunnel to a cloud provider or a visual comparison engine, and reporters receive events from every worker. That design is the source of both the ecosystem’s breadth and its configuration weight: most behaviour is a service you add rather than a default you accept.
Lazy elements and auto-waiting
The $ and $$ commands return lazy, chainable elements: nothing is looked up until a command on them is awaited, so a chain of $ calls ending in a click needs a single await. Before actions such as click and setValue, WebdriverIO waits for the element to be displayed and interactable, up to waitforTimeout (5,000 ms by default, 10,000 in the generated config). Matchers such as toBeDisplayed and toHaveText retry until they pass or time out. Two things do not wait: reading a value with isDisplayed or getText returns immediately, so a plain equality check on it is a snapshot, and the WebDriver implicit timeout is 0, so lookups outside these mechanisms fail fast.
Strict selectors and contexts as values in v10
Version 10 changes two long-standing habits. $ is now strict: if a selector matches more than one element it throws StrictSelectorError instead of taking the first, as Playwright locators do; strictSelectors: false in the config or { strict: false } on one query opts out. And in BiDi sessions, tabs, windows and frames are values you hold: browser.url returns the page it opened, browser.newWindow returns the new tab without switching to it, and a context’s frame method returns a frame. switchWindow and switchFrame throw in BiDi sessions. Code written for v9 that relied on ambiguous selectors or on switching will fail loudly, which is the intent.
Appium as a managed service
For native mobile, WebdriverIO is the client and Appium 3 is the server. The appium service starts Appium in a child process before the run and stops it afterwards, skipping the local launch when cloud capabilities are detected. Capabilities name the platform, the Appium driver and the app, and the same runner, reporters, page object pattern and assertions apply. That shared tooling is the main argument for WebdriverIO over running Playwright for web and a separate Appium client for mobile. Version 10 refuses Appium 1 and 2, and Appium-specific capabilities such as deviceName must carry the appium: prefix.
Strengths
What it does well
Web, mobile and desktop from one framework
Browsers through WebDriver, iOS and Android through Appium, and Electron, Tauri and Dioxus apps through dedicated services. One test runner, one assertion library and one reporting setup cover all of them, which matters most to a team that would otherwise maintain two frameworks with two sets of conventions.
Multiremote for multi-user scenarios
Multiremote opens several named sessions, browsers or devices, in one test and lets you command them together or one at a time. A chat, a video call or an approval flow between two roles can be tested as it really happens, which in-browser tools cannot do and most others make awkward. It is meant for these scenarios, not as a way to parallelise a suite.
Parallelism and sharding included
Worker processes run spec files in parallel on one machine, and the --shard option splits the suite across CI machines, for example --shard=2/4 on the second of four jobs. Neither needs a hosted service or an account. specFileRetries reruns a failed spec file, and framework retries cover individual tests.
Grid and cloud portability
Because sessions are standard WebDriver, the same suite runs against local browsers, a Selenium Grid or a cloud provider by changing the connection settings and capabilities. Official services for providers such as Sauce Labs and BrowserStack handle tunnels and job status. This is the practical route to real Safari versions and real phones you do not own.
DevTools dashboard and portable traces
The @wdio/devtools-service adds a live dashboard showing commands, page, console and network while tests run, with single-test reruns. Its trace mode writes a trace.zip per session or per test, with a retain-on-failure policy, which the show-trace player replays offline with DOM time travel, network and console tabs. It closes much of the debugging gap with Playwright’s trace viewer, though it is new and still gaining features.
Component testing in real browsers
The browser runner serves components through Vite and runs Mocha tests inside a real Chrome, Edge, Firefox or Safari, with a subset of the WebdriverIO API for interacting with rendered elements. Testing Library is the recommended way to render React, Preact, Svelte or Vue components. Running unit-level tests in Safari is something few component tools offer.
Tools for coding agents
Version 10 adds wdio session, a CLI that keeps one browser, phone or desktop session alive across shell commands so an agent can explore an app step by step and export the result as a test. browser.act in @wdio/ai-service sends an intent to a model you configure on its first run, records the resulting steps, and replays them as plain commands on later runs without model calls.
Pricing
WebdriverIO pricing and licensing
WebdriverIO is free and open source under the MIT licence, maintained by its community under the OpenJS Foundation and funded through sponsors and donations. There is no paid edition, no hosted dashboard to buy and no feature held back. The costs are the ones you choose around it: CI minutes, a cloud grid or device lab for Safari versions and real phones, model usage if you adopt the AI features, and engineering time for configuration and upgrades.
- Licence: MIT, for the framework and the packages in its main repository on GitHub.
- No tiers: parallel workers, sharding, multiremote, mocking and reporting are all in the open-source packages, with no account or licence key.
- Cloud grids: BrowserStack, Sauce Labs and similar providers are the usual paid component, priced by them, for Safari on macOS versions you do not run and for real iOS and Android devices.
- AI features: browser.act sends steps to a provider you configure, such as Anthropic, OpenAI or a local model through Ollama, so token costs are yours; replays of recorded steps make no model calls.
- Hidden cost: upgrades. Major versions remove commands and rename configuration, and v10 requires Node.js 22.19 or later and Appium 3, so plan engineering time for each major release.
Checked against the vendor’s own pages on 13 October 2026. Pricing changes; confirm on the official site before buying.
Getting started
WebdriverIO tutorial: getting started
Setup, step by step
- Install Node.js 22.19 or later.
- Run npm init wdio@latest . in your project and answer the wizard: a web app, Mocha, TypeScript, the spec reporter.
- Accept the example page objects so you have a working pattern to copy, then open wdio.conf.ts.
- Set baseUrl in wdio.conf.ts so specs can call browser.url with relative paths.
- Run npx wdio run ./wdio.conf.ts and watch Chrome open, run the example login test and close.
Install
npm init wdio@latest .
# Node.js 22.19 or later is required
npx wdio run ./wdio.conf.tsA first example
// test/specs/login.e2e.ts
import { browser, $, expect } from '@wdio/globals'
describe('login', () => {
it('accepts valid credentials', async () => {
await browser.url('https://the-internet.herokuapp.com/login')
// Every command returns a promise: await each one
await $('#username').setValue('tomsmith')
await $('#password').setValue('SuperSecretPassword!')
// click waits for the button to be displayed and interactable
await $('button[type="submit"]').click()
// expect-webdriverio matchers retry until they pass or time out
await expect($('#flash')).toHaveText(
expect.stringContaining('You logged into a secure area!')
)
})
})What trips people up first
A missing await. WebdriverIO commands return promises, so a setValue without await starts the command and moves on, and the test fails a few lines later with an error pointing somewhere else, or passes when it should not. Every command needs await except $ and $$, which are lazy. The related trap is forEach with an async callback, which does not wait at all: loop with for...of over an awaited $$ list instead. TypeScript with a no-floating-promises lint rule catches most of these. Readers following older tutorials meet a third version: sync mode, where no await was needed, was removed years ago, so that code will not run.
In practice
Using WebdriverIO in practice
Structuring specs, page objects and suites
The wizard’s layout is a good default: specs in test/specs, page objects in test/pageobjects, and named suites in wdio.conf.ts so CI can run a subset with --suite. WebdriverIO’s own page object pattern uses getters that return $ elements, which works well because elements are lazy and looked up fresh on every use, so stale references are rare. Under v10’s strict mode each getter must identify exactly one element, which is a useful discipline. Prefer the role selector, which matches the accessible role and name, or a data-testid attribute; the docs rank both well above class or tag selectors. Note that password inputs have no ARIA role, so they need a test id or label.
// test/pageobjects/login.page.ts
import { $, browser } from '@wdio/globals'
class LoginPage {
// Getters return lazy elements, looked up fresh on each use
get email() { return $('role/textbox[name="Email"]') }
get password() { return $('[data-testid="password"]') }
get submit() { return $('role/button[name="Sign in"]') }
get error() { return $('[data-testid="login-error"]') }
async open() {
await browser.url('/login') // relative to baseUrl
}
async login(email: string, password: string) {
await this.email.setValue(email)
await this.password.setValue(password)
await this.submit.click()
}
}
export default new LoginPage()
// test/specs/login.e2e.ts
import { expect } from '@wdio/globals'
import LoginPage from '../pageobjects/login.page.js'
describe('login', () => {
it('rejects a wrong password', async () => {
await LoginPage.open()
await LoginPage.login('[email protected]', 'wrong-password')
await expect(LoginPage.error).toHaveText(expect.stringContaining('Invalid'))
})
})Stubbing APIs with browser.mock
browser.mock intercepts requests matching a URL pattern, optionally filtered by method or headers, and respond replaces the body and status. A pattern without a protocol matches the path on any origin, and a single * matches any characters. Each mock is also a spy, so expect(mock).toBeRequested checks that the application made the call. Mocking requires a WebDriver BiDi session, which you get locally with Chrome, Edge or Firefox and on Selenium Grid 4 or later; check before relying on it with a cloud provider. Keep some journeys unmocked so the stubs cannot drift from the real contract unnoticed.
// test/specs/orders.e2e.ts
import { browser, $, $$, expect } from '@wdio/globals'
describe('orders', () => {
it('renders orders from a stubbed API', async () => {
const orders = await browser.mock('*/api/orders', { method: 'get' })
// fetchResponse: false means the real backend is never called
await orders.respond(
[{ id: 1, total: 42 }, { id: 2, total: 17 }],
{ statusCode: 200, fetchResponse: false }
)
await browser.url('/orders')
// The matcher refetches the list until the size matches or it times out
const rows = await $$('[data-testid="order-row"]')
await expect(rows).toBeElementsArrayOfSize(2)
await expect(orders).toBeRequested()
})
it('shows an error when the API fails', async () => {
const orders = await browser.mock('*/api/orders')
await orders.respond({ message: 'down' }, { statusCode: 500, fetchResponse: false })
await browser.url('/orders')
await expect($('role/alert')).toHaveText(expect.stringContaining('Try again'))
})
})Running in GitHub Actions with sharding
A WebdriverIO pipeline is ordinary Node.js: check out, install, run. Sharding needs no service: a matrix of jobs each passes its own --shard value, and every job runs its share of spec files. On Linux runners v10 starts a virtual display automatically when none exists, using Weston or Xvfb; for browser-only suites, run Chrome headless and set displayServerEnabled: false to skip that overhead. Set outputDir or a JUnit reporter output directory so logs and results land in a folder you can upload when a job fails, and use specFileRetries sparingly, since a retried file can hide a real intermittent bug.
# .github/workflows/e2e.yml
name: e2e
on: [push, pull_request]
jobs:
wdio:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
# Each job runs a quarter of the spec files
- run: npx wdio run ./wdio.conf.ts --shard=${{ matrix.shard }}/${{ strategy.job-total }}
- uses: actions/upload-artifact@v7
if: failure()
with:
name: wdio-results-${{ matrix.shard }}
path: |
logs
reportsAdding native mobile with the Appium service
Keep mobile in its own config file that extends the web one, so the shared settings, reporters and hooks stay in one place. Install the service with Appium 3 (npm install @wdio/appium-service appium@^3 --save-dev), install the platform driver once with npx appium driver install uiautomator2 for Android or xcuitest for iOS, and let the service start Appium on port 4723. Use accessibility ids for selectors in native contexts, since the role selector is not available there. Expect the first week to go on device setup rather than tests: emulators, SDK paths and signing are the slow part, and npx wdio session doctor android reports missing prerequisites in v10.
// wdio.android.conf.ts
import { config as base } from './wdio.conf.js'
export const config: WebdriverIO.Config = {
...base,
port: 4723,
// Starts Appium 3 in a child process and stops it after the run
services: ['appium'],
specs: ['./test/specs/mobile/**/*.e2e.ts'],
capabilities: [{
platformName: 'Android',
// v10 requires the appium: prefix on Appium capabilities
'appium:automationName': 'UiAutomator2',
'appium:deviceName': 'Android Emulator',
'appium:app': './apps/app-release.apk',
}],
}
// In a mobile spec, select by accessibility id:
// await $('~login-button').click()Debugging failures and planning the v10 upgrade
For local debugging, await browser.debug() pauses the test and opens a REPL against the live browser; raise the framework timeout first or the test runner will fail the paused test. For CI, add the devtools service in trace mode with traceGranularity set to test and tracePolicy set to retain-on-failure, upload the test-results folder, and open failures with npx show-trace. For an existing v9 suite, upgrade on a branch: move to Node.js 22.19 or later and Appium 3, run the suite once with strictSelectors left on to list every ambiguous selector, then fix them rather than switching strict mode off. The migration guide lists renamed keys such as wdio:specs and cucumberOpts.tags, several of which now throw.
Pitfalls
Common pitfalls
Async callbacks in forEach and other array helpers
Why it happens: Array.prototype.forEach does not wait for the promises its callback returns, so a loop of clicks or setValue calls fires them all at once and the test moves on before any finish.
The fix: Use for...of over an awaited list, for example looping over the result of await $$ on a selector, or Promise.all only where order truly does not matter. In v10 a for...of over an unawaited $$ result throws, which surfaces the mistake.
Asserting on values instead of elements
Why it happens: Reading isDisplayed or getText and comparing it with a plain equality check takes one snapshot, so it fails whenever the page has not caught up yet. It is the most common source of flakiness in WebdriverIO suites.
The fix: Pass the element to expect and use the WebdriverIO matchers, such as toBeDisplayed or toHaveText, which retry until they pass. The best-practice guide also warns against adding waitForDisplayed before an assertion that already waits.
browser.pause to fix a race
Why it happens: A fixed pause hides the race on a fast machine and fails on a slow one, while adding dead time to every run. The official best-practice guide says not to use it.
The fix: Wait for the specific condition: waitForEnabled before clicking a button that starts disabled, an expect matcher on the state you need, or browser.waitUntil with a clear condition and timeout message.
StrictSelectorError everywhere after upgrading to v10
Why it happens: v10 makes $ throw when a selector matches more than one element. Suites that relied on the old behaviour of taking the first match were silently ambiguous and now fail.
The fix: Treat each error as a finding: narrow the selector, scope it from a parent, or use a role selector with a name. Use { strict: false } only where several matches are genuinely expected, and avoid turning strict mode off project-wide.
Mocks that work locally and do nothing in the cloud
Why it happens: browser.mock depends on WebDriver BiDi. Local Chrome, Edge and Firefox support it, but a remote grid or cloud provider without BiDi, or a capability that enforces Classic, does not.
The fix: Confirm BiDi support with your provider before building on mocks, keep mocked specs in a suite you can run where BiDi exists, and do not set wdio:enforceWebDriverClassic unless an app requires it, as some Electron setups do.
Focus-dependent tests failing on Linux CI after v10
Why it happens: In v9 each worker ran under its own Xvfb display, so its browser always had focus. v10 shares one virtual display, under which Chrome and Edge windows can lack focus, so focus events and :focus styles stop firing.
The fix: Run browser suites headless with displayServerEnabled: false, or follow the documented workaround of enabling focus emulation through the Chrome DevTools Protocol in a before hook for Chromium browsers.
Compared
How WebdriverIO compares
| Tool | Languages | Protocol | Native mobile | Real Safari | Parallel across machines | Network mocking |
|---|---|---|---|---|---|---|
| WebdriverIO | JavaScript, TypeScript | WebDriver BiDi or Classic | Yes, via Appium service | Yes, via safaridriver | Built-in --shard, free | Built in, needs BiDi |
| Playwright | JS, TS, Python, Java, C# | Own protocol over a WebSocket | No native apps | No, WebKit engine | Built-in sharding, free | Built in |
| Cypress | JavaScript, TypeScript | Runs inside the browser | No | No, experimental WebKit | Via Cypress Cloud | Built in, cy.intercept |
| Selenium | Java, Python, C#, Ruby, JS | WebDriver Classic and BiDi | Via Appium client libraries | Yes, via safaridriver | Via Grid plus framework | BiDi or CDP |
FAQ
Frequently asked questions
What is WebdriverIO?
WebdriverIO is an open-source Node.js test framework that automates browsers through W3C WebDriver and WebDriver BiDi, native iOS and Android apps through Appium, and desktop apps built on Electron or Tauri. It bundles a test runner, auto-waiting, an assertion library and a large set of services and reporters. It is an OpenJS Foundation project under the MIT licence.
Is WebdriverIO free?
Yes. WebdriverIO is open source under the MIT licence and has no paid edition or feature tiers; parallel execution, sharding, mocking, multiremote and reporting are all included. It is maintained by its community under the OpenJS Foundation and funded by sponsors and donations. Your costs are CI time, any cloud grid or device lab you choose, and the engineering time to configure and upgrade it.
Should I choose WebdriverIO or Playwright?
For a web-only JavaScript project, Playwright needs less configuration and has a more established trace viewer, so it is usually the simpler start. WebdriverIO is the stronger option when the same team must automate native iOS or Android apps through Appium alongside the web, needs real Safari rather than WebKit, or wants to run on any WebDriver grid or cloud provider. Its multiremote feature is also a good fit for multi-user flows.
Is WebdriverIO built on Selenium?
No. WebdriverIO is its own implementation of a WebDriver client, written in JavaScript, with its own runner and assertion library. It speaks the same W3C protocols as Selenium, so it can run against a Selenium Grid or any cloud provider that accepts WebDriver sessions, and the browser drivers it uses, such as chromedriver and geckodriver, are the same ones Selenium uses.
Can WebdriverIO test mobile apps?
Yes, through Appium. The appium service starts an Appium 3 server for the run, and capabilities select the platform, the driver, such as UiAutomator2 for Android or XCUITest for iOS, and the app. Native, hybrid and mobile web are all covered with the same runner and assertions as web tests. Version 10 requires Appium 3 and the appium: prefix on Appium-specific capabilities.
Should I upgrade to WebdriverIO 10 now?
For a new project, start on v10, since the migration cost only grows. For an existing suite, plan the upgrade rather than bumping versions: v10 requires Node.js 22.19 or later and Appium 3, makes $ strict, removes commands such as executeAsync and touchAction, and renames several configuration keys, some of which now throw. Work through the official migration guide on a branch and fix strict selector errors instead of disabling them.
Does WebdriverIO still support the Chrome DevTools Protocol?
Not as an automation protocol. Version 9 removed the Puppeteer-based devtools protocol and made WebDriver BiDi the default, and v10 continues that. For Chromium-only needs you can still reach the DevTools Protocol through getPuppeteer, which returns a Puppeteer instance alongside the WebDriver session, after installing puppeteer-core. It is not supported in component tests or cloud sessions.
Does WebdriverIO wait for elements automatically?
Yes, for actions and assertions. Before commands such as click and setValue it waits for the element to be displayed and interactable, up to waitforTimeout, and expect matchers such as toBeDisplayed retry until they pass. Reading a value with isDisplayed or getText does not wait, and the WebDriver implicit timeout is 0, so assert through the matchers rather than comparing raw values.
Alternatives
WebdriverIO alternatives worth weighing
Playwright
Microsoft browser automation with auto-waiting, network interception and parallel execution built in.
Selenium
Open-source browser automation built on the W3C WebDriver standard, with official bindings for Java, Python, C#, Ruby and JavaScript.
Cypress
JavaScript test framework running inside the browser, with a time-travelling debugger that is still the best in the category.
Appium
Open-source automation server that drives iOS, Android, tvOS, Windows and macOS apps through the W3C WebDriver protocol, using separately installed platform drivers.
Resources
Where to go next
Keep going
Prepare, or get it done
Picking the wrong tool costs more than picking late.
QAble advises on toolchain decisions and then implements them, with engineers who have run these tools on real delivery. Start with a free QA audit.