WebdriverIO with TypeScript: Setup and Best Practices
WebdriverIO has first-class TypeScript support — types ship with the package, the setup wizard generates TypeScript configs, and the entire API surface is typed. Using TypeScript with WebdriverIO catches selector mistakes at compile time, gives you IDE autocomplete for browser APIs, and makes page objects more maintainable. This guide covers setting up and getting the most out of TypeScript with WebdriverIO.
Why TypeScript with WebdriverIO?
JavaScript browser automation tests tend to degrade over time. Selectors get stale, helper functions get called with wrong arguments, return types become unclear. TypeScript prevents whole categories of these problems:
- Autocomplete for browser APIs — no more guessing method names
- Type errors before test runs — catch
$('selector').click()vsawait $('selector').click()issues - Typed page objects — method signatures document what arguments are expected
- Refactoring confidence — rename a method, TypeScript tells you every call site that needs updating
Setup
If you're starting fresh with the WebdriverIO wizard:
npx wdio setup
# When asked: "Do you want to use a compiler?" → select TypeScriptThe wizard generates a TypeScript-ready project. For an existing JavaScript project, migrate manually:
npm install typescript ts-node @types/node --save-devtsconfig.json
WebdriverIO needs specific TypeScript configuration to recognize browser, $, and $$ as globals:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true,
"outDir": "./.tmp",
"types": [
"node",
"@wdio/globals/types" // Registers browser, $, $$, driver as globals
]
},
"include": [
"./test/**/*.ts",
"./wdio.conf.ts"
],
"exclude": [
"node_modules"
]
}The critical line is "@wdio/globals/types" in types. Without it, TypeScript complains that browser, $, and $$ don't exist.
WebdriverIO Config in TypeScript
Rename wdio.conf.js to wdio.conf.ts:
import type { Options } from '@wdio/types';
export const config: Options.Testrunner = {
runner: 'local',
specs: ['./test/specs/**/*.e2e.ts'],
exclude: [],
maxInstances: 4,
capabilities: [{
browserName: 'chrome',
'goog:chromeOptions': {
args: ['--headless', '--no-sandbox', '--disable-dev-shm-usage'],
},
}],
logLevel: 'info',
bail: 0,
baseUrl: 'http://localhost:3000',
waitforTimeout: 10000,
connectionRetryTimeout: 120000,
connectionRetryCount: 3,
services: ['chromedriver'],
framework: 'mocha',
reporters: ['spec'],
mochaOpts: {
ui: 'bdd',
timeout: 60000,
},
afterTest: async function (
test: Mocha.Test,
context: object,
{ error }: { error?: Error }
) {
if (error) {
await browser.saveScreenshot(`./screenshots/${test.title.replace(/\s+/g, '-')}.png`);
}
},
};TypeScript will now enforce that your config options are valid — no more typos in option names.
Your First TypeScript Test
// test/specs/login.e2e.ts
describe('Login', () => {
it('should login with valid credentials', async () => {
await browser.url('/login');
const emailInput = await $('input[name="email"]');
const passwordInput = await $('input[name="password"]');
const submitButton = await $('button[type="submit"]');
await emailInput.setValue('user@example.com');
await passwordInput.setValue('password123');
await submitButton.click();
// TypeScript knows browser.getUrl() returns Promise<string>
const url: string = await browser.getUrl();
expect(url).toContain('/dashboard');
// expect-webdriverio matchers are fully typed
await expect($('h1')).toHaveText('Dashboard');
});
});Typed Page Objects
TypeScript page objects are where the investment really pays off. Method signatures document intent, and TypeScript enforces correct usage:
// test/pageobjects/LoginPage.ts
export interface LoginCredentials {
email: string;
password: string;
rememberMe?: boolean;
}
export interface LoginResult {
success: boolean;
redirectUrl?: string;
errorMessage?: string;
}
export class LoginPage {
// Typed element getters
get emailInput(): ChainablePromiseElement {
return $('input[name="email"]');
}
get passwordInput(): ChainablePromiseElement {
return $('input[name="password"]');
}
get submitButton(): ChainablePromiseElement {
return $('button[type="submit"]');
}
get errorMessage(): ChainablePromiseElement {
return $('[data-testid="error-message"]');
}
get rememberMeCheckbox(): ChainablePromiseElement {
return $('input[type="checkbox"][name="remember"]');
}
async open(): Promise<this> {
await browser.url('/login');
return this;
}
async login(credentials: LoginCredentials): Promise<void> {
await this.emailInput.setValue(credentials.email);
await this.passwordInput.setValue(credentials.password);
if (credentials.rememberMe) {
const checkbox = await this.rememberMeCheckbox;
if (!(await checkbox.isSelected())) {
await checkbox.click();
}
}
await this.submitButton.click();
}
async getErrorMessage(): Promise<string | null> {
try {
await this.errorMessage.waitForDisplayed({ timeout: 3000 });
return this.errorMessage.getText();
} catch {
return null;
}
}
async isDisplayed(): Promise<boolean> {
return this.emailInput.isDisplayed();
}
}
export const loginPage = new LoginPage();Using the typed page object in tests:
import { loginPage, LoginCredentials } from '../../pageobjects/LoginPage.js';
describe('Login', () => {
beforeEach(async () => {
await loginPage.open();
});
it('should login with valid credentials', async () => {
const credentials: LoginCredentials = {
email: 'user@example.com',
password: 'password123',
rememberMe: true,
};
await loginPage.login(credentials);
// TypeScript error if you forget await here
await expect(browser).toHaveUrlContaining('/dashboard');
});
it('should return typed error message', async () => {
await loginPage.login({
email: 'wrong@example.com',
password: 'badpass',
});
const error: string | null = await loginPage.getErrorMessage();
expect(error).not.toBeNull();
expect(error).toContain('Invalid credentials');
});
});Custom Type Declarations
For test utilities that use browser globals, declare custom types:
// test/types/global.d.ts
declare global {
// Extend browser with custom methods if needed
namespace WebdriverIO {
interface Browser {
loginAs(email: string, password: string): Promise<void>;
}
}
}
export {};Implement the custom command:
// test/helpers/commands.ts
browser.addCommand('loginAs', async function (email: string, password: string) {
await browser.url('/login');
await $('input[name="email"]').setValue(email);
await $('input[name="password"]').setValue(password);
await $('button[type="submit"]').click();
await browser.waitUntil(
async () => !(await browser.getUrl()).includes('/login'),
{ timeout: 5000 }
);
});Register in your config:
// wdio.conf.ts
before: async () => {
await import('./test/helpers/commands.js');
},Use with full type safety:
await browser.loginAs('user@example.com', 'password123');
// TypeScript knows this method exists and its signatureEnvironment Variables with TypeScript
Type-safe environment variable access prevents runtime errors from missing config:
// test/config/env.ts
interface TestConfig {
baseUrl: string;
testUserEmail: string;
testUserPassword: string;
headless: boolean;
}
function requireEnv(key: string): string {
const value = process.env[key];
if (!value) {
throw new Error(`Required environment variable ${key} is not set`);
}
return value;
}
export const env: TestConfig = {
baseUrl: process.env.BASE_URL || 'http://localhost:3000',
testUserEmail: requireEnv('TEST_USER_EMAIL'),
testUserPassword: requireEnv('TEST_USER_PASSWORD'),
headless: process.env.HEADLESS !== 'false',
};Use in config and tests:
// wdio.conf.ts
import { env } from './test/config/env.js';
export const config: Options.Testrunner = {
baseUrl: env.baseUrl,
capabilities: [{
browserName: 'chrome',
'goog:chromeOptions': {
args: env.headless ? ['--headless', '--no-sandbox'] : [],
},
}],
};Strict Mode and Common Errors
With "strict": true in tsconfig, TypeScript catches more issues. Common patterns to handle:
Null Checks for Elements
// TypeScript may complain about potential null/undefined
const elements = await $$('.item');
const count = elements.length; // Safe - $$ always returns array
// For single elements, use type narrowing
const button = await $('button');
if (await button.isExisting()) {
await button.click(); // Safe
}Await Chains
// TypeScript doesn't always catch missing awaits, but this pattern helps:
// Using explicit types for intermediate values
const text: string = await $('h1').getText(); // Forces you to think about awaitOptional Element Properties
// When getAttribute might return null
const href = await $('a').getAttribute('href');
if (href !== null) {
expect(href).toContain('/expected-path');
}Running TypeScript Tests
WebdriverIO uses ts-node to run TypeScript directly — no compilation step needed:
# Run all tests
npx wdio run wdio.conf.ts
<span class="hljs-comment"># Run specific spec
npx wdio run wdio.conf.ts --spec <span class="hljs-built_in">test/specs/login.e2e.tsAdd to package.json:
{
"scripts": {
"test": "wdio run wdio.conf.ts",
"test:login": "wdio run wdio.conf.ts --spec test/specs/login.e2e.ts",
"typecheck": "tsc --noEmit"
}
}Run npm run typecheck in CI to catch TypeScript errors before running tests:
# .github/workflows/e2e.yml
- name: Type check
run: npm run typecheck
- name: Run tests
run: npm testPerformance Considerations
TypeScript adds a compilation step via ts-node. For large test suites, this can add startup time. Options:
- Use
ts-node(default) — easiest, some overhead for large suites - Pre-compile to JavaScript —
tsc→ run compiled JS — fastest CI execution - Use
@swc/corewith ts-node — much faster transpilation, no type checking
# Fast transpilation with SWC (no type checking)
npm install @swc/core --save-dev// tsconfig.json
{
"ts-node": {
"swc": true
}
}Summary
TypeScript with WebdriverIO gives you a safer, more maintainable test suite. The initial setup investment — tsconfig, typed page objects, custom command declarations — pays off as your suite grows. IDE autocomplete alone saves significant time during test authoring.
Key setup points:
- Add
"@wdio/globals/types"totsconfig.jsontypes array — this is the most commonly missed step - Type your page object interfaces to document expected inputs and outputs
- Run
tsc --noEmitin CI before tests to catch type errors early - Use typed environment variable access to prevent runtime config errors
Combined with the Page Object Model, TypeScript makes WebdriverIO test suites readable, maintainable, and resilient to refactoring. The Mocha integration works identically with TypeScript — just rename your spec files to .e2e.ts.