WebdriverIO is one of the most powerful tools for automating web application testing. It controls a real browser, interacts with your app the same way a user would, and tells you exactly what passed and what didn't. In this article, I'll walk you through how I built a WebdriverIO automation suite from the ground up — the structure, the patterns, and the real test code behind it.
the implementation
Project Structure
Here's how the project is organized:
| 1 | automation-teststore-webdriverio/ |
| 2 | config/ |
| 3 | production.e2e.conf.js ← separate config for production environment |
| 4 | test/ |
| 5 | helper/ |
| 6 | generator.js ← random test data generator |
| 7 | pageobjects/ |
| 8 | page.js ← base page (shared logic) |
| 9 | homepage.js ← Homepage page object |
| 10 | navbar.js ← Navbar page object |
| 11 | login.page.js ← Login page object |
| 12 | registerPage.js ← Register page object |
| 13 | successRegister.js ← Success page object |
| 14 | specialspage.js ← Specials page object |
| 15 | subnav.js ← Sub navigation page object |
| 16 | specs/ |
| 17 | navbar.spec.js ← Navbar tests |
| 18 | loginPage.spec.js ← Login tests |
| 19 | registerPage.spec.js ← Register tests |
| 20 | wdio.conf.js ← Main configuration file |
| 21 | package.json |
The separation is intentional. Page objects hold the selectors and actions. Spec files hold the test scenarios. They never mix.
Reusable Navbar Component
The Configuration File
Everything starts with wdio.conf.js. This file tells WebdriverIO how to run tests — which browser to use, which files to run, how long to wait, and where to send the report.
| 1 | const browserName = process.env.BROWSER || "firefox"; |
| 2 | |
| 3 | export const config = { |
| 4 | runner: "local", |
| 5 | framework: "mocha", |
| 6 | maxInstances: 10, |
| 7 | waitforTimeout: 10000, |
| 8 | |
| 9 | capabilities: [{ |
| 10 | browserName: browserName, |
| 11 | acceptInsecureCerts: true, |
| 12 | }], |
| 13 | |
| 14 | reporters: [ |
| 15 | "spec", |
| 16 | ["allure", { outputDir: "allure-results" }], |
| 17 | ], |
| 18 | }; |
A few things worth noting here:
Browser is configurable via environment variable. You don't hardcode the browser. Instead, you pass it at runtime:
BROWSER=chrome npx wdio run ./wdio.conf.js
No config change needed to switch browsers.
Test suites are grouped. Instead of running all tests every time, you can run just the suite you need:
| 1 | suites: { |
| 2 | login: ["test/specs/loginPage.spec.js"], |
| 3 | register: ["test/specs/registerPage.spec.js"], |
| 4 | e2e: [ |
| 5 | "test/specs/navbar.spec.js", |
| 6 | "test/specs/loginPage.spec.js", |
| 7 | "test/specs/registerPage.spec.js", |
| 8 | ], |
| 9 | }, |
Run them with:
| 1 | npm run login # runs only login tests |
| 2 | npm run register # runs only register tests |
| 3 | npm run e2e # runs the full suite |
Allure is wired in. Every test run automatically generates a detailed report in allure-results/ that you can open in a browser — showing pass/fail per test, execution time, and error screenshots.
The Page Object Pattern
The most important design decision in this project is the Page Object Model (POM). The idea is simple: every page in the app gets its own class. That class holds all the element selectors and all the actions you can take on that page.
01.
Base Page
| 1 | export default class Page { |
| 2 | open(path) { |
| 3 | return browser.url(`https://automationteststore.com/${path}`) |
| 4 | } |
| 5 | } |
Every page object extends this base class. So opening any page is just one line.
02.
Login Page Object
| 1 | get loginName() { |
| 2 | return $("#loginFrm_loginname"); |
| 3 | } |
| 4 | |
| 5 | get password() { |
| 6 | return $("#loginFrm_password"); |
| 7 | } |
| 8 | |
| 9 | get loginBtn() { |
| 10 | return $('button[title="Login"]'); |
| 11 | } |
| 12 | |
| 13 | get alertErrorMsg() { |
| 14 | return $(".alert-error"); |
| 15 | } |
| 16 | |
| 17 | async login(loginName, password) { |
| 18 | await this.loginName.waitForDisplayed(); |
| 19 | await this.loginName.setValue(loginName); |
| 20 | await this.password.setValue(password); |
| 21 | await this.loginBtn.click(); |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | export default new LoginPage(); |
Notice the login() method — it wraps the entire login flow into one reusable action. In any test that needs a logged-in user, you just call:
await loginPage.login("Cahya123", "Cahya123");
One line. No duplicated steps.
03.
Register Page Object
The Register page object is larger because the registration form has many fields — first name, last name, email, phone, address, city, region, zip code, country, login name, password, confirm password, and privacy policy checkbox. Each field has its own getter:
| 1 | get firstName() { return $("#AccountFrm_firstname"); } |
| 2 | get email() { return $("#AccountFrm_email"); } |
| 3 | get loginName() { return $("#AccountFrm_loginname"); } |
| 4 | get password() { return $("#AccountFrm_password"); } |
| 5 | get privacyPolicyCheckbox() { return $("#AccountFrm_agree"); } |
And each validation message has its own getter too:
| 1 | get minCharFirstnameMsg() { |
| 2 | return $('//span[contains(text(), "First Name must be between 1 and 32 characters!")]'); |
| 3 | } |
| 4 | |
| 5 | get duplicateLoginNameMsg() { |
| 6 | return $('//span[contains(text(), "This login name is not available.")]'); |
| 7 | } |
This means in the test, you never write raw selectors. You always write human-readable property names.
The Test Data Generator
One of the trickiest problems in registration tests is uniqueness. If every test uses the same login name, the second test will always fail with "duplicate login name." The solution is a random data generator:
| 1 | import { animals, uniqueNamesGenerator } from "unique-names-generator"; |
| 2 | |
| 3 | function generateUniqueName() { |
| 4 | let randomName = uniqueNamesGenerator({ |
| 5 | dictionaries: [animals], |
| 6 | length: 1, |
| 7 | }); |
| 8 | while (randomName.length < 10) { |
| 9 | randomName += uniqueNamesGenerator({ dictionaries: [animals], length: 1 }); |
| 10 | } |
| 11 | return randomName.substring(0, 10); |
| 12 | } |
| 13 | |
| 14 | function generatePassword() { |
| 15 | return uniqueNamesGenerator({ dictionaries: [animals], length: 1 }); |
| 16 | } |
| 17 | |
| 18 | function generatePhonenumber() { |
| 19 | return parseInt(Math.random().toFixed(6).replace("0.", "")); |
| 20 | } |
Every time the registration test runs, it generates a unique name, a unique email, a unique phone number, and a unique login name. Tests never collide with each other — or with existing data in the system.
The Test Specs
01.
Navbar Tests
| 1 | beforeEach(async () => { |
| 2 | await browser.maximizeWindow(); |
| 3 | await homepage.open(); |
| 4 | }); |
| 5 | |
| 6 | afterEach(async () => { |
| 7 | await browser.deleteCookies(); |
| 8 | await browser.refresh(); |
| 9 | }); |
| 10 | |
| 11 | it("should display the logo", async () => { |
| 12 | await navbar.logo.waitForDisplayed(); |
| 13 | expect(navbar.logo).toBeDisplayed(); |
| 14 | }); |
| 15 | |
| 16 | it("navigate to homepage on clicking logo button", async () => { |
| 17 | await navbar.logo.click(); |
| 18 | await expect(browser).toHaveUrl("https://automationteststore.com/"); |
| 19 | }); |
| 20 | |
| 21 | it("dropdown is appear on hovering over account button", async () => { |
| 22 | await navbar.accountBtn.moveTo(); |
| 23 | await expect(navbar.accountDropdownMenu).toBeDisplayed(); |
| 24 | }); |
| 25 | }); |
beforeEach runs before every test — maximizes the window and opens the homepage fresh. afterEach clears cookies and refreshes — so every test starts clean, with no leftover session data.
02.
Login Tests
| 1 | it("Should be able to show message when login with invalid credential", async () => { |
| 2 | await loginPage.loginName.setValue("1231231"); |
| 3 | await loginPage.password.setValue("123123"); |
| 4 | await loginPage.loginBtn.click(); |
| 5 | await expect(loginPage.alertErrorMsg).toBeDisplayed(); |
| 6 | }); |
| 7 | |
| 8 | it("Should be able to login", async () => { |
| 9 | await loginPage.login("Cahya123", "Cahya123"); |
| 10 | }); |
| 11 | }); |
Two scenarios — invalid credentials show an error, valid credentials succeed. Clean, readable, and maintainable.
03.
Register Tests — Validation Coverage
The register spec is the most comprehensive. It tests every single validation rule the form enforces:
| Test | What it verifies |
|---|---|
| First name empty | Error message appears |
| First name > 32 characters | Error message appears |
| Last name empty | Error message appears |
| Email empty | Error message appears |
| Address < 3 characters | Error message appears |
| Address > 128 characters | Error message appears |
| City < 3 characters | Error message appears |
| City > 128 characters | Error message appears |
| Region not selected | Error message appears |
| ZIP code empty | Error message appears |
| Login name empty | Error message appears |
| Login name < 5 characters | Error message appears |
| Login name > 64 characters | Error message appears |
| Duplicate login name | "Not available" message appears |
| Password < 4 characters | Error message appears |
| Password > 20 characters | Error message appears |
| Password masked | Input type is password |
| Password ≠ confirm password | Alert error appears |
| Privacy policy not checked | Alert error appears |
| All valid data | Redirects to success page |
The Allure Report
After every run, results are saved to allure-results/. You can generate and open the visual report with:
| 1 | npx allure generate allure-results --clean |
| 2 | npx allure open |
The report shows every test by name, pass/fail status, execution duration, and — on failure — the exact error and the line where it happened.
What This Suite Covers
| Area | Coverage |
|---|---|
| Navbar — element visibility | ✅ |
| Navbar — all navigation links | ✅ |
| Navbar — hover dropdown | ✅ |
| Login — valid credentials | ✅ |
| Login — invalid credentials | ✅ |
| Register — all field validations | ✅ |
| Register — duplicate login name | ✅ |
| Register — password security | ✅ |
| Register — full happy path | ✅ |
| Session cleanup between tests | ✅ |
| Random test data — no collisions | ✅ |
| Multi-browser support | ✅ |
| Allure reporting | ✅ |
A WebdriverIO suite built with the Page Object Model is clean, maintainable, and readable by anyone on the team. Selectors live in page objects. Test logic lives in spec files. Random data generators keep tests independent. And Allure reports make results easy to share with clients and stakeholders. When a bug appears, you know exactly which test caught it, what the expected behavior was, and what the app actually did instead.
the testing stack
Every tool chosen with purpose — from feature to assertion.
WebdriverIO
for browser automation and element interaction
Jenkins
runs the load tests automatically as part of the CI/CD pipeline
Allure Report
to visualize test results in a clean, shareable report