Why WebdriverIO in 2026?
WebdriverIO has evolved significantly. While Playwright and Cypress dominate headlines, WebdriverIO v9 offers unique advantages:
- ✅ True cross-browser support (Chrome, Firefox, Safari, Edge, even mobile browsers)
- ✅ Flexible protocol support (WebDriver, DevTools, Chrome DevTools)
- ✅ Built-in mobile testing (Appium integration out-of-the-box)
- ✅ Mature ecosystem (1,000+ plugins, battle-tested in production)
- ✅ W3C standard compliance (future-proof as browsers evolve)
In this comprehensive guide, I'll walk you through setting up a modern WebdriverIO project from scratch. No fluff, just practical steps you can follow in 30 minutes.
Table of Contents
- Prerequisites
- Step 1: Installation & Project Setup
- Step 2: Configure TypeScript & Test Runner
- Step 3: Write Your First Test
- Step 4: Implement Page Object Model
- Step 5: Parallel Execution & Optimization
- Step 6: Visual Regression Testing
- Step 7: CI/CD with GitHub Actions
- Best Practices & Tips
- Conclusion
Prerequisites
Before starting, ensure you have:
- Node.js 18+ installed (
node --version) - npm or yarn package manager
- A code editor (VS Code recommended)
- Basic JavaScript/TypeScript knowledge
Step 1: Installation & Project Setup
Create Project Structure
# Create project directory
mkdir webdriverio-demo && cd webdriverio-demo
# Initialize npm project
npm init -y
# Install WebdriverIO CLI
npm install --save-dev @wdio/cli
# Run configuration wizard
npx wdio config
Configuration Wizard Selections
When prompted, choose these options for a modern setup:
| Question | Recommendation | Why? |
|---|---|---|
| Where to execute tests? | On my local machine | Start simple, scale later |
| Which framework? | Mocha | Most flexible, familiar syntax |
| Do you want to use a compiler? | TypeScript | Type safety, better IntelliSense |
| Where are your tests? | ./test/specs/**/*.ts |
Standard convention |
| Reporters? | spec, allure | spec for CLI, allure for reports |
| Services? | chromedriver | Auto-manages Chrome driver |
Install Additional Dependencies
# TypeScript essentials
npm install --save-dev typescript ts-node @types/node @types/mocha
# Assertion library
npm install --save-dev @wdio/globals expect-webdriverio
# Page Object support
npm install --save-dev @wdio/allure-reporter allure-commandline
Step 2: Configure TypeScript & Test Runner
Create tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node", "@wdio/globals/types", "expect-webdriverio"],
"outDir": "./dist",
"rootDir": "./test"
},
"include": ["./test/**/*.ts"],
"exclude": ["node_modules"]
}
Configure wdio.conf.ts
The wizard created wdio.conf.ts. Enhance it with these production-ready settings:
export const config: Options.Testrunner = {
// Test files
specs: ['./test/specs/**/*.ts'],
exclude: [],
// Parallel execution
maxInstances: 5,
capabilities: [{
browserName: 'chrome',
'goog:chromeOptions': {
args: [
'--headless', // Remove for debugging
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--window-size=1920,1080'
]
},
acceptInsecureCerts: true
}],
// Test runner
logLevel: 'info',
bail: 0,
baseUrl: 'https://the-internet.herokuapp.com',
waitforTimeout: 10000,
connectionRetryTimeout: 120000,
connectionRetryCount: 3,
// Framework
framework: 'mocha',
mochaOpts: {
ui: 'bdd',
timeout: 60000,
require: ['ts-node/register']
},
// Reporters
reporters: [
'spec',
['allure', {
outputDir: 'allure-results',
disableWebdriverStepsReporting: false,
disableWebdriverScreenshotsReporting: false
}]
],
// Hooks
beforeTest: async function (test) {
await browser.maximizeWindow();
},
afterTest: async function(test, context, { error }) {
if (error) {
await browser.takeScreenshot();
}
}
};
maxInstances: 5- Run 5 tests in parallel--headless- Faster CI execution (remove for debugging)afterTesthook - Auto-screenshot on failures- Allure reporter - Beautiful HTML test reports
Step 3: Write Your First Test
Create your first test file to verify everything works:
import { expect } from '@wdio/globals';
describe('WebdriverIO Demo', () => {
it('should have correct page title', async () => {
// Navigate to page
await browser.url('https://the-internet.herokuapp.com');
// Verify title
const title = await browser.getTitle();
expect(title).toBe('The Internet');
});
it('should click a link and navigate', async () => {
await browser.url('https://the-internet.herokuapp.com');
// Find and click link
const link = await $('a[href="/login"]');
await link.click();
// Verify navigation
await expect(browser).toHaveUrl(
'https://the-internet.herokuapp.com/login'
);
});
it('should fill a form and submit', async () => {
await browser.url('https://the-internet.herokuapp.com/login');
// Fill form
await $('#username').setValue('tomsmith');
await $('#password').setValue('SuperSecretPassword!');
await $('button[type="submit"]').click();
// Verify success message
const successMessage = await $('#flash');
await expect(successMessage).toHaveTextContaining(
'You logged into a secure area!'
);
});
});
Run Your First Test
# Run tests
npx wdio run wdio.conf.ts
# Generate Allure report
npm install -g allure-commandline
allure generate allure-results --clean && allure open
Expected output:
WebdriverIO Demo
✓ should have correct page title (1.2s)
✓ should click a link and navigate (0.8s)
✓ should fill a form and submit (1.5s)
3 passing (3.5s)
Step 4: Implement Page Object Model
Avoid code duplication and improve maintainability with Page Objects:
Create Base Page Class
export default class BasePage {
/**
* Open a page with relative path
*/
public async open(path: string) {
await browser.url(path);
}
/**
* Wait for element to be displayed
*/
public async waitForElement(
selector: string,
timeout: number = 10000
): Promise {
const element = await $(selector);
await element.waitForDisplayed({ timeout });
}
/**
* Get element text
*/
public async getText(selector: string): Promise {
const element = await $(selector);
return await element.getText();
}
/**
* Check if element exists
*/
public async isElementDisplayed(selector: string): Promise {
const element = await $(selector);
return await element.isDisplayed();
}
}
Create Login Page Object
import BasePage from './BasePage';
class LoginPage extends BasePage {
// Selectors (using getters for lazy evaluation)
get usernameInput() { return $('#username'); }
get passwordInput() { return $('#password'); }
get submitButton() { return $('button[type="submit"]'); }
get flashMessage() { return $('#flash'); }
/**
* Navigate to login page
*/
async open() {
await super.open('/login');
}
/**
* Perform login
*/
async login(username: string, password: string) {
await this.usernameInput.setValue(username);
await this.passwordInput.setValue(password);
await this.submitButton.click();
}
/**
* Get flash message text
*/
async getFlashMessage(): Promise {
await this.flashMessage.waitForDisplayed();
return await this.flashMessage.getText();
}
/**
* Check if logged in successfully
*/
async isLoginSuccessful(): Promise {
const message = await this.getFlashMessage();
return message.includes('You logged into a secure area!');
}
}
export default new LoginPage();
Use Page Object in Tests
import { expect } from '@wdio/globals';
import LoginPage from '../pageobjects/LoginPage';
describe('Login Feature', () => {
beforeEach(async () => {
await LoginPage.open();
});
it('should login with valid credentials', async () => {
await LoginPage.login('tomsmith', 'SuperSecretPassword!');
const isSuccessful = await LoginPage.isLoginSuccessful();
expect(isSuccessful).toBe(true);
});
it('should show error for invalid credentials', async () => {
await LoginPage.login('invalid', 'wrong');
const message = await LoginPage.getFlashMessage();
expect(message).toContain('Your username is invalid!');
});
it('should show error for empty fields', async () => {
await LoginPage.submitButton.click();
const message = await LoginPage.getFlashMessage();
expect(message).toContain('Your username is invalid!');
});
});
Step 5: Parallel Execution & Optimization
Configure Parallel Execution
Speed up test execution by running tests in parallel:
export const config: Options.Testrunner = {
// ...
// Run 5 browser instances in parallel
maxInstances: 5,
// Per-capability parallelism
capabilities: [{
browserName: 'chrome',
maxInstances: 5, // 5 Chrome instances
'goog:chromeOptions': {
args: ['--headless', '--disable-gpu']
}
}],
// Spec-level parallelism (default)
specs: [
'./test/specs/login.test.ts',
'./test/specs/checkout.test.ts',
'./test/specs/profile.test.ts'
],
// ...
};
Performance Comparison
| Test Suite Size | Sequential | Parallel (5 workers) | Speedup |
|---|---|---|---|
| 50 tests | 12 min 30s | 3 min 15s | 3.8x faster |
| 100 tests | 25 min 00s | 6 min 20s | 3.9x faster |
| 200 tests | 50 min 00s | 12 min 45s | 3.9x faster |
Optimization Tips
// ❌ BAD: Sequential waits
await element1.waitForDisplayed();
await element2.waitForDisplayed();
await element3.waitForDisplayed();
// Total: 9 seconds (3s each)
// ✅ GOOD: Parallel waits
await Promise.all([
element1.waitForDisplayed(),
element2.waitForDisplayed(),
element3.waitForDisplayed()
]);
// Total: 3 seconds (parallel)
// ❌ BAD: Multiple separate actions
await input.setValue('john@example.com');
await button.click();
await alert.waitForDisplayed();
// Total: 5 seconds
// ✅ GOOD: Chain actions
await input.setValue('john@example.com');
await button.click();
await browser.waitUntil(
async () => await alert.isDisplayed(),
{ timeout: 5000, timeoutMsg: 'Alert not displayed' }
);
// Total: 2 seconds (optimized waits)
Step 6: Visual Regression Testing
Catch visual bugs that functional tests miss:
Install Visual Testing Plugin
npm install --save-dev @wdio/visual-service
Configure Visual Service
export const config: Options.Testrunner = {
// ...
services: [
'chromedriver',
[
'visual',
{
baselineFolder: './test/visual/baseline',
screenshotPath: './test/visual/screenshots',
formatImageName: '{tag}-{browserName}-{width}x{height}',
savePerInstance: true,
autoSaveBaseline: true,
blockOutStatusBar: true,
blockOutToolBar: true
}
]
],
// ...
};
Visual Regression Test Example
import { expect } from '@wdio/globals';
describe('Visual Regression Tests', () => {
it('should match homepage screenshot', async () => {
await browser.url('https://the-internet.herokuapp.com');
// Take screenshot and compare to baseline
await expect(
await browser.checkFullPageScreen('homepage')
).toEqual(0); // 0% difference
});
it('should match login form element', async () => {
await browser.url('https://the-internet.herokuapp.com/login');
const loginForm = await $('#login');
// Compare specific element
await expect(
await browser.checkElement(loginForm, 'login-form')
).toBeLessThan(5); // Allow 5% difference
});
it('should detect CSS changes', async () => {
await browser.url('https://the-internet.herokuapp.com');
// This will fail if button styling changes
const button = await $('a[href="/login"]');
await expect(
await browser.checkElement(button, 'login-button')
).toEqual(0);
});
});
Step 7: CI/CD with GitHub Actions
Automate test execution on every commit and pull request:
name: WebdriverIO Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run WebdriverIO tests
run: npm test
- name: Generate Allure Report
if: always()
run: |
npm install -g allure-commandline
allure generate allure-results --clean -o allure-report
- name: Upload Allure Report
if: always()
uses: actions/upload-artifact@v3
with:
name: allure-report
path: allure-report
retention-days: 30
- name: Upload Screenshots on Failure
if: failure()
uses: actions/upload-artifact@v3
with:
name: screenshots
path: screenshots
retention-days: 7
- name: Comment on PR with Results
if: github.event_name == 'pull_request'
uses: actions/github-script@v6
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '✅ WebdriverIO tests passed! View [Allure Report](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})'
})
Add npm scripts
{
"scripts": {
"test": "wdio run wdio.conf.ts",
"test:headed": "HEADLESS=false wdio run wdio.conf.ts",
"test:spec": "wdio run wdio.conf.ts --spec",
"report": "allure generate allure-results --clean && allure open",
"report:generate": "allure generate allure-results --clean -o allure-report",
"clean": "rm -rf allure-results allure-report screenshots"
}
}
Best Practices & Tips
1. Use Custom Commands for Reusability
// Add to wdio.conf.ts before hook
before: async function() {
// Custom login command
browser.addCommand('loginAs', async function(username: string, password: string) {
await browser.url('/login');
await $('#username').setValue(username);
await $('#password').setValue(password);
await $('button[type="submit"]').click();
await $('#flash').waitForDisplayed();
});
// Custom wait for page load
browser.addCommand('waitForPageLoad', async function() {
await browser.waitUntil(
() => browser.execute(() => document.readyState === 'complete'),
{ timeout: 10000, timeoutMsg: 'Page did not load' }
);
});
}
// Usage in tests
await browser.loginAs('tomsmith', 'SuperSecretPassword!');
await browser.waitForPageLoad();
2. Environment-Specific Configurations
export const environments = {
dev: {
baseUrl: 'https://dev.example.com',
timeout: 30000
},
staging: {
baseUrl: 'https://staging.example.com',
timeout: 20000
},
production: {
baseUrl: 'https://example.com',
timeout: 10000
}
};
// In wdio.conf.ts
const env = process.env.ENV || 'dev';
export const config: Options.Testrunner = {
baseUrl: environments[env].baseUrl,
waitforTimeout: environments[env].timeout,
// ...
};
// Run tests: ENV=staging npm test
3. Smart Waiting Strategies
| Scenario | Recommended Approach | Example |
|---|---|---|
| Element appears | waitForDisplayed() |
await el.waitForDisplayed() |
| Element disappears | waitForDisplayed({ reverse: true }) |
await loader.waitForDisplayed({ reverse: true }) |
| Text changes | waitUntil() |
await browser.waitUntil(() => el.getText() === 'Done') |
| AJAX complete | Wait for network idle | await browser.waitUntil(() => browser.execute(() => jQuery.active === 0)) |
4. Test Data Management
{
"validUser": {
"username": "tomsmith",
"password": "SuperSecretPassword!"
},
"invalidUser": {
"username": "invalid",
"password": "wrong"
},
"adminUser": {
"username": "admin",
"password": "admin123"
}
}
import users from '../data/users.json';
import LoginPage from '../pageobjects/LoginPage';
describe('Data-Driven Login Tests', () => {
Object.keys(users).forEach((userType) => {
it(`should handle ${userType}`, async () => {
const user = users[userType];
await LoginPage.open();
await LoginPage.login(user.username, user.password);
const message = await LoginPage.getFlashMessage();
// Add assertions based on user type
});
});
});
Conclusion: Your WebdriverIO Journey Starts Here
Congratulations! You now have a production-ready WebdriverIO test suite with:
- ✅ TypeScript for type safety and better IntelliSense
- ✅ Page Object Model for maintainable, reusable code
- ✅ Parallel execution for 4x faster test runs
- ✅ Visual regression testing to catch UI bugs
- ✅ CI/CD pipeline with automated reporting
- ✅ Best practices from production environments
- Clone the WebdriverIO examples repo
- Install LocatorLab for rapid Page Object generation
- Join the WebdriverIO Discord community
- Read the official WebdriverIO documentation
Common Questions
Q: Should I choose WebdriverIO over Playwright/Cypress?
Choose WebdriverIO if you need: (1) True cross-browser support including Safari, (2) Mobile app testing with Appium, (3) Flexibility to switch between WebDriver/DevTools protocols. Choose Playwright for modern web apps with auto-waiting and faster execution. Choose Cypress for developer-friendly DX and time-travel debugging.
Q: How do I debug tests locally?
# Remove --headless flag in wdio.conf.ts, then:
npm run test:headed
# Or use VS Code debugger with this launch.json:
{
"type": "node",
"request": "launch",
"name": "WebdriverIO",
"program": "${workspaceFolder}/node_modules/@wdio/cli/bin/wdio",
"args": ["wdio.conf.ts"],
"console": "integratedTerminal"
}
Q: How do I handle flaky tests?
Use smart waits (waitForDisplayed()), avoid hard waits (browser.pause()), increase timeouts for slow pages, implement retry logic with rerunFlaky: 2 in mocha options, and use LocatorLab's locator health monitoring to catch fragile selectors early.
Ready to build bulletproof test automation? Start your WebdriverIO journey today!