# WebdriverIO E2E Testing

> 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.

- Skill: `pramoddutta/webdriverio-e2e-testing` (Agent Skill)
- Install (CLI): `npx skillmds@latest add pramoddutta/webdriverio-e2e-testing`
- Raw SKILL.md: https://api.skillmd.com/api/skills/pramoddutta/webdriverio-e2e-testing/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: MIT
- Author: PramodDutta (https://skillmd.com/u/pramoddutta)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/pramoddutta/webdriverio-e2e-testing

---


# 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

1. **WDIO commands auto-wait — do not add manual pauses.** `$('button').click()` retries until the element is interactable (governed by `waitforTimeout`). `browser.pause()` in committed code is a bug, not a fix.
2. **`$` 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.
3. **Use WDIO selector strengths in priority order:** accessibility-ish text selectors (`button=Submit`, `*=partial`), then `data-testid` via `[data-testid="x"]`, then CSS. Reach for XPath only for parent-axis traversal.
4. **One spec = one user-visible behavior.** WDIO workers isolate per spec file; long multi-journey specs serialize your suite and hide which behavior broke.
5. **Parallelism is config, not code.** `maxInstances` + the `capabilities` array fan out across browsers; specs must not share accounts or mutable server state.
6. **Services do the heavy lifting.** Visual diffs (`@wdio/visual-service`), Appium (`@wdio/appium-service`), and Selenium Grid wiring belong in `services:`, not hand-rolled in hooks.

## Setup

```bash
npm init wdio@latest .   # interactive scaffold
# or manual:
npm install --save-dev @wdio/cli @wdio/local-runner @wdio/mocha-framework @wdio/spec-reporter tsx
```

```typescript
// 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

```typescript
// 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:

```typescript
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):

```typescript
await browser.waitUntil(
  async () => (await $('[data-testid="job-status"]').getText()) === 'COMPLETE',
  {
    timeout: 30_000,
    interval: 500,
    timeoutMsg: 'job never reached COMPLETE',
  },
);
```

## Page Objects

```typescript
// 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();
```

```typescript
// 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

```typescript
// 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

```typescript
// visual regression
// npm i -D @wdio/visual-service
services: [['visual', {
  baselineFolder: './test/baseline',
  screenshotPath: './test/screenshots',
  blockOutStatusBar: true,
}]],
```

```typescript
// in a spec
await expect(browser).toMatchFullPageSnapshot('dashboard', { misMatchTolerance: 0.2 });
```

```typescript
// 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)

```yaml
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 `waitforTimeout` at 10-15s; raise per-call (`{ timeout }` arg) for known-slow flows instead of globally.
- Use `expect-webdriverio` matchers (`toHaveText`, `toBeDisplayed`) — they retry; bare `getText()` + chai does not.
- Reset state via API calls in `before` hooks, not UI click-throughs.
- Name specs by behavior: `checkout-applies-coupon.spec.ts`, not `test1.spec.ts`.
- Pin browser versions in CI (chrome-for-testing) to stop drive-by breakage.

## Anti-Patterns

1. `browser.pause(3000)` anywhere in committed code — replace with a matcher or `waitUntil`.
2. Caching `const el = await $(sel)` in a variable across navigations — re-locate via getters.
3. One mega-spec covering login→cart→checkout→refund — kills parallelism and triage.
4. Asserting with non-retrying chai on async UI — flake factory.
5. Driving Appium and desktop web in one capability set without separating configs — split `wdio.web.conf.ts` / `wdio.mobile.conf.ts` sharing a base.

## When to Trigger This Skill

- "Write WebdriverIO tests for X" / "add a wdio spec"
- Repo has `wdio.conf.ts|js` or `@wdio/cli` dependency
- Migrating Selenium JS or Protractor suites to WDIO
- Setting up visual regression or Appium through WDIO services

