Lesson 4 of 6

Lesson 04 — Building Your First Appium Test Project

Title: WebdriverIO setup, project structure, and one complete mobile test
Description: Learn how to turn Appium concepts into a working JavaScript test project. This lesson walks through a practical WebdriverIO + Appium structure, configuration, Page Object classes, hooks, and a complete login test that starts and closes sessions cleanly.

Why it matters for QA: Beginners often understand Appium in theory but get stuck when creating a real project. A clean starter structure prevents messy tests, duplicated capabilities, and forgotten session cleanup.


1. Recommended project structure

texttext
mobile-tests/
  package.json
  wdio.conf.js
  pages/
    LoginPage.js
    HomePage.js
  tests/
    login.test.js

Keep the structure small at first. Add folders only when the suite grows.


2. Install the tools

bashbash
npm init -y
npm pkg set type=module
npm install --save-dev @wdio/cli webdriverio @wdio/globals
npm install --save-dev @wdio/local-runner @wdio/mocha-framework
npm install --save-dev @wdio/spec-reporter

You also need:

bashbash
npm install -g appium
appium driver install uiautomator2

Start the Appium server in a separate terminal:

bashbash
appium

3. Minimal WebdriverIO config

javascriptjavascript
// wdio.conf.js
export const config = {
  runner: "local",
  specs: ["./tests/**/*.test.js"],
  maxInstances: 1,
  framework: "mocha",
  reporters: ["spec"],
  logLevel: "info",
  hostname: "localhost",
  port: 4723,
  path: "/",
  capabilities: [
    {
      platformName: "Android",
      "appium:automationName": "UiAutomator2",
      "appium:deviceName": "Pixel_6_API_33",
      "appium:app": process.env.ANDROID_APP_PATH,
      "appium:autoGrantPermissions": true,
      "appium:noReset": false,
    },
  ],
  mochaOpts: {
    timeout: 60_000,
  },
};

Use environment variables for machine-specific paths:

bashbash
ANDROID_APP_PATH=/absolute/path/app-debug.apk npx wdio run wdio.conf.js

On Windows PowerShell:

powershellpowershell
$env:ANDROID_APP_PATH="C:\apps\app-debug.apk"
npx wdio run wdio.conf.js

4. Login Page Object

javascriptjavascript
// pages/LoginPage.js
import { $ } from "@wdio/globals";

const SELECTORS = {
  emailInput: "~emailInput",
  passwordInput: "~passwordInput",
  loginButton: "~loginButton",
  errorMessage: "~loginErrorMessage",
};

const DEFAULT_TIMEOUT = 10_000;

export class LoginPage {
  async enterEmail(email) {
    const emailInput = await $(SELECTORS.emailInput);
    await emailInput.waitForDisplayed({ timeout: DEFAULT_TIMEOUT });
    await emailInput.setValue(email);
  }

  async enterPassword(password) {
    const passwordInput = await $(SELECTORS.passwordInput);
    await passwordInput.waitForDisplayed({ timeout: DEFAULT_TIMEOUT });
    await passwordInput.setValue(password);
  }

  async tapLoginButton() {
    const loginButton = await $(SELECTORS.loginButton);
    await loginButton.waitForEnabled({ timeout: DEFAULT_TIMEOUT });
    await loginButton.click();
  }

  async login({ email, password }) {
    await this.enterEmail(email);
    await this.enterPassword(password);
    await this.tapLoginButton();
  }

  async getErrorMessage() {
    const errorMessage = await $(SELECTORS.errorMessage);
    await errorMessage.waitForDisplayed({ timeout: DEFAULT_TIMEOUT });
    return errorMessage.getText();
  }
}

The test should not know locator strings. It should use page actions.


5. Home Page Object

javascriptjavascript
// pages/HomePage.js
import { $ } from "@wdio/globals";

const SELECTORS = {
  title: "~homeTitle",
};

const DEFAULT_TIMEOUT = 10_000;

export class HomePage {
  async isLoaded() {
    const title = await $(SELECTORS.title);
    await title.waitForDisplayed({ timeout: DEFAULT_TIMEOUT });
    return title.isDisplayed();
  }
}

Small page objects are fine. Do not wait until a page object becomes "large enough."


6. First complete test

javascriptjavascript
// tests/login.test.js
import { expect } from "@wdio/globals";
import { HomePage } from "../pages/HomePage.js";
import { LoginPage } from "../pages/LoginPage.js";

describe("Login screen", () => {
  it("should log in with valid credentials", async () => {
    const loginPage = new LoginPage();
    const homePage = new HomePage();

    await loginPage.login({
      email: "qa@example.com",
      password: "Secret123!",
    });

    await expect(await homePage.isLoaded()).toBe(true);
  });

  it("should show an error with wrong password", async () => {
    const loginPage = new LoginPage();

    await loginPage.login({
      email: "qa@example.com",
      password: "wrong-password",
    });

    await expect(await loginPage.getErrorMessage()).toBe(
      "Invalid email or password"
    );
  });
});

WebdriverIO provides expect, browser, and $ through @wdio/globals.


7. Good hooks

Use hooks for state that every test needs.

javascriptjavascript
import { browser } from "@wdio/globals";

beforeEach(async () => {
  await browser.activateApp("com.example.myshop");
});

afterEach(async () => {
  await browser.terminateApp("com.example.myshop");
});

Keep hooks short. If a hook becomes a long script, move the details into a named helper.


8. Common project mistakes

MistakeBetter approach
Capabilities copied into every testKeep them in wdio.conf.js
Raw locators inside test filesUse Page Objects
App path hardcoded for one machineUse ANDROID_APP_PATH
No wait before tappingUse waitForDisplayed / waitForEnabled
Tests depend on previous login stateReset app state or create known setup

Official docs


Practice exercises

  1. Create the folder structure shown in this lesson.
  2. Add a wdio.conf.js with Android capabilities.
  3. Create LoginPage.js with private selector constants.
  4. Write one positive login test and one negative login test.
  5. Run the suite against an emulator and capture the terminal output.

Homework

Build a small Appium project for any demo app:

  1. Use WebdriverIO and Appium 2.
  2. Store app path in an environment variable.
  3. Create at least two Page Objects.
  4. Write three tests.
  5. Use explicit waits before every interaction.

Next: Lesson 05 — Gestures, waits, and app lifecycle (lab05.md).