name: WebdriverIO E2E Testing description: Build WebdriverIO E2E suites — wdio.conf.ts setup, $ and $$ selectors, auto-wait and waitUntil, Mocha framework structure, page objects, parallel capabilities, and services for visual testing and Appium mobile. version: 1.0.0 author: thetestingacademy license: MIT tags: [webdriverio, wdio, e2e, selenium, appium, mocha, page-object, parallel, typescript] testingTypes: [e2e, integration] frameworks: [webdriverio, mocha, appium] languages: [typescript, javascript] domains: [web, mobile] agents: [claude-code, cursor, github-copilot, windsurf, codex, aider, continue, cline, zed, bolt, gemini-cli, amp]
WebdriverIO E2E Testing
This skill makes an AI agent write and configure WebdriverIO (WDIO) end-to-end tests: a correct wdio.conf.ts, $/$$ selector usage with auto-waiting, waitUntil for custom conditions, Mocha-structured specs, page objects, parallel execution via maxInstances and multiple capabilities, and service wiring (visual regression, Appium for mobile). Trigger it when a repo contains @wdio/cli in devDependencies, a wdio.conf.* file, or the user asks for WebdriverIO/WDIO tests.
Core Principles
- WDIO commands auto-wait — do not add manual pauses.
$('button').click()retries until the element is interactable (governed bywaitforTimeout).browser.pause()in committed code is a bug, not a fix. $returns a chainable element, not a handle. Re-locating happens on each command, so stale-element errors are rare. Store the selector chain, never an awaited snapshot, in page objects.- Use WDIO selector strengths in priority order: accessibility-ish text selectors (
button=Submit,*=partial), thendata-testidvia[data-testid="x"], then CSS. Reach for XPath only for parent-axis traversal. - One spec = one user-visible behavior. WDIO workers isolate per spec file; long multi-journey specs serialize your suite and hide which behavior broke.
- Parallelism is config, not code.
maxInstances+ thecapabilitiesarray fan out across browsers; specs must not share accounts or mutable server state. - Services do the heavy lifting. Visual diffs (
@wdio/visual-service), Appium (@wdio/appium-service), and Selenium Grid wiring belong inservices:, not hand-rolled in hooks.
Setup
npm init wdio@latest . # interactive scaffold
# or manual:
npm install --save-dev @wdio/cli @wdio/local-runner @wdio/mocha-framework @wdio/spec-reporter tsx
// wdio.conf.ts
import type { Options } from '@wdio/types';
export const config: Options.Testrunner = {
runner: 'local',
specs: ['./test/specs/**/*.ts'],
maxInstances: 5,
capabilities: [
{
browserName: 'chrome',
'goog:chromeOptions': {
args: process.env.CI ? ['--headless=new', '--disable-gpu', '--window-size=1366,900'] : [],
},
},
],
logLevel: 'warn',
baseUrl: process.env.BASE_URL ?? 'http://localhost:3000',
waitforTimeout: 10_000, // default $ auto-wait budget
connectionRetryTimeout: 120_000,
framework: 'mocha',
reporters: ['spec'],
mochaOpts: { ui: 'bdd', timeout: 60_000 },
// Fail fast in CI, keep full runs locally
bail: process.env.CI ? 1 : 0,
afterTest: async function (_test, _context, { passed }) {
if (!passed) {
await browser.takeScreenshot(); // attached to the runner log dir
}
},
};
Selectors and Auto-Wait
// test/specs/login.spec.ts
import { browser, $, expect } from '@wdio/globals';
describe('login', () => {
beforeEach(async () => {
await browser.url('/login'); // resolves against baseUrl
});
it('signs in with valid credentials', async () => {
await $('[data-testid="email"]').setValue('user@example.com');
await $('[data-testid="password"]').setValue('s3cret!');
await $('button=Sign in').click(); // text selector, auto-waits
// expect-webdriverio assertions retry until timeout — no manual waits
await expect($('h1')).toHaveText('Dashboard');
await expect(browser).toHaveUrl(expect.stringContaining('/dashboard'));
});
it('shows a validation error for a bad password', async () => {
await $('[data-testid="email"]').setValue('user@example.com');
await $('[data-testid="password"]').setValue('wrong');
await $('button=Sign in').click();
const alert = $('[role="alert"]');
await expect(alert).toBeDisplayed();
await expect(alert).toHaveText(expect.stringContaining('Invalid credentials'));
});
});
$$ for collections:
const rows = $$('[data-testid="cart-row"]');
await expect(rows).toBeElementsArrayOfSize(3);
const titles = await rows.map((row) => row.$('.title').getText());
waitUntil for Custom Conditions
Use only when no built-in matcher fits (e.g., polling app state):
await browser.waitUntil(
async () => (await $('[data-testid="job-status"]').getText()) === 'COMPLETE',
{
timeout: 30_000,
interval: 500,
timeoutMsg: 'job never reached COMPLETE',
},
);
Page Objects
// test/pageobjects/login.page.ts
import { $, browser } from '@wdio/globals';
class LoginPage {
// getters return fresh chainable selectors — never cache awaited elements
get email() { return $('[data-testid="email"]'); }
get password() { return $('[data-testid="password"]'); }
get submit() { return $('button=Sign in'); }
async open() {
await browser.url('/login');
}
async login(email: string, password: string) {
await this.email.setValue(email);
await this.password.setValue(password);
await this.submit.click();
}
}
export default new LoginPage();
// usage
import LoginPage from '../pageobjects/login.page';
it('logs in', async () => {
await LoginPage.open();
await LoginPage.login('user@example.com', 's3cret!');
await expect($('h1')).toHaveText('Dashboard');
});
Parallel + Multi-Browser Capabilities
// wdio.conf.ts (excerpt)
maxInstances: 6,
capabilities: [
{ browserName: 'chrome', 'goog:chromeOptions': { args: ['--headless=new'] } },
{ browserName: 'firefox', 'moz:firefoxOptions': { args: ['-headless'] }, maxInstances: 2 },
],
Each spec file runs in its own worker; maxInstances caps concurrency globally, the per-capability maxInstances caps per browser. Shard further in CI with --spec globs per job.
Services
// visual regression
// npm i -D @wdio/visual-service
services: [['visual', {
baselineFolder: './test/baseline',
screenshotPath: './test/screenshots',
blockOutStatusBar: true,
}]],
// in a spec
await expect(browser).toMatchFullPageSnapshot('dashboard', { misMatchTolerance: 0.2 });
// Appium mobile (native or mobile web)
// npm i -D @wdio/appium-service
services: ['appium'],
capabilities: [{
platformName: 'Android',
'appium:automationName': 'UiAutomator2',
'appium:deviceName': 'Pixel_8_API_34',
'appium:app': './apps/app-release.apk',
}],
CI (GitHub Actions)
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npm run start:test & # app under test
- run: npx wait-on http://localhost:3000
- run: npx wdio run wdio.conf.ts
env: { CI: 'true' }
- uses: actions/upload-artifact@v4
if: failure()
with: { name: wdio-screenshots, path: ./test/screenshots }
Best Practices
- Keep
waitforTimeoutat 10-15s; raise per-call ({ timeout }arg) for known-slow flows instead of globally. - Use
expect-webdriveriomatchers (toHaveText,toBeDisplayed) — they retry; baregetText()+ chai does not. - Reset state via API calls in
beforehooks, not UI click-throughs. - Name specs by behavior:
checkout-applies-coupon.spec.ts, nottest1.spec.ts. - Pin browser versions in CI (chrome-for-testing) to stop drive-by breakage.
Anti-Patterns
browser.pause(3000)anywhere in committed code — replace with a matcher orwaitUntil.- Caching
const el = await $(sel)in a variable across navigations — re-locate via getters. - One mega-spec covering login→cart→checkout→refund — kills parallelism and triage.
- Asserting with non-retrying chai on async UI — flake factory.
- Driving Appium and desktop web in one capability set without separating configs — split
wdio.web.conf.ts/wdio.mobile.conf.tssharing a base.
When to Trigger This Skill
- "Write WebdriverIO tests for X" / "add a wdio spec"
- Repo has
wdio.conf.ts|jsor@wdio/clidependency - Migrating Selenium JS or Protractor suites to WDIO
- Setting up visual regression or Appium through WDIO services