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
mobile-tests/
package.json
wdio.conf.js
pages/
LoginPage.js
HomePage.js
tests/
login.test.jsKeep the structure small at first. Add folders only when the suite grows.
2. Install the tools
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-reporterYou also need:
npm install -g appium
appium driver install uiautomator2Start the Appium server in a separate terminal:
appium3. Minimal WebdriverIO config
// 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:
ANDROID_APP_PATH=/absolute/path/app-debug.apk npx wdio run wdio.conf.jsOn Windows PowerShell:
$env:ANDROID_APP_PATH="C:\apps\app-debug.apk"
npx wdio run wdio.conf.js4. Login Page Object
// 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
// 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
// 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.
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
| Mistake | Better approach |
|---|---|
| Capabilities copied into every test | Keep them in wdio.conf.js |
| Raw locators inside test files | Use Page Objects |
| App path hardcoded for one machine | Use ANDROID_APP_PATH |
| No wait before tapping | Use waitForDisplayed / waitForEnabled |
| Tests depend on previous login state | Reset app state or create known setup |
Official docs
Practice exercises
- Create the folder structure shown in this lesson.
- Add a
wdio.conf.jswith Android capabilities. - Create
LoginPage.jswith private selector constants. - Write one positive login test and one negative login test.
- Run the suite against an emulator and capture the terminal output.
Homework
Build a small Appium project for any demo app:
- Use WebdriverIO and Appium 2.
- Store app path in an environment variable.
- Create at least two Page Objects.
- Write three tests.
- Use explicit waits before every interaction.
Next: Lesson 05 — Gestures, waits, and app lifecycle (lab05.md).