TL;DR
- WebdriverIO wraps Selenium WebDriver with modern Node.js async/await syntax
- Configuration via
wdio.conf.js— supports Mocha, Jasmine, Cucumber out of the box- Selectors:
$('selector')for single,$$('selector')for multiple elements- Built-in waits, retries, and powerful assertion library
- First-class TypeScript support and excellent VS Code integration
Best for: Node.js teams wanting Selenium-based testing with modern JavaScript Skip if: You prefer Playwright’s speed or Cypress’s debugging experience Reading time: 15 minutes
Your Selenium Java tests work but feel dated. The team knows JavaScript. You want modern async/await, not callback hell. You need TypeScript support without fighting the framework.
WebdriverIO brings Selenium to the Node.js ecosystem properly. Real browser automation, modern syntax, plugin architecture that actually works.
This tutorial covers WebdriverIO from installation to CI/CD — everything to build maintainable browser tests in JavaScript.
What is WebdriverIO?
WebdriverIO (WDIO) is a test automation framework for Node.js. It implements the WebDriver protocol, same as Selenium, but with JavaScript-native design.
Why WebdriverIO:
- Modern JavaScript — async/await everywhere, no callback pyramids
- Protocol flexibility — WebDriver for cross-browser, DevTools for Chrome
- Rich ecosystem — reporters, services, plugins for everything
- Multi-platform — browsers, mobile (Appium), desktop (Electron)
- TypeScript first — full type definitions, autocomplete works
WebdriverIO vs alternatives:
| Feature | WebdriverIO | Playwright | Cypress |
|---|---|---|---|
| Protocol | WebDriver/DevTools | CDP | In-browser |
| Cross-browser | All browsers | Chromium, Firefox, WebKit | Limited |
| Mobile | Via Appium | No | No |
| Parallel | Built-in | Built-in | Paid feature |
| Real browsers | Yes | Patched browsers | Yes |
Installation and Setup
Quick Start
# Create new project
mkdir wdio-tests && cd wdio-tests
npm init -y
# Install WebdriverIO CLI
npm install @wdio/cli --save-dev
# Run configuration wizard
npx wdio config
The wizard asks about:
- Test framework (Mocha, Jasmine, Cucumber)
- Reporter (spec, allure, junit)
- Services (chromedriver, selenium-standalone)
- Base URL and specs location
Configuration File
// wdio.conf.js
export const config = {
runner: 'local',
specs: ['./test/specs/**/*.js'],
exclude: [],
maxInstances: 5,
capabilities: [{
browserName: 'chrome',
'goog:chromeOptions': {
args: ['--headless', '--disable-gpu']
}
}],
logLevel: 'info',
bail: 0,
baseUrl: 'https://example.com',
waitforTimeout: 10000,
connectionRetryTimeout: 120000,
connectionRetryCount: 3,
framework: 'mocha',
reporters: ['spec'],
mochaOpts: {
ui: 'bdd',
timeout: 60000
}
}
TypeScript Setup
npm install typescript ts-node @types/node --save-dev
// wdio.conf.ts
import type { Options } from '@wdio/types'
export const config: Options.Testrunner = {
specs: ['./test/specs/**/*.ts'],
framework: 'mocha',
// ... rest of config
}
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"types": ["node", "@wdio/globals/types", "expect-webdriverio"]
}
}
Writing Your First Test
Basic Test Structure
// test/specs/login.spec.js
describe('Login Page', () => {
beforeEach(async () => {
await browser.url('/login')
})
it('should login with valid credentials', async () => {
await $('#username').setValue('testuser')
await $('#password').setValue('password123')
await $('button[type="submit"]').click()
await expect($('.welcome-message')).toBeDisplayed()
await expect($('.welcome-message')).toHaveTextContaining('Welcome')
})
it('should show error for invalid credentials', async () => {
await $('#username').setValue('wrong')
await $('#password').setValue('wrong')
await $('button[type="submit"]').click()
await expect($('.error-message')).toBeDisplayed()
await expect($('.error-message')).toHaveText('Invalid credentials')
})
})
Running Tests
# Run all tests
npx wdio run wdio.conf.js
# Run specific spec
npx wdio run wdio.conf.js --spec ./test/specs/login.spec.js
# Run with specific browser
npx wdio run wdio.conf.js --capabilities.browserName=firefox
Selectors and Elements
Selector Strategies
// CSS selectors (most common)
const button = await $('button.submit')
const input = await $('#email')
const items = await $$('.list-item') // Returns array
// XPath
const cell = await $('//table//tr[2]/td[3]')
// Link text
const link = await $('=Click Here') // Exact match
const partialLink = await $('*=Click') // Partial match
// Tag name
const headers = await $$('<h1>')
// ID shorthand
const element = await $('#myId')
// Class shorthand
const elements = await $$('.myClass')
Chaining and Finding Children
// Find child elements
const container = await $('.container')
const button = await container.$('button')
const items = await container.$$('.item')
// Chain directly
const text = await $('.container').$('.header').$('span').getText()
// React/Vue component selectors (with plugins)
const component = await $('my-component')
Working with Elements
// Actions
await $('#input').setValue('text')
await $('#input').clearValue()
await $('button').click()
await $('button').doubleClick()
await $('element').moveTo()
await $('select').selectByVisibleText('Option 1')
await $('select').selectByAttribute('value', 'opt1')
// Getting information
const text = await $('p').getText()
const value = await $('input').getValue()
const attr = await $('a').getAttribute('href')
const css = await $('div').getCSSProperty('color')
const isDisplayed = await $('element').isDisplayed()
const isEnabled = await $('button').isEnabled()
const isSelected = await $('checkbox').isSelected()
Waiting and Synchronization
Built-in Waits
// Wait for element to exist
await $('button').waitForExist({ timeout: 5000 })
// Wait for element to be displayed
await $('modal').waitForDisplayed({ timeout: 10000 })
// Wait for element to be clickable
await $('button').waitForClickable({ timeout: 5000 })
// Wait for element to NOT be displayed
await $('spinner').waitForDisplayed({ reverse: true })
// Custom wait condition
await browser.waitUntil(
async () => (await $('counter').getText()) === '10',
{
timeout: 10000,
timeoutMsg: 'Counter never reached 10'
}
)
Implicit Waits
// In wdio.conf.js
export const config = {
waitforTimeout: 10000, // Default wait for all waitFor commands
waitforInterval: 500, // Poll interval
}
Page Object Model
Page Object Structure
// test/pageobjects/login.page.js
class LoginPage {
get inputUsername() { return $('#username') }
get inputPassword() { return $('#password') }
get btnSubmit() { return $('button[type="submit"]') }
get errorMessage() { return $('.error-message') }
async open() {
await browser.url('/login')
}
async login(username, password) {
await this.inputUsername.setValue(username)
await this.inputPassword.setValue(password)
await this.btnSubmit.click()
}
}
export default new LoginPage()
Using Page Objects in Tests
// test/specs/login.spec.js
import LoginPage from '../pageobjects/login.page.js'
import DashboardPage from '../pageobjects/dashboard.page.js'
describe('Login', () => {
it('should login successfully', async () => {
await LoginPage.open()
await LoginPage.login('user@example.com', 'password123')
await expect(DashboardPage.welcomeMessage).toBeDisplayed()
})
})
Base Page Pattern
// test/pageobjects/base.page.js
export default class BasePage {
async open(path) {
await browser.url(path)
}
async getTitle() {
return browser.getTitle()
}
async waitForPageLoad() {
await browser.waitUntil(
async () => (await browser.execute(() => document.readyState)) === 'complete',
{ timeout: 30000 }
)
}
}
// test/pageobjects/login.page.js
import BasePage from './base.page.js'
class LoginPage extends BasePage {
get inputUsername() { return $('#username') }
// ... rest of page object
async open() {
await super.open('/login')
}
}
export default new LoginPage()
Browser Commands
Navigation
await browser.url('https://example.com')
await browser.url('/relative/path')
await browser.back()
await browser.forward()
await browser.refresh()
const url = await browser.getUrl()
const title = await browser.getTitle()
Window Management
// Window size
await browser.setWindowSize(1920, 1080)
const { width, height } = await browser.getWindowSize()
// Maximize/minimize
await browser.maximizeWindow()
// Tabs/windows
const handles = await browser.getWindowHandles()
await browser.switchToWindow(handles[1])
await browser.closeWindow()
// New tab
await browser.newWindow('https://example.com')
Screenshots
// Full page screenshot
await browser.saveScreenshot('./screenshot.png')
// Element screenshot
await $('header').saveScreenshot('./header.png')
// Get as base64
const screenshot = await browser.takeScreenshot()
Execute JavaScript
// Execute script in browser
const result = await browser.execute(() => {
return document.title
})
// With arguments
const text = await browser.execute((selector) => {
return document.querySelector(selector).innerText
}, '.my-element')
// Async execution
await browser.executeAsync((done) => {
setTimeout(() => done('finished'), 1000)
})
Assertions with expect-webdriverio
// Element assertions
await expect($('button')).toBeDisplayed()
await expect($('button')).toBeClickable()
await expect($('input')).toBeFocused()
await expect($('input')).toHaveValue('expected')
await expect($('p')).toHaveText('exact text')
await expect($('p')).toHaveTextContaining('partial')
await expect($('a')).toHaveAttribute('href', '/path')
await expect($('div')).toHaveElementClass('active')
await expect($$('li')).toBeElementsArrayOfSize(5)
// Browser assertions
await expect(browser).toHaveUrl('https://example.com/')
await expect(browser).toHaveUrlContaining('/dashboard')
await expect(browser).toHaveTitle('Page Title')
// Negation
await expect($('modal')).not.toBeDisplayed()
Parallel Execution
Configuration
// wdio.conf.js
export const config = {
maxInstances: 5, // Max parallel browser instances
capabilities: [{
maxInstances: 3,
browserName: 'chrome'
}, {
maxInstances: 2,
browserName: 'firefox'
}]
}
Shared State Considerations
// DON'T share state between tests
let sharedUser // Bad - race conditions
// DO use unique data per test
it('test 1', async () => {
const user = await createUniqueUser()
// ...
})
Reporters and Reporting
Spec Reporter (Default)
// wdio.conf.js
reporters: ['spec']
Allure Reporter
npm install @wdio/allure-reporter allure-commandline --save-dev
// wdio.conf.js
reporters: [['allure', {
outputDir: 'allure-results',
disableWebdriverStepsReporting: true,
disableWebdriverScreenshotsReporting: false
}]]
# Generate report
npx allure generate allure-results --clean
npx allure open
JUnit Reporter (CI/CD)
reporters: [['junit', {
outputDir: './results',
outputFileFormat: function(options) {
return `results-${options.cid}.xml`
}
}]]
CI/CD Integration
GitHub Actions
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run WebdriverIO tests
run: npx wdio run wdio.conf.js
- name: Upload Allure results
if: always()
uses: actions/upload-artifact@v4
with:
name: allure-results
path: allure-results
Docker Configuration
# Dockerfile
FROM node:20
RUN apt-get update && apt-get install -y \
chromium \
chromium-driver
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npx", "wdio", "run", "wdio.conf.js"]
WebdriverIO with AI Assistance
AI tools can help write and maintain WebdriverIO tests effectively.
What AI does well:
- Generate page objects from HTML structure
- Convert manual test cases to automation code
- Suggest selectors for complex elements
- Create data-driven test variations
What still needs humans:
- Choosing what to test vs skip
- Debugging flaky tests
- Understanding business logic
- Performance optimization decisions
Useful prompt:
Generate a WebdriverIO page object for a checkout form with fields: email, card number, expiry, CVV, and submit button. Include methods for filling the form and validating error states.
FAQ
What is WebdriverIO?
WebdriverIO is a progressive automation framework for Node.js. It implements WebDriver and DevTools protocols to control browsers and mobile devices. Unlike raw Selenium bindings, WebdriverIO provides modern async/await syntax, built-in assertions, automatic waits, and a rich plugin ecosystem. It’s particularly popular among JavaScript/TypeScript teams who want real browser testing.
Is WebdriverIO better than Selenium?
WebdriverIO is built on Selenium WebDriver protocol, so it’s more of an enhancement than replacement. It adds modern JavaScript patterns (async/await), better error messages, automatic waiting, and extensive plugin support. For Node.js developers, WebdriverIO is generally easier than using selenium-webdriver package directly. Raw Selenium offers more language options.
Can WebdriverIO test mobile apps?
Yes. WebdriverIO integrates seamlessly with Appium for mobile testing. Install the @wdio/appium-service and configure capabilities for iOS or Android. The same selector syntax and commands work for mobile apps, making it easy to share knowledge between web and mobile testing teams.
WebdriverIO vs Playwright vs Cypress?
WebdriverIO uses WebDriver protocol (real browser behavior, all browsers supported, mobile via Appium). Playwright uses Chrome DevTools Protocol (faster execution, auto-waits, but patched browsers). Cypress runs inside the browser (excellent debugging, but same-origin only, no mobile). Choose WebdriverIO for cross-browser/mobile needs, Playwright for speed, Cypress for developer experience.
Official Resources
See Also
- Selenium Tutorial - WebDriver fundamentals
- Playwright Tutorial - Modern alternative
- Cypress Tutorial - In-browser testing
- GitHub Actions for QA - CI/CD integration
