PRACTICAL GUIDE / webdriverio tutorial

WebdriverIO Tutorial: Modern E2E Testing with JavaScript

WebdriverIO tutorial covering setup, first spec, selectors, assertions, services, page objects, debugging, CI, reporting, and maintainable E2E tests.

By The Testing AcademyUpdated July 10, 202610 min read
All field guides
In this guide9 sections
  1. Scaffold around one repository workflow
  2. Configure the runner for predictable failures
  3. Create state with an API helper
  4. Build page objects from user tasks
  5. Write an isolated Mocha spec
  6. Wait for application signals
  7. Add services and reporters for a reason
  8. Diagnose failures by responsibility
  9. Run a constrained CI job

What you will learn

  • Scaffold around one repository workflow
  • Configure the runner for predictable failures
  • Create state with an API helper
  • Build page objects from user tasks

A booking test passes when run alone but fails after another spec because the previous customer remains signed in. Adding a retry makes the dashboard green, yet the suite still depends on execution order. WebdriverIO provides a runner, hooks, assertions, and browser automation, but isolation must be designed in the project rather than expected from the tool.

This TypeScript and Mocha example tests a room reservation portal. It creates a unique room through an API, starts a clean browser session, books the room, and verifies the confirmation. The same structure can later target a remote WebDriver service without rewriting product behavior.

Scaffold around one repository workflow

Run the WebdriverIO setup wizard in the application repository and choose the local runner, Mocha, TypeScript, and the browser targets your team actually supports.

Shell
npm init wdio@latest .
npx wdio run ./wdio.conf.ts

Review every generated dependency and file before committing. Keep tests near the application when they share release ownership, or in a dedicated repository when infrastructure and credentials require separate controls.

A useful initial layout is:

Example
test/
├── specs/booking.e2e.ts
├── pageobjects/booking.page.ts
└── support/
    ├── booking-api.ts
    └── test-data.ts
wdio.conf.ts

Do not install Cucumber, visual comparison, mobile services, and several reporters before the first browser journey is stable. Each integration adds configuration and failure modes.

Keep the generated TypeScript configuration aligned with the repository's module system. Mixing CommonJS config, ESM test imports, and implicit transpilation can make local startup differ from CI. Run the exact checked-in command on a clean clone and commit the package lockfile.

Type-check page objects and support clients as part of the normal build. Browser globals such as $, browser, and expect require the generated WebdriverIO type configuration. If the repository disables globals, import them from the supported WebdriverIO globals package consistently instead of mixing both styles.

Configure the runner for predictable failures

The configuration controls specs, capabilities, timeouts, framework behavior, and evidence hooks.

TypeScript
// wdio.conf.ts
import type { Options } from '@wdio/types'
import { mkdirSync } from 'node:fs'

export const config: Options.Testrunner = {
  runner: 'local',
  specs: ['./test/specs/**/*.e2e.ts'],
  maxInstances: 2,
  capabilities: [{
    browserName: 'chrome',
    'goog:chromeOptions': {
      args: process.env.CI
        ? ['--headless=new', '--window-size=1440,900']
        : ['--window-size=1440,900']
    }
  }],
  baseUrl: process.env.APP_URL ?? 'http://127.0.0.1:3000',
  waitforTimeout: 10_000,
  connectionRetryTimeout: 120_000,
  connectionRetryCount: 1,
  framework: 'mocha',
  reporters: ['spec'],
  mochaOpts: {
    ui: 'bdd',
    timeout: 60_000
  },
  onPrepare: function () {
    mkdirSync('./artifacts', { recursive: true })
  },
  afterTest: async function (_test, _context, result) {
    if (!result.passed) {
      await browser.saveScreenshot(
        `./artifacts/${Date.now()}-${browser.sessionId}.png`
      )
    }
  }
}

waitforTimeout is the default for WebdriverIO wait commands, not a fixed pause before every action. connectionRetryCount retries transport commands, not whole business workflows. Keep Mocha retries at zero while stabilizing the suite so failures remain visible.

Set browser capabilities according to the execution backend. A cloud or internal Grid may require vendor capabilities and secure credentials. Load them from environment variables, never from the committed configuration.

Create state with an API helper

The booking spec should not navigate through admin screens to create a room. A typed helper can call a protected test-support endpoint.

TypeScript
// test/support/booking-api.ts
export type TestRoom = {
  id: string
  name: string
  rate: number
}

export async function createRoom(name: string): Promise<TestRoom> {
  const response = await fetch(`${process.env.API_URL}/test-support/rooms`, {
    method: 'POST',
    headers: {
      authorization: `Bearer ${process.env.TEST_API_TOKEN}`,
      'content-type': 'application/json'
    },
    body: JSON.stringify({ name, rate: 12900, available: true })
  })

  if (response.status !== 201) {
    throw new Error(`Room setup failed with ${response.status}`)
  }
  return response.json() as Promise<TestRoom>
}

Node must provide fetch, or the repository should use its approved HTTP client. The helper checks status before parsing and does not print the response body because it might contain sensitive setup details.

Generate names with a worker-safe suffix and implement deletion through the same API. If a test fails after creation, an afterEach hook should still clean up. Cleanup must target the exact record ID, never a broad prefix that could delete another worker's data.

Separate privileged setup from browser identity. The API helper may use a test-support token to create rooms, but the booking page must operate as the customer role being evaluated. Do not install the setup token in browser storage or expose it to application JavaScript.

Return typed, minimal records from helpers and validate the runtime response before trusting the cast. A TypeScript as TestRoom expression does not check JSON at runtime. For a mature suite, use the repository's schema validator or a focused guard that confirms id, name, and numeric rate.

Build page objects from user tasks

WebdriverIO page objects can expose getters for elements and methods for meaningful behavior. Keep assertions in the spec unless an assertion is intrinsic to completing an action.

TypeScript
// test/pageobjects/booking.page.ts
class BookingPage {
  get destination() { return $('[data-testid="destination"]') }
  get searchButton() { return $('[data-testid="search-rooms"]') }
  get confirmation() { return $('[role="status"]') }

  roomCard(roomId: string) {
    return $(`[data-room-id="${roomId}"]`)
  }

  async open() {
    await browser.url('/book')
    await expect(this.destination).toBeDisplayed()
  }

  async search(city: string) {
    await this.destination.setValue(city)
    await this.searchButton.click()
  }

  async book(roomId: string) {
    const card = this.roomCard(roomId)
    await card.scrollIntoView()
    await card.$('[data-testid="book-room"]').click()
    await $('[data-testid="confirm-booking"]').click()
    await expect(this.confirmation).toHaveText('Booking confirmed')
  }
}

export default new BookingPage()

The generated roomId comes from the API and must be selector-safe. An application-owned test ID is preferable to text or card position. Returning a singleton page object is conventional for a session-scoped browser global; if the project creates multiple simultaneous browser instances inside one test, use class instances instead.

WebdriverIO interaction commands wait for elements to become actionable, and its expect matchers retry until timeout. That does not prove backend readiness. The confirmation assertion must represent the completed product state.

Write an isolated Mocha spec

Create the room in beforeEach, delete it in afterEach, and ensure the browser begins from a known session.

TypeScript
// test/specs/booking.e2e.ts
import { randomUUID } from 'node:crypto'
import bookingPage from '../pageobjects/booking.page.js'
import {
  createRoom,
  deleteRoom,
  getBooking,
  type TestRoom
} from '../support/booking-api.js'

describe('room booking', () => {
  let room: TestRoom

  beforeEach(async () => {
    room = await createRoom(`Harbor room ${randomUUID()}`)
    await browser.reloadSession()
  })

  afterEach(async () => {
    if (room) await deleteRoom(room.id)
  })

  it('books an available room', async () => {
    await bookingPage.open()
    await bookingPage.search('Kochi')
    await bookingPage.book(room.id)

    const bookingId = await $('[data-testid="booking-id"]').getText()
    const persisted = await getBooking(bookingId)
    expect(persisted).toMatchObject({
      roomId: room.id,
      status: 'confirmed'
    })
  })
})

reloadSession creates a new browser session and can be expensive. Because the runner normally gives a worker a session and specs may share it, use it only when the isolation requirement justifies the cost. A faster alternative is a deterministic logout or a new-session strategy in configuration.

The API assertion proves persistence. If booking completion is asynchronous, getBooking should poll with a deadline and report the last state. A plain immediate GET can make the UI test race the service.

Wait for application signals

Use waitForDisplayed, waitForClickable, and matcher assertions for ordinary element state. Use browser.waitUntil for a product-specific condition that has no built-in matcher.

TypeScript
await browser.waitUntil(
  async () => {
    const status = await getBooking(bookingId)
    if (status.state === 'failed') {
      throw new Error(`Booking failed: ${status.reason}`)
    }
    return status.state === 'confirmed'
  },
  {
    timeout: 30_000,
    interval: 500,
    timeoutMsg: `Booking ${bookingId} did not become confirmed`
  }
)

Avoid browser.pause(). It wastes the full delay and cannot describe why the application is ready. Also avoid a generic helper that retries every click. Repeating a booking submission can create duplicate reservations.

For loading spinners, wait for a positive result after the spinner disappears. An absent spinner can mean either "finished" or "never started." For new windows, wait until handles increase, switch explicitly, and restore the original handle in cleanup.

Choose whether a browser matcher or API poll owns the final condition. Polling both independently can double the timeout and obscure which boundary failed. For a user-facing confirmation, wait for the visible message first, then perform one bounded backend verification if persistence is part of the test contract.

Element getters resolve through $ when accessed, which helps after rerendering. Do not assign the result to a module-level variable during import because no browser session may exist yet and the element can become stale. Keep dynamic elements in getters or methods.

Add services and reporters for a reason

WebdriverIO services can manage local drivers, connect to mobile automation, integrate with cloud providers, or start application processes. Add a service only when it owns a real infrastructure responsibility. Confirm its package and configuration against the installed WebdriverIO documentation because service compatibility follows the project's dependency set.

Use the spec reporter during early development because console output is easy to read. Add JUnit or another machine-readable reporter when CI needs test results. Screenshots are useful for visible failures; browser logs and the current URL help with JavaScript or navigation errors. Video can help multi-step timing failures but increases storage and review cost.

Never attach unredacted cookies, authorization headers, booking customer data, or full page source from authenticated screens. Artifact retention should follow the same data policy as production logs.

Diagnose failures by responsibility

On failure, collect the session ID, capability set, spec and worker name, room ID, current URL, screenshot, and browser console messages. Then classify the problem:

  • Setup API failed before the browser journey.
  • A locator no longer represents the accessible or testing contract.
  • The control never became actionable because of an overlay or validation.
  • The UI confirmed before persistence completed.
  • Another worker modified or deleted the room.
  • The runner lost its WebDriver transport or browser process.

Do not turn on Mocha retries until this classification exists. If retries are later used for known infrastructure noise, retain the first-attempt artifacts and report retry counts. A pass on attempt two is still a maintenance signal.

Use WebdriverIO's session ID to correlate runner logs with a remote provider or Grid. Record capabilities after session creation because the requested browser and effective browser can differ. Do not include provider access keys in artifact URLs or console output.

If the browser command timed out, inspect whether the application was still responsive in the screenshot and whether the WebDriver endpoint logged a transport error. If the command completed but the assertion failed, inspect DOM state and backend data. Those paths have different owners and should not share a generic "flaky UI" label.

Keep a tiny infrastructure canary that opens a static route and asserts one heading. Run it when the browser image, service, or Grid configuration changes. A failing canary prevents a large product suite from producing hundreds of redundant transport failures.

Run a constrained CI job

Provision Node and the browser reproducibly, start the application, wait for its health endpoint, then execute the same config used locally.

YAML
- name: Install dependencies
  run: npm ci

- name: Wait for test environment
  run: npx wait-on "${{ vars.TEST_APP_URL }}/health"

- name: Run WebdriverIO smoke tests
  run: npx wdio run ./wdio.conf.ts --mochaOpts.grep smoke
  env:
    APP_URL: ${{ vars.TEST_APP_URL }}
    API_URL: ${{ vars.TEST_API_URL }}
    TEST_API_TOKEN: ${{ secrets.TEST_API_TOKEN }}

- name: Upload WebdriverIO evidence
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: webdriverio-artifacts
    path: artifacts

Confirm the runner CLI supports any override flags used by the installed configuration. Many teams prefer suite definitions in wdio.conf.ts over a long CI command because config is type-checked and reviewed.

Increase maxInstances only after records, sessions, files, and backend capacity are isolated. A maintainable WebdriverIO suite has one primary runner contract, page methods that speak the product language, explicit API boundaries, and artifacts that identify the failed layer. Retries and plugins can support that architecture, but they cannot replace it.

Split suite names in configuration by release risk so CI selects reviewed groups without depending on shell glob behavior. Pull requests can run the booking smoke path, while scheduled jobs cover cancellation, sold-out inventory, provider errors, and required browsers.

Measure worker queue time, session startup, API setup, and browser execution before adding machines. If session creation dominates, a remote pool or fewer session resets may help. If backend confirmation dominates, more browser instances can overload the same service and make the job slower.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 10, 2026 / Reviewed July 10, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Selenium documentation

    Selenium Project

    Canonical WebDriver, Grid, waits, element, and browser automation guidance.

  2. 02
    WebDriver standard

    W3C

    The browser automation protocol specification behind WebDriver implementations.

  3. 03
    WebdriverIO documentation

    WebdriverIO

    Official runner, configuration, service, and browser automation guidance.

FAQ / QUICK ANSWERS

Questions testers ask

What is WebdriverIO used for?

WebdriverIO is used for browser automation, E2E testing, component testing in some setups, and mobile automation through Appium. It is popular with JavaScript teams that want a flexible test runner, rich services, and WebDriver or DevTools based automation under one ecosystem.

Is WebdriverIO the same as Selenium?

No. WebdriverIO is a JavaScript automation framework that can use the WebDriver protocol, while Selenium is a broader browser automation project with multiple language bindings. WebdriverIO gives JavaScript teams a runner, assertions, services, and framework structure around browser automation.

Should I use Mocha, Jasmine, or Cucumber with WebdriverIO?

Mocha is a practical default for many teams because it is simple and widely understood. Cucumber is useful when the organization genuinely collaborates around Gherkin scenarios. Choose the framework that improves communication, not the one that adds the most ceremony.

Is WebdriverIO good for mobile testing?

Yes, WebdriverIO can automate mobile apps through Appium, which makes it useful for teams that want one JavaScript test ecosystem for web and mobile. Mobile tests still need separate device management, capabilities, waits, and app state control.

What makes WebdriverIO tests flaky?

The usual causes are unstable selectors, weak waits, tests that depend on order, shared data, and environment differences. WebdriverIO provides tools for waiting and retries, but stable automation still depends on clear app state and meaningful assertions.