Mobile Automation Framework with CI/CD

Build a job-ready Appium test framework with GitHub Actions and Allure reports.

Introduction

30 Second Summary

When you apply for a QA automation role, hiring managers want proof that you can build frameworks, not just talk about them. A single GitHub repository link on your resume can speak louder than any certification.

In this project, you will build a complete mobile test automation framework from scratch and deploy it as a live portfolio piece on GitHub. Your repo will include Page Object Model architecture, data-driven tests, a CI/CD pipeline running on your real phone, and a shareable Allure test report.

What You'll Build

A GitHub repository containing a professional mobile automation framework with Appium, pytest, GitHub Actions, and Allure reporting, all triggered automatically on every push.

By the end of this project, you'll have:

  • A Page Object Model framework with reusable page classes for the WebdriverIO Demo App on your Samsung Galaxy S9+.
  • Data-driven tests powered by external JSON files and pytest parametrize, proving you can scale test coverage efficiently.
  • A working CI/CD pipeline on GitHub Actions with a self-hosted runner connected to your real device, plus a live Allure report deployed to GitHub Pages.
  • Secret Mission: Add a retry mechanism for flaky tests and display retry history in the Allure report.

Are there any prerequisites?

This project assumes you have basic Appium knowledge (launching a session, locating elements with Appium Inspector), Python fundamentals, and familiarity with terminal commands. You also need a GitHub account and a Samsung Galaxy S9+ (or similar Android device) connected via USB.

You should already have Python, Node.js, Appium Server, ADB, pytest, VS Code, and Git installed on your machine. Step 1 covers the remaining tools you need.

Initialize the Git Repository and Install Dependencies

Your portfolio project lives in a single GitHub repository. When an interviewer clicks your link, they see everything: the framework architecture, the CI/CD pipeline, the test reports, and the README explaining your decisions.

Before you can write any automation code, you need the repository created, your Python packages installed, and the demo app loaded onto your phone. This step sets up that foundation.

In this step, get ready to:
  • Create a GitHub repository with professional defaults.
  • Install Python packages and save them to a requirements file.
  • Install the Allure CLI and the demo app on your Samsung S9+.
Create a new GitHub repository

Your repository is the single artifact you share on your resume. Starting it with a README, a license, and a proper .gitignore means it looks professional from the very first commit.

  • Open your browser and sign in to GitHub.
  • Click the + icon in the top-right corner of the page.
  • Select New repository.
  • Enter mobile-test-framework in the Repository name field.
  • Enter a short description such as Mobile test automation framework with Appium, pytest, and CI/CD.
  • Select Public so interviewers can view your repo without needing access.
  • Check the Add a README file checkbox.
  • In the Add .gitignore dropdown, select Python.
  • In the Choose a license dropdown, select MIT License.
  • Click Create repository.

GitHub redirects you to your new repository page. You should see the README, LICENSE, and .gitignore files listed.

Why MIT License?

MIT is the most permissive open-source license. It tells interviewers and collaborators they can reference or fork your work freely. For a portfolio project, it signals openness and professionalism.

Clone the repository and install Python dependencies

Now you need a local copy of the repository on your machine. You will also create an isolated Python environment so your project dependencies do not interfere with other Python projects.

  • On your repository page, click the green Code button and copy the HTTPS URL.
  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Clone your repository by running this command:
git clone [[YOUR_REPO_URL="https://github.com/your-username/mobile-test-framework.git"]]
  • Move into your project folder by running:
cd mobile-test-framework
  • Confirm the clone worked by running:
ls

You should see README.md, LICENSE, and .gitignore listed in the output.

Don't see the files?

Make sure you ran cd mobile-test-framework after cloning. If the folder name differs from what you expected, run ls ~/Desktop to find it.

Why can't I see my cloned repository?

  • Create a Python virtual environment by running:
python -m venv venv
  • Activate the virtual environment by running:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

Your terminal prompt should now show (venv) at the beginning, confirming the virtual environment is active.

What is a virtual environment?

A virtual environment is an isolated Python installation inside your project folder. Packages you install here stay contained to this project. This prevents version conflicts between different projects on your machine.

  • Install the three project dependencies by running:
pip install Appium-Python-Client==5.3.1 allure-pytest==2.16.0 pytest==8.3.4

What are these packages?

  • Appium-Python-Client lets your Python code communicate with the Appium server to control your phone.
  • allure-pytest is a pytest plugin that captures test results in a format the Allure reporting tool understands.
  • pytest is the test framework that discovers and runs your test functions.

You should see Successfully installed followed by a list of packages at the end of the output.

Seeing a permission error or pip not found?

Make sure you activated the virtual environment first. Your prompt should show (venv) before the path. If you see pip: command not found, try using pip3 instead.

Help me fix my pip install error

  • Create the requirements.txt file that records your exact dependency versions. Open VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows), typing VS Code, and pressing Enter.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the mobile-test-framework folder, and click Open.
  • In the Explorer sidebar on the left, right-click in the empty space below the existing files.
  • Select New File and name it requirements.txt.
  • Paste the following content into the file:
Appium-Python-Client==5.3.1
allure-pytest==2.16.0
pytest==8.3.4

What does this file do?

Pinning exact versions (with ==) guarantees that anyone who clones your repo gets the same package versions you used. This prevents "it works on my machine" problems. Your CI/CD pipeline will also use this file to install dependencies.

  • Save requirements.txt with Cmd+S (macOS) or Ctrl+S (Windows).

Next, replace the GitHub-generated .gitignore with a custom version tailored to this project. The default Python template includes many entries you do not need, and is missing entries specific to your automation framework.

  • In the Explorer sidebar, click .gitignore to open it.
  • Select all the existing content with Cmd+A (macOS) or Ctrl+A (Windows) and delete it.
  • Paste the following content:
__pycache__/
*.pyc
.pytest_cache/
allure-results/
allure-report/
logs/
.env
venv/
*.egg-info/
dist/
build/

What does this file do?

  • allure-results/ and allure-report/ prevent large generated report files from cluttering your repo.
  • logs/ keeps timestamped log files out of version control.
  • venv/ excludes your virtual environment since anyone can recreate it from requirements.txt.
  • .env prevents accidental commits of sensitive environment variables.
  • Save .gitignore.

✔️ Awesome, I've got everything!

Great. Double check that both files are saved. Your Explorer sidebar should show requirements.txt and .gitignore alongside the existing README.md and LICENSE.

ⓧ I'd like to double check the full code

Here is the complete content for both files:

Appium-Python-Client==5.3.1
allure-pytest==2.16.0
pytest==8.3.4
__pycache__/
*.pyc
.pytest_cache/
allure-results/
allure-report/
logs/
.env
venv/
*.egg-info/
dist/
build/
Install the Allure CLI and the demo app

Allure is the reporting tool that turns raw test results into a polished HTML dashboard. You need its CLI installed locally to preview reports. You also need the WebdriverIO Demo App loaded onto your phone so your tests have something to automate.

  • Install the Allure CLI by running the command for your platform:

macOS

brew install allure

Windows

Allure is installed via Scoop, a command-line package manager for Windows. If you do not have Scoop yet, run this in PowerShell first:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
irm get.scoop.sh | iex

Then install Allure:

scoop install allure

Why does Allure need Java?

The Allure CLI is a Java application. Most package managers (Homebrew, Scoop) handle the Java dependency automatically during installation. If you see a Java-related error, install Java 8 or later first, then retry the Allure install.

  • Verify Allure installed correctly by running:
allure --version

You should see a version number printed, confirming the CLI is available.

Seeing 'allure: command not found'?

Close your current terminal and open a new one. Package managers sometimes require a fresh terminal session to update your PATH. If it still fails, check that Scoop (Windows) or Homebrew (macOS) completed without errors.

Help me fix the allure command not found error

Now download and install the demo app on your Samsung S9+. Make sure your phone is connected via USB with USB debugging enabled.

  • Verify your phone is connected by running:
adb devices

You should see your device serial number listed with device next to it. If it says unauthorized, check your phone screen for a USB debugging permission prompt and tap Allow.

Note your device serial number

The alphanumeric string shown in the adb devices output is your device UDID. You will need this value in Step 2 when you create the configuration file. Write it down or keep this terminal open.

  • Download the WebdriverIO Native Demo App APK v2.2.0 by running:

macOS

curl -L -o android.wdio.native.app.v2.2.0.apk https://github.com/webdriverio/native-demo-app/releases/download/v2.2.0/android.wdio.native.app.v2.2.0.apk

Windows

Invoke-WebRequest -Uri https://github.com/webdriverio/native-demo-app/releases/download/v2.2.0/android.wdio.native.app.v2.2.0.apk -OutFile android.wdio.native.app.v2.2.0.apk
  • Install the APK on your Samsung S9+ by running:
adb install android.wdio.native.app.v2.2.0.apk

You should see Success printed in the terminal once the install finishes.

  • On your phone, open the app drawer and confirm you see an app called "WDIO Native Demo" or "WebdriverIO".

ADB install failing?

If you see INSTALL_FAILED_ALREADY_EXISTS, the app is already on your phone from a previous session. Run adb uninstall com.wdiodemoapp first, then retry the install.

If you see error: no devices/emulators found, reconnect your USB cable and run adb devices again to confirm the connection.

Help me fix this ADB install error

What is the WebdriverIO Demo App?

This is an open-source app built specifically for practicing mobile automation. It includes a Login screen with validation rules, a Forms screen with text inputs and switches, swipe gestures, and drag-and-drop. Each element has stable accessibility IDs, making it ideal for writing reliable locators.

Your foundation is set. You have a professional GitHub repository, all three Python dependencies installed, the Allure CLI ready, and the demo app loaded on your phone. Next up, you will build out the project structure and configuration layer that makes your framework environment-aware.

Build the Project Structure and Configuration Layer

Your dependencies are installed and your device is ready. Now you need to build the skeleton that makes this repository look professional the moment an interviewer opens it.

A clean folder structure signals that you understand framework architecture. In this step, you will create the directory layout, a centralized configuration class, and a logging utility that captures every test action.

In this step, get ready to:
  • Create the full directory structure following Page Object Model conventions.
  • Build an environment-aware configuration class that works locally and in CI.
  • Set up Python logging to capture timestamped test execution details.
Create the directory structure

Each folder in your project has a specific role. The pages/ folder holds your page objects, tests/ holds your test cases, test_data/ stores JSON data files, and utils/ contains shared utilities like the logger.

  • In your VS Code terminal, create all the project folders by running this command:
mkdir -p pages tests test_data utils .github/workflows

Windows users: use Git Bash

The mkdir -p command works in Git Bash, which you already have from installing Git. In VS Code, click the dropdown arrow next to the + icon in your terminal panel and select Git Bash.

  • Create an empty __init__.py file in the utils/ folder to make it a Python package:
touch utils/__init__.py
  • Confirm the directories exist by running:
ls

You should see pages, tests, test_data, utils, and .github listed alongside your existing requirements.txt and .gitignore.

Don't see .github in the output?

Folders starting with a dot are hidden by default. Run ls -a to show hidden files and folders. You should see .github in the list.

Why isn't my .github folder showing up?

Create the configuration class

A centralized config class stores all your Appium session details in one place. It uses environment variables with fallback defaults so the same code runs on your local machine and in a CI/CD pipeline without any code changes.

  • In the VS Code Explorer sidebar, right-click the project root and select New File.
  • Name the file config.py and paste the following code:
import os


class Config:
    # Appium server URL - overridable for remote execution
    APPIUM_SERVER = os.environ.get("APPIUM_SERVER", "http://127.0.0.1:4723")
    PLATFORM_NAME = "Android"
    # Device details - read from environment for CI flexibility
    PLATFORM_VERSION = os.environ.get("PLATFORM_VERSION", "10")
    DEVICE_NAME = os.environ.get("DEVICE_NAME", "Samsung Galaxy S9+")
    AUTOMATION_NAME = "UiAutomator2"
    # WebdriverIO Demo App identifiers
    APP_PACKAGE = "com.wdiodemoapp"
    APP_ACTIVITY = "com.wdiodemoapp.MainActivity"
    # Your physical device's unique ID
    UDID = os.environ.get("DEVICE_UDID", "[[DEVICE_UDID=\"your-device-udid-here\"]]")
    NO_RESET = True
    # Wait timeouts in seconds
    IMPLICIT_WAIT = 10
    EXPLICIT_WAIT = 15

What does this code do?

The Config class holds every setting your Appium driver needs to connect to your phone. The os.environ.get() pattern checks for an environment variable first and falls back to a default value if one is not set.

This means locally, the defaults match your Samsung S9+. In GitHub Actions, you can override them via environment variables without editing any code.

  • Find your device's UDID by running this in your terminal:
adb devices
  • Copy the alphanumeric string shown next to device in the output. This is your UDID.
  • Replace the placeholder in config.py with your actual UDID value.
  • Save config.py.

Why NO_RESET is True

Setting NO_RESET = True prevents Appium from reinstalling the app between each test session. This speeds up test execution significantly on a real device.

adb devices shows no output?

Make sure your Samsung S9+ is connected via USB and that USB Debugging is enabled in Developer Options. If you see unauthorized next to your device, check your phone for an authorization prompt and tap Allow.

Help me troubleshoot why adb devices is not detecting my phone.

✔️ Awesome, I've got everything!

Great. Make sure you have saved config.py with your real UDID before moving on.

ⓧ I'd like to double check the full code

import os


class Config:
    APPIUM_SERVER = os.environ.get("APPIUM_SERVER", "http://127.0.0.1:4723")
    PLATFORM_NAME = "Android"
    PLATFORM_VERSION = os.environ.get("PLATFORM_VERSION", "10")
    DEVICE_NAME = os.environ.get("DEVICE_NAME", "Samsung Galaxy S9+")
    AUTOMATION_NAME = "UiAutomator2"
    APP_PACKAGE = "com.wdiodemoapp"
    APP_ACTIVITY = "com.wdiodemoapp.MainActivity"
    UDID = os.environ.get("DEVICE_UDID", "your-device-udid-here")
    NO_RESET = True
    IMPLICIT_WAIT = 10
    EXPLICIT_WAIT = 15
Create the logging utility

When a test fails in CI, you need more context than just "assertion failed." A logger captures every action your framework takes, including which element was clicked and what text was entered. These timestamped logs make debugging faster.

  • In the VS Code Explorer sidebar, right-click the utils folder and select New File.
  • Name the file logger.py and paste the following code:
import logging
import os
from datetime import datetime


def setup_logger(name):
    # Create a named logger instance
    logger = logging.getLogger(name)
    logger.setLevel(logging.INFO)

    # Build path to logs/ directory at the project root
    log_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logs")
    os.makedirs(log_dir, exist_ok=True)

    # Generate a unique log file for each test run
    timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
    log_file = os.path.join(log_dir, f"test_run_{timestamp}.log")

    # Write logs to a file
    file_handler = logging.FileHandler(log_file)
    file_handler.setLevel(logging.INFO)

    # Also print logs to the terminal
    console_handler = logging.StreamHandler()
    console_handler.setLevel(logging.INFO)

    # Consistent format: timestamp - logger name - level - message
    formatter = logging.Formatter(
        "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
    )
    file_handler.setFormatter(formatter)
    console_handler.setFormatter(formatter)

    logger.addHandler(file_handler)
    logger.addHandler(console_handler)

    return logger

What does this code do?

  • setup_logger(name) creates a logger you can import anywhere in the project. Pass a name like "base_page" to identify which module produced each log line.
  • Every test run creates a new timestamped file in a logs/ folder. This way you never overwrite previous logs and can compare runs side-by-side.
  • The dual-handler setup (file + console) means you see output live in the terminal AND have a persistent record saved to disk.
  • Save utils/logger.py.
  • Verify the logger works by running this quick test in your terminal:
python -c "from utils.logger import setup_logger; log = setup_logger('test'); log.info('Logger is working')"

You should see a formatted log message printed to your terminal with a timestamp, the name test, the level INFO, and the message Logger is working. A new logs/ folder also appears in your project with a timestamped log file inside.

Seeing ModuleNotFoundError?

Make sure you created utils/__init__.py in the earlier step. Without this file, Python does not recognize utils/ as a package. Also confirm you are running the command from the project root folder (the one containing config.py and requirements.txt).

Help me fix my ModuleNotFoundError when importing from utils.

✔️ Awesome, I've got everything!

Great. Double check you have saved utils/logger.py and that the logs/ folder appeared in your project.

ⓧ I'd like to double check the full code

import logging
import os
from datetime import datetime


def setup_logger(name):
    logger = logging.getLogger(name)
    logger.setLevel(logging.INFO)

    log_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logs")
    os.makedirs(log_dir, exist_ok=True)

    timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
    log_file = os.path.join(log_dir, f"test_run_{timestamp}.log")

    file_handler = logging.FileHandler(log_file)
    file_handler.setLevel(logging.INFO)

    console_handler = logging.StreamHandler()
    console_handler.setLevel(logging.INFO)

    formatter = logging.Formatter(
        "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
    )
    file_handler.setFormatter(formatter)
    console_handler.setFormatter(formatter)

    logger.addHandler(file_handler)
    logger.addHandler(console_handler)

    return logger

Your project skeleton is in place. You have a clean folder structure, a config class that adapts to any environment, and a logger ready to capture every action. Next up, you will build the Page Object Model classes that turn raw Appium commands into clean, reusable methods.

Build the Page Object Model Classes

You have a solid project structure and configuration layer in place. Now it is time to build the classes that will actually interact with your app.

Without a design pattern, test scripts become repetitive walls of driver.find_element() calls. The Page Object Model (POM) solves this by wrapping each screen of your app in its own class. Locators live in one place, actions become readable method calls, and when the app UI changes you only update a single file.

In this step, get ready to:
  • Create a BasePage class with reusable methods like click, send_keys, and swipe.
  • Create a LoginPage class with locators and actions for the login screen.
  • Create a FormsPage class with locators and actions for the forms screen.
Create the BasePage class

The BasePage is the parent class that every other page object will inherit from. It holds common actions (clicking, typing, waiting, swiping) so you never rewrite the same boilerplate in each page.

  • Create an empty pages/__init__.py file to make the pages/ folder a Python package. Run this command in your project root:
touch pages/__init__.py
  • Create a new file called base_page.py inside the pages/ folder, then paste the following code into it:
from appium.webdriver.common.appiumby import AppiumBy
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from utils.logger import setup_logger

# Create a logger specific to this module
logger = setup_logger("base_page")


class BasePage:
    def __init__(self, driver):
        self.driver = driver
        # Set a 15-second explicit wait as the default
        self.wait = WebDriverWait(driver, 15)

    def find_element(self, locator):
        # Wait until the element is present in the DOM
        return self.wait.until(EC.presence_of_element_located(locator))

    def click(self, locator):
        logger.info(f"Clicking element: {locator}")
        # Wait until the element is clickable, then click it
        element = self.wait.until(EC.element_to_be_clickable(locator))
        element.click()

    def send_keys(self, locator, text):
        logger.info(f"Sending keys to element: {locator}")
        element = self.find_element(locator)
        # Clear existing text before typing new text
        element.clear()
        element.send_keys(text)

    def get_text(self, locator):
        element = self.find_element(locator)
        text = element.text
        logger.info(f"Got text '{text}' from element: {locator}")
        return text

    def is_displayed(self, locator):
        # Return False instead of crashing if the element doesn't exist
        try:
            element = self.find_element(locator)
            return element.is_displayed()
        except Exception:
            return False

    def swipe_left(self):
        # Calculate coordinates to swipe from 80% to 20% of screen width
        size = self.driver.get_window_size()
        start_x = int(size["width"] * 0.8)
        end_x = int(size["width"] * 0.2)
        y = int(size["height"] * 0.5)
        self.driver.swipe(start_x, y, end_x, y, 800)

    def swipe_right(self):
        # Calculate coordinates to swipe from 20% to 80% of screen width
        size = self.driver.get_window_size()
        start_x = int(size["width"] * 0.2)
        end_x = int(size["width"] * 0.8)
        y = int(size["height"] * 0.5)
        self.driver.swipe(start_x, y, end_x, y, 800)

What does this code do?

This class wraps raw Selenium and Appium calls into clean, reusable methods:

  • WebDriverWait with expected_conditions ensures tests wait for elements to be ready before interacting, preventing flaky "element not found" errors.
  • Every action logs what it does via the logger you built in the previous step. This gives you a detailed trace when debugging failures.
  • is_displayed() catches exceptions and returns False instead of crashing. This makes assertions cleaner in your test methods.
  • Save pages/base_page.py.
  • Verify the import works by running this in your terminal:
python -c "from pages.base_page import BasePage; print('BasePage imported successfully')"

You should see BasePage imported successfully printed to the console.

Seeing a ModuleNotFoundError?

  • Make sure you created pages/__init__.py. Without it, Python does not recognize pages/ as a package.
  • Confirm you are running the command from your project root folder (the one containing config.py), not from inside the pages/ folder.
  • Check that your virtual environment is activated and you ran pip install -r requirements.txt in the previous step.

Why can't Python find my BasePage module?

Create the LoginPage class

The LoginPage inherits from BasePage and adds screen-specific locators and actions for the WebdriverIO Demo App login screen. Each locator is stored as a class-level tuple so it only needs updating in one place if the UI changes.

  • Create a new file called login_page.py inside the pages/ folder, then paste the following code into it:
from appium.webdriver.common.appiumby import AppiumBy
from pages.base_page import BasePage


class LoginPage(BasePage):
    # Locators for each element on the login screen
    LOGIN_TAB = (AppiumBy.ACCESSIBILITY_ID, "Login")
    EMAIL_INPUT = (AppiumBy.ACCESSIBILITY_ID, "input-email")
    PASSWORD_INPUT = (AppiumBy.ACCESSIBILITY_ID, "input-password")
    LOGIN_BUTTON = (AppiumBy.ACCESSIBILITY_ID, "button-LOGIN")
    ERROR_MESSAGE = (AppiumBy.XPATH, "//android.widget.TextView[contains(@text, 'Please')]")
    SUCCESS_MESSAGE = (AppiumBy.ACCESSIBILITY_ID, "You are logged in!")

    def navigate_to_login(self):
        self.click(self.LOGIN_TAB)

    def enter_email(self, email):
        self.send_keys(self.EMAIL_INPUT, email)

    def enter_password(self, password):
        self.send_keys(self.PASSWORD_INPUT, password)

    def tap_login_button(self):
        self.click(self.LOGIN_BUTTON)

    def get_error_message(self):
        return self.get_text(self.ERROR_MESSAGE)

    def is_login_successful(self):
        return self.is_displayed(self.SUCCESS_MESSAGE)

    # Convenience method that combines the full login flow
    def login(self, email, password):
        self.navigate_to_login()
        self.enter_email(email)
        self.enter_password(password)
        self.tap_login_button()

What does this code do?

  • Each locator is a tuple of (strategy, value). AppiumBy.ACCESSIBILITY_ID targets elements by their accessibility label, which is the most stable selector for mobile apps.
  • The ERROR_MESSAGE locator uses XPath with contains() because the exact error text varies depending on the validation that fails.
  • The login() method is a convenience wrapper. Tests that need the full login flow call one method instead of repeating four separate steps.
  • Save pages/login_page.py.
  • Verify the import works by running:
python -c "from pages.login_page import LoginPage; print('LoginPage imported successfully')"

You should see LoginPage imported successfully in the output.

Import error on LoginPage?

  • If you see cannot import name 'BasePage', double-check that base_page.py is saved and the class name is spelled exactly BasePage (capital B, capital P).
  • Ensure the import line reads from pages.base_page import BasePage with no typos in the module path.

Why is my LoginPage import failing?

Create the FormsPage class

The FormsPage covers the Forms tab of the demo app. It handles text input fields and the toggle switch element, which you will test with multiple data sets in a later step.

  • Create a new file called forms_page.py inside the pages/ folder, then paste the following code into it:
from appium.webdriver.common.appiumby import AppiumBy
from pages.base_page import BasePage


class FormsPage(BasePage):
    # Locators for each element on the forms screen
    FORMS_TAB = (AppiumBy.ACCESSIBILITY_ID, "Forms")
    INPUT_FIELD = (AppiumBy.ACCESSIBILITY_ID, "text-input")
    INPUT_RESULT = (AppiumBy.ACCESSIBILITY_ID, "input-text-result")
    SWITCH_ELEMENT = (AppiumBy.ACCESSIBILITY_ID, "switch")
    SWITCH_TEXT = (AppiumBy.ACCESSIBILITY_ID, "switch-text")

    def navigate_to_forms(self):
        self.click(self.FORMS_TAB)

    def enter_text(self, text):
        self.send_keys(self.INPUT_FIELD, text)

    def get_input_result(self):
        # Read back what the app displays after typing
        return self.get_text(self.INPUT_RESULT)

    def toggle_switch(self):
        self.click(self.SWITCH_ELEMENT)

    def get_switch_text(self):
        # Read the label that changes when the switch is toggled
        return self.get_text(self.SWITCH_TEXT)

What does this code do?

  • INPUT_RESULT is a read-only label in the demo app that mirrors whatever you type into INPUT_FIELD. Your tests will compare these two values to confirm typing works correctly.
  • The switch element has a text label that changes state when toggled. Your test will read the text before and after tapping to verify the toggle works.
  • Save pages/forms_page.py.
  • Verify all three page objects import correctly by running:
python -c "from pages.base_page import BasePage; from pages.login_page import LoginPage; from pages.forms_page import FormsPage; print('All page objects imported successfully')"

You should see All page objects imported successfully with no errors.

Seeing a circular import error?

  • Make sure forms_page.py and login_page.py both import from pages.base_page and not from each other.
  • Confirm that pages/__init__.py is empty. Adding imports there can cause unexpected circular dependencies.

Help me fix my page object imports.

Why Page Object Model over plain scripts?

Every QA job posting lists POM as a required skill. The pattern separates "where things are" (locators) from "what tests do" (assertions). When a developer changes a button's accessibility ID, you update one line in one page class instead of hunting through dozens of test files.

Interviewers specifically look for this architecture because it proves you can build maintainable, scalable test suites.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files in the pages/ folder.

ⓧ I'd like to double check the full code

Here is the complete code for each file in the pages/ folder.

from appium.webdriver.common.appiumby import AppiumBy
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from utils.logger import setup_logger

logger = setup_logger("base_page")


class BasePage:
    def __init__(self, driver):
        self.driver = driver
        self.wait = WebDriverWait(driver, 15)

    def find_element(self, locator):
        return self.wait.until(EC.presence_of_element_located(locator))

    def click(self, locator):
        logger.info(f"Clicking element: {locator}")
        element = self.wait.until(EC.element_to_be_clickable(locator))
        element.click()

    def send_keys(self, locator, text):
        logger.info(f"Sending keys to element: {locator}")
        element = self.find_element(locator)
        element.clear()
        element.send_keys(text)

    def get_text(self, locator):
        element = self.find_element(locator)
        text = element.text
        logger.info(f"Got text '{text}' from element: {locator}")
        return text

    def is_displayed(self, locator):
        try:
            element = self.find_element(locator)
            return element.is_displayed()
        except Exception:
            return False

    def swipe_left(self):
        size = self.driver.get_window_size()
        start_x = int(size["width"] * 0.8)
        end_x = int(size["width"] * 0.2)
        y = int(size["height"] * 0.5)
        self.driver.swipe(start_x, y, end_x, y, 800)

    def swipe_right(self):
        size = self.driver.get_window_size()
        start_x = int(size["width"] * 0.2)
        end_x = int(size["width"] * 0.8)
        y = int(size["height"] * 0.5)
        self.driver.swipe(start_x, y, end_x, y, 800)
from appium.webdriver.common.appiumby import AppiumBy
from pages.base_page import BasePage


class LoginPage(BasePage):
    LOGIN_TAB = (AppiumBy.ACCESSIBILITY_ID, "Login")
    EMAIL_INPUT = (AppiumBy.ACCESSIBILITY_ID, "input-email")
    PASSWORD_INPUT = (AppiumBy.ACCESSIBILITY_ID, "input-password")
    LOGIN_BUTTON = (AppiumBy.ACCESSIBILITY_ID, "button-LOGIN")
    ERROR_MESSAGE = (AppiumBy.XPATH, "//android.widget.TextView[contains(@text, 'Please')]")
    SUCCESS_MESSAGE = (AppiumBy.ACCESSIBILITY_ID, "You are logged in!")

    def navigate_to_login(self):
        self.click(self.LOGIN_TAB)

    def enter_email(self, email):
        self.send_keys(self.EMAIL_INPUT, email)

    def enter_password(self, password):
        self.send_keys(self.PASSWORD_INPUT, password)

    def tap_login_button(self):
        self.click(self.LOGIN_BUTTON)

    def get_error_message(self):
        return self.get_text(self.ERROR_MESSAGE)

    def is_login_successful(self):
        return self.is_displayed(self.SUCCESS_MESSAGE)

    def login(self, email, password):
        self.navigate_to_login()
        self.enter_email(email)
        self.enter_password(password)
        self.tap_login_button()
from appium.webdriver.common.appiumby import AppiumBy
from pages.base_page import BasePage


class FormsPage(BasePage):
    FORMS_TAB = (AppiumBy.ACCESSIBILITY_ID, "Forms")
    INPUT_FIELD = (AppiumBy.ACCESSIBILITY_ID, "text-input")
    INPUT_RESULT = (AppiumBy.ACCESSIBILITY_ID, "input-text-result")
    SWITCH_ELEMENT = (AppiumBy.ACCESSIBILITY_ID, "switch")
    SWITCH_TEXT = (AppiumBy.ACCESSIBILITY_ID, "switch-text")

    def navigate_to_forms(self):
        self.click(self.FORMS_TAB)

    def enter_text(self, text):
        self.send_keys(self.INPUT_FIELD, text)

    def get_input_result(self):
        return self.get_text(self.INPUT_RESULT)

    def toggle_switch(self):
        self.click(self.SWITCH_ELEMENT)

    def get_switch_text(self):
        return self.get_text(self.SWITCH_TEXT)

Your page object layer is complete. Next up, you will create the test data files and the conftest.py that ties everything together with an Appium driver fixture.

Create the Data Layer and Conftest

Your Page Object Model classes are ready to interact with the app. Now you need test data to feed them and a central configuration file that manages the Appium driver lifecycle.

Hard-coding credentials and form inputs directly inside test files is a common beginner mistake. It makes tests brittle and impossible to scale. In this step, you will create external JSON data files and a conftest.py that pytest uses to share fixtures across all your tests.

In this step, get ready to:
  • Create login test data with valid and invalid credential scenarios.
  • Create forms test data with text input variations.
  • Build conftest.py with the Appium driver fixture, a failure screenshot hook, and a data loading utility.
Create the login test data file

Data-driven testing means your test logic stays the same while the inputs change. Each JSON object in the data file represents one test scenario that pytest will pick up automatically.

  • Create a new file called login_data.json inside your test_data/ folder.
  • Paste the following JSON into the file:
[
    {
        "test_id": "valid_login",
        "email": "test@example.com",
        "password": "Password123",
        "expected": "success",
        "description": "Valid credentials should login successfully"
    },
    {
        "test_id": "invalid_email",
        "email": "invalid-email",
        "password": "Password123",
        "expected": "error",
        "description": "Invalid email format should show validation error"
    },
    {
        "test_id": "short_password",
        "email": "test@example.com",
        "password": "short",
        "expected": "error",
        "description": "Password under 8 characters should show validation error"
    },
    {
        "test_id": "empty_email",
        "email": "",
        "password": "Password123",
        "expected": "error",
        "description": "Empty email should show validation error"
    }
]

What does this data do?

Each object is a single test case. The test_id becomes a readable label in your test reports. The expected field tells your test logic whether to assert for a success message or an error message.

You have four scenarios covering the most common login validation paths: valid credentials, bad email format, too-short password, and empty email. These map directly to the WebdriverIO Demo App's built-in validation rules.

  • Save test_data/login_data.json.
Create the forms test data file
  • Create a new file called forms_data.json inside your test_data/ folder.
  • Paste the following JSON into the file:
[
    {
        "test_id": "basic_text",
        "input": "Hello Appium",
        "expected": "Hello Appium"
    },
    {
        "test_id": "special_characters",
        "input": "Test@123!",
        "expected": "Test@123!"
    },
    {
        "test_id": "numeric_input",
        "input": "1234567890",
        "expected": "1234567890"
    }
]

What does this data do?

These three scenarios verify that the Forms screen's text input field correctly handles plain text, special characters, and numeric strings. The expected field matches input because the app should echo back exactly what you type.

  • Save test_data/forms_data.json.
  • Confirm both files exist by running:
ls test_data/

You should see forms_data.json and login_data.json in the output.

Don't see both files?

Make sure your terminal is in the project root folder (the one containing config.py). If you are inside the test_data/ folder itself, run cd .. first.

help me find my test data files

Build the conftest.py with driver fixture and utilities

In pytest, a conftest.py file is a special configuration file that pytest automatically discovers. Any fixture you define here becomes available to every test file without importing it.

  • Create a new file called conftest.py in your project root (the same folder that contains config.py).
  • Add the imports and logger setup by pasting this code:
import json
import os
import pytest
import allure
from appium import webdriver
from appium.options.android import UiAutomator2Options
from config import Config
from utils.logger import setup_logger

logger = setup_logger("appium_tests")

What does this code do?

You import the UiAutomator2Options class from the Appium Python Client to configure the driver session. The Config class you built earlier provides all device settings. The logger creates a dedicated appium_tests log channel so fixture events appear in your log files.

  • Below the logger line, add the driver fixture:

@pytest.fixture(scope="function")
def driver():
    logger.info("Starting Appium driver session")

    # Configure UiAutomator2 options from Config values
    options = UiAutomator2Options()
    options.platform_name = Config.PLATFORM_NAME
    options.platform_version = Config.PLATFORM_VERSION
    options.device_name = Config.DEVICE_NAME
    options.automation_name = Config.AUTOMATION_NAME
    options.app_package = Config.APP_PACKAGE
    options.app_activity = Config.APP_ACTIVITY
    options.udid = Config.UDID
    options.no_reset = Config.NO_RESET

    # Start the Appium session
    driver = webdriver.Remote(
        Config.APPIUM_SERVER,
        options=options
    )
    driver.implicitly_wait(Config.IMPLICIT_WAIT)

    logger.info("Driver session started successfully")
    yield driver

    # Teardown: quit after each test function
    logger.info("Quitting driver session")
    driver.quit()

What does this fixture do?

The scope="function" setting means pytest creates a fresh Appium session before each test function and tears it down after. This keeps tests isolated from each other.

The yield keyword splits the fixture into setup (everything above it) and teardown (everything below it). Tests receive the driver object, and when the test finishes, driver.quit() runs automatically.

  • Below the driver fixture, add the screenshot-on-failure hook:

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item):
    outcome = yield
    rep = outcome.get_result()

    # Capture a screenshot when a test fails during execution
    if rep.when == "call" and rep.failed:
        driver = item.funcargs.get("driver")
        if driver:
            logger.error(f"Test failed: {item.name}")
            screenshot = driver.get_screenshot_as_png()
            allure.attach(
                screenshot,
                name=f"failure_{item.name}",
                attachment_type=allure.attachment_type.PNG,
            )

What does this hook do?

This is a pytest hook that runs after every test. It checks if the test failed during the "call" phase (the actual test execution, not setup or teardown). If it did, it grabs a screenshot from the phone and attaches it to the Allure report as a PNG image.

When an interviewer opens your Allure report and clicks a failed test, they will see the exact screen state at the moment of failure. This is a professional debugging practice.

  • Finally, add the data loading utility function at the bottom of the file:

def load_test_data(filename):
    # Build the path to test_data/ relative to conftest.py
    data_path = os.path.join(
        os.path.dirname(__file__), "test_data", filename
    )
    with open(data_path, "r") as f:
        return json.load(f)

What does this function do?

This helper reads a JSON file from the test_data/ folder and returns it as a Python list. Your test files will call load_test_data("login_data.json") to get the login scenarios without needing to know the file path.

  • Save conftest.py.
  • Verify pytest can discover your fixture by running:
pytest --fixtures -q

You should see driver listed among the available fixtures in the output.

Seeing a ModuleNotFoundError?

If you see ModuleNotFoundError: No module named 'appium', your virtual environment may not be activated. Run the activation command for your environment before retrying.

If you see ModuleNotFoundError: No module named 'config', make sure your terminal is in the project root folder where config.py lives.

help me fix my conftest import error

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the complete conftest.py for comparison:

import json
import os
import pytest
import allure
from appium import webdriver
from appium.options.android import UiAutomator2Options
from config import Config
from utils.logger import setup_logger

logger = setup_logger("appium_tests")


@pytest.fixture(scope="function")
def driver():
    logger.info("Starting Appium driver session")

    options = UiAutomator2Options()
    options.platform_name = Config.PLATFORM_NAME
    options.platform_version = Config.PLATFORM_VERSION
    options.device_name = Config.DEVICE_NAME
    options.automation_name = Config.AUTOMATION_NAME
    options.app_package = Config.APP_PACKAGE
    options.app_activity = Config.APP_ACTIVITY
    options.udid = Config.UDID
    options.no_reset = Config.NO_RESET

    driver = webdriver.Remote(
        Config.APPIUM_SERVER,
        options=options
    )
    driver.implicitly_wait(Config.IMPLICIT_WAIT)

    logger.info("Driver session started successfully")
    yield driver

    logger.info("Quitting driver session")
    driver.quit()


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item):
    outcome = yield
    rep = outcome.get_result()

    if rep.when == "call" and rep.failed:
        driver = item.funcargs.get("driver")
        if driver:
            logger.error(f"Test failed: {item.name}")
            screenshot = driver.get_screenshot_as_png()
            allure.attach(
                screenshot,
                name=f"failure_{item.name}",
                attachment_type=allure.attachment_type.PNG,
            )


def load_test_data(filename):
    data_path = os.path.join(
        os.path.dirname(__file__), "test_data", filename
    )
    with open(data_path, "r") as f:
        return json.load(f)

Why JSON instead of YAML for test data?

JSON requires no additional pip dependency. Python's built-in json module handles it directly. YAML would require installing PyYAML, which adds a dependency your framework does not need.

Your data layer and test infrastructure are ready. Next up, you will write the actual test cases that combine your Page Objects with this test data using pytest parametrize and Allure annotations.

Write Data-Driven Test Cases with Allure Annotations

Your page objects know how to interact with the app. Your JSON files hold the test data. Now you need test cases that connect these two layers and produce professional reports.

In this step, you'll write data-driven tests that automatically loop through every scenario in your JSON files. Each test case will appear as a separate entry in the Allure report with severity levels, feature tags, and step-by-step execution details.

In this step, get ready to:
  • Write parametrized login tests that cover valid and invalid scenarios.
  • Write parametrized form tests that verify text input and switch interactions.
  • Configure pytest with Allure reporting and run your test suite locally.
Create the login test file

pytest's parametrize decorator takes a list of test data and runs the same test function once for each entry. Combined with Allure annotations, each data entry appears as a uniquely named test in the report.

  • First, create the tests/__init__.py file to make Python treat the tests/ folder as a package. Create the file by running:
New-Item -ItemType File -Path tests/__init__.py
  • Create a new file called tests/test_login.py and paste the following code:
import allure
import pytest
from pages.login_page import LoginPage
from conftest import load_test_data

# Load all login scenarios from the JSON file at module level
login_data = load_test_data("login_data.json")


@allure.feature("Authentication")
@allure.story("Login Functionality")
class TestLogin:

    @allure.severity(allure.severity_level.CRITICAL)
    @pytest.mark.parametrize(
        "data",
        [d for d in login_data if d["expected"] == "success"],
        ids=[d["test_id"] for d in login_data if d["expected"] == "success"],
    )
    def test_valid_login(self, driver, data):
        allure.dynamic.title(f"Login: {data['description']}")
        login_page = LoginPage(driver)

        with allure.step("Navigate to Login screen"):
            login_page.navigate_to_login()

        with allure.step(f"Enter email: {data['email']}"):
            login_page.enter_email(data["email"])

        with allure.step("Enter password"):
            login_page.enter_password(data["password"])

        with allure.step("Tap Login button"):
            login_page.tap_login_button()

        with allure.step("Verify login is successful"):
            assert login_page.is_login_successful(), data["description"]

    @allure.severity(allure.severity_level.NORMAL)
    @pytest.mark.parametrize(
        "data",
        [d for d in login_data if d["expected"] == "error"],
        ids=[d["test_id"] for d in login_data if d["expected"] == "error"],
    )
    def test_invalid_login(self, driver, data):
        allure.dynamic.title(f"Login: {data['description']}")
        login_page = LoginPage(driver)

        with allure.step("Navigate to Login screen"):
            login_page.navigate_to_login()

        with allure.step(f"Enter email: {data['email']}"):
            if data["email"]:
                login_page.enter_email(data["email"])

        with allure.step("Enter password"):
            if data["password"]:
                login_page.enter_password(data["password"])

        with allure.step("Tap Login button"):
            login_page.tap_login_button()

        with allure.step("Verify validation error is displayed"):
            error = login_page.get_error_message()
            assert "Please" in error, data["description"]

What does this code do?

load_test_data("login_data.json") reads all four login scenarios from your JSON file at module level. This means the data is available before any test runs.

@pytest.mark.parametrize filters the data by expected field. test_valid_login runs only for entries marked "success", while test_invalid_login runs for entries marked "error". Each scenario gets its own unique test ID from the ids parameter.

The allure.step context managers break each test into labeled steps. In the final Allure report, you'll be able to expand each test and see exactly which step passed or failed.

  • Save tests/test_login.py.

You won't see a change yet. The next file completes the test suite before we run everything together.

Seeing an import error in your editor?

If VS Code underlines from conftest import load_test_data with a red squiggle, make sure your terminal's working directory is the project root (the folder containing conftest.py). pytest automatically adds the root to the Python path at runtime, so the import resolves when you run tests.

help me fix import errors in my test file

Create the forms test file

The forms test file follows the same pattern. It loads data from forms_data.json and parametrizes a text input test across all entries. It also includes a non-parametrized switch toggle test to show that both approaches work in the same class.

  • Create a new file called tests/test_forms.py and paste the following code:
import allure
import pytest
from pages.forms_page import FormsPage
from conftest import load_test_data

# Load form input test scenarios from JSON
forms_data = load_test_data("forms_data.json")


@allure.feature("Forms")
@allure.story("Form Input Interactions")
class TestForms:

    @allure.severity(allure.severity_level.CRITICAL)
    @pytest.mark.parametrize(
        "data",
        forms_data,
        ids=[d["test_id"] for d in forms_data],
    )
    def test_text_input(self, driver, data):
        allure.dynamic.title(f"Form input: {data['test_id']}")
        forms_page = FormsPage(driver)

        with allure.step("Navigate to Forms screen"):
            forms_page.navigate_to_forms()

        with allure.step(f"Type text: {data['input']}"):
            forms_page.enter_text(data["input"])

        with allure.step("Verify result matches input"):
            result = forms_page.get_input_result()
            assert result == data["expected"], (
                f"Expected '{data['expected']}' but got '{result}'"
            )

    @allure.severity(allure.severity_level.NORMAL)
    @allure.title("Test toggling the switch element")
    def test_switch_toggle(self, driver):
        forms_page = FormsPage(driver)

        with allure.step("Navigate to Forms screen"):
            forms_page.navigate_to_forms()

        with allure.step("Get initial switch state"):
            initial_text = forms_page.get_switch_text()

        with allure.step("Toggle the switch"):
            forms_page.toggle_switch()

        with allure.step("Verify switch state changed"):
            new_text = forms_page.get_switch_text()
            assert new_text != initial_text, "Switch text should change after toggle"

What does this code do?

test_text_input is parametrized across all three entries in forms_data.json. It types text into the input field and asserts the displayed result matches the expected value. This catches bugs in text rendering, special character handling, and numeric input.

test_switch_toggle uses @allure.title (a static title) instead of allure.dynamic.title because it only runs once. Dynamic titles are for parametrized tests where each iteration needs a unique name.

  • Save tests/test_forms.py.

You won't see a change yet. The next substep configures pytest so you can run the full suite.

Confused about parametrize vs non-parametrized tests?

Use @pytest.mark.parametrize when you have multiple data entries to test the same logic against. Use a regular test method (like test_switch_toggle) when there is only one scenario to verify.

help me understand when to use parametrize

What are the Allure annotations doing?

  • @allure.feature groups tests by high-level feature (Authentication, Forms). These become top-level categories in the report.
  • @allure.story creates subcategories within a feature (Login Functionality, Form Input Interactions).
  • @allure.severity marks test priority. CRITICAL tests block releases. NORMAL tests are important but not blockers.
  • allure.dynamic.title() sets the test name at runtime, so parametrized tests show readable descriptions instead of raw data.
  • allure.step() context managers break test execution into labeled phases. When a test fails, you can see exactly which step broke.
Configure pytest and run the test suite

pytest needs a configuration file to know where your tests live and which Allure flags to pass automatically. Without this, you'd have to type the full command with all flags every time.

  • Create a file called pytest.ini in your project root (the same folder as conftest.py) and paste the following:
[pytest]
addopts = --alluredir=allure-results --clean-alluredir
testpaths = tests

What does this configuration do?

  • addopts automatically appends these flags to every pytest run. --alluredir=allure-results saves Allure data to that folder. --clean-alluredir clears old results before each run.
  • testpaths = tests tells pytest to only look inside the tests/ directory for test files.
  • Save pytest.ini.
  • Make sure your Samsung S9+ is connected via USB and the WebdriverIO Demo App is installed.
  • Start your Appium server in a separate terminal by running:
appium
  • In your original terminal (not the one running Appium), run the test suite by running:
pytest --alluredir=allure-results
  • You should see pytest discover and run 8 test cases (1 valid login + 3 invalid logins + 3 text inputs + 1 switch toggle). Each test name includes its test_id from the JSON file.
  • After the run completes, view the Allure report locally by running:
allure serve allure-results

A browser tab opens with the full Allure dashboard. You should see your tests grouped under the Authentication and Forms features, each with expandable steps showing the execution flow.

Tests failing or not discovered?

If pytest says "no tests ran", check that tests/__init__.py exists and that your test files start with test_. pytest only discovers files matching this prefix.

If tests fail with a connection error, verify Appium is running in a separate terminal window and your phone is unlocked with USB debugging enabled.

help me debug why my Appium tests aren't running

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files before moving on.

ⓧ I'd like to double check the full code

Here are the complete files you created in this step.

import allure
import pytest
from pages.login_page import LoginPage
from conftest import load_test_data

login_data = load_test_data("login_data.json")


@allure.feature("Authentication")
@allure.story("Login Functionality")
class TestLogin:

    @allure.severity(allure.severity_level.CRITICAL)
    @pytest.mark.parametrize(
        "data",
        [d for d in login_data if d["expected"] == "success"],
        ids=[d["test_id"] for d in login_data if d["expected"] == "success"],
    )
    def test_valid_login(self, driver, data):
        allure.dynamic.title(f"Login: {data['description']}")
        login_page = LoginPage(driver)

        with allure.step("Navigate to Login screen"):
            login_page.navigate_to_login()

        with allure.step(f"Enter email: {data['email']}"):
            login_page.enter_email(data["email"])

        with allure.step("Enter password"):
            login_page.enter_password(data["password"])

        with allure.step("Tap Login button"):
            login_page.tap_login_button()

        with allure.step("Verify login is successful"):
            assert login_page.is_login_successful(), data["description"]

    @allure.severity(allure.severity_level.NORMAL)
    @pytest.mark.parametrize(
        "data",
        [d for d in login_data if d["expected"] == "error"],
        ids=[d["test_id"] for d in login_data if d["expected"] == "error"],
    )
    def test_invalid_login(self, driver, data):
        allure.dynamic.title(f"Login: {data['description']}")
        login_page = LoginPage(driver)

        with allure.step("Navigate to Login screen"):
            login_page.navigate_to_login()

        with allure.step(f"Enter email: {data['email']}"):
            if data["email"]:
                login_page.enter_email(data["email"])

        with allure.step("Enter password"):
            if data["password"]:
                login_page.enter_password(data["password"])

        with allure.step("Tap Login button"):
            login_page.tap_login_button()

        with allure.step("Verify validation error is displayed"):
            error = login_page.get_error_message()
            assert "Please" in error, data["description"]
import allure
import pytest
from pages.forms_page import FormsPage
from conftest import load_test_data

forms_data = load_test_data("forms_data.json")


@allure.feature("Forms")
@allure.story("Form Input Interactions")
class TestForms:

    @allure.severity(allure.severity_level.CRITICAL)
    @pytest.mark.parametrize(
        "data",
        forms_data,
        ids=[d["test_id"] for d in forms_data],
    )
    def test_text_input(self, driver, data):
        allure.dynamic.title(f"Form input: {data['test_id']}")
        forms_page = FormsPage(driver)

        with allure.step("Navigate to Forms screen"):
            forms_page.navigate_to_forms()

        with allure.step(f"Type text: {data['input']}"):
            forms_page.enter_text(data["input"])

        with allure.step("Verify result matches input"):
            result = forms_page.get_input_result()
            assert result == data["expected"], (
                f"Expected '{data['expected']}' but got '{result}'"
            )

    @allure.severity(allure.severity_level.NORMAL)
    @allure.title("Test toggling the switch element")
    def test_switch_toggle(self, driver):
        forms_page = FormsPage(driver)

        with allure.step("Navigate to Forms screen"):
            forms_page.navigate_to_forms()

        with allure.step("Get initial switch state"):
            initial_text = forms_page.get_switch_text()

        with allure.step("Toggle the switch"):
            forms_page.toggle_switch()

        with allure.step("Verify switch state changed"):
            new_text = forms_page.get_switch_text()
            assert new_text != initial_text, "Switch text should change after toggle"
[pytest]
addopts = --alluredir=allure-results --clean-alluredir
testpaths = tests

Your test suite is running and producing professional Allure reports. Next up, you'll set up a GitHub Actions pipeline with a self-hosted runner so these tests execute automatically every time you push code.

Set Up GitHub Actions with Self-Hosted Runner

In the previous step, you wrote data-driven test cases with full Allure annotations. Your tests pass locally, but a portfolio project needs to prove it works automatically.

A CI/CD pipeline that triggers on every push shows interviewers your framework is production-ready. In this step, you will connect your repository to a GitHub Actions self-hosted runner on your Windows machine, create a workflow that executes tests on your real Samsung S9+, and publish Allure reports to GitHub Pages.

In this step, get ready to:
  • Install and configure the GitHub Actions self-hosted runner on your Windows machine.
  • Create the CI/CD workflow file that triggers tests and generates Allure reports.
  • Enable GitHub Pages and push your code to trigger the pipeline.
Install and configure the self-hosted runner

A self-hosted runner is a machine you own that connects to GitHub and executes workflow jobs. Because your tests need a physical phone connected via USB, you need to run the pipeline on your own machine rather than on GitHub's cloud runners.

Why a self-hosted runner instead of cloud devices?

Cloud device farms like BrowserStack require paid accounts for real-device testing. A self-hosted runner is free, runs end-to-end on your actual hardware, and teaches a production pattern used by many companies.

Your README can document cloud device setup as a future enhancement.

  • Open your repository on GitHub in your browser.
  • Click Settings in the top navigation bar of your repository.
  • In the left sidebar, click Actions, then click Runners.
  • Click New self-hosted runner.
  • Select Windows as the operating system and x64 as the architecture.

GitHub now displays a set of PowerShell commands customized for your repository. These commands include a time-limited token that expires in one hour.

  • Open PowerShell as Administrator on your Windows machine.
  • Run the commands displayed on the GitHub page in order: create the directory, download the runner, and extract it.
  • GitHub recommends using C:\actions-runner as the installation directory so Windows system accounts can access it.

What do these commands do?

The commands create a folder, download a zip file containing the runner application, and extract it. Then the config.cmd script registers your machine with GitHub using a temporary token tied to your repository.

  • Run the config.cmd command shown on the GitHub page.
  • When prompted for a runner group, press Enter to accept the default.
  • When prompted for a runner name, press Enter to accept the default machine name.
  • When prompted for additional labels, press Enter to skip.
  • When prompted for the work folder, press Enter to accept the default.
  • When asked if you want to configure as a service, enter Y and press Enter.
  • Start the runner by running the run.cmd command shown on the GitHub page.

You should see a message saying Listening for Jobs in the terminal. This confirms your runner is connected and waiting.

  • Back in your browser, navigate to Settings > Actions > Runners.
  • Confirm your runner appears with an Idle status (green dot).

Runner showing as Offline?

Make sure the PowerShell window running run.cmd is still open and showing Listening for Jobs. If you closed it, navigate back to C:\actions-runner in PowerShell and run run.cmd again.

Also check that your machine has internet access and can reach GitHub on port 443. Help me troubleshoot my self-hosted runner connection

Create the CI/CD workflow file

Your runner is connected and ready to execute jobs. Now you need a workflow file that tells GitHub Actions what to do when you push code.

  • First, grant the workflow permission to deploy to GitHub Pages by navigating to Settings > Actions > General in your repository.
  • Scroll down to the Workflow permissions section.
  • Select Read and write permissions.
  • Click Save.

Why enable read and write permissions?

By default, the GITHUB_TOKEN only has read access to your repository contents. The workflow needs write access to push the Allure report to the gh-pages branch.

  • In VS Code, create a new file at .github/workflows/test-pipeline.yml (the .github/workflows/ directory already exists).
  • Add the workflow trigger, job definition, and test execution steps by pasting the following code:
name: Mobile Test Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: self-hosted
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Start Appium server
        run: |
          Start-Process -NoNewWindow appium
          Start-Sleep -Seconds 5
        shell: pwsh

      - name: Run tests
        run: pytest --alluredir=allure-results
        continue-on-error: true

      - name: Stop Appium server
        if: always()
        run: Stop-Process -Name "node" -ErrorAction SilentlyContinue
        shell: pwsh

What does this workflow do?

  • on: push/pull_request triggers the workflow whenever you push to main or open a pull request.
  • runs-on: self-hosted routes the job to your Windows machine instead of GitHub's cloud runners.
  • The Appium steps use PowerShell (shell: pwsh) to start and stop the server as a background process.
  • continue-on-error: true on the test step ensures the workflow continues to report generation even if some tests fail.
  • Now add the Allure report generation and GitHub Pages deployment steps below the "Stop Appium server" step in the same file:
      - name: Get Allure history
        uses: actions/checkout@v4
        if: always()
        continue-on-error: true
        with:
          ref: gh-pages
          path: gh-pages

      - name: Generate Allure Report
        uses: simple-elf/allure-report-action@v1.9
        if: always()
        with:
          allure_results: allure-results
          allure_history: allure-history
          keep_reports: 20

      - name: Deploy to GitHub Pages
        if: always()
        uses: peaceiris/actions-gh-pages@v4
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_branch: gh-pages
          publish_dir: allure-history

What do these steps do?

  • Get Allure history checks out the existing gh-pages branch so Allure can include historical trend data in the report.
  • Generate Allure Report uses simple-elf/allure-report-action@v1.9 to convert raw test results into an HTML report. The keep_reports: 20 setting retains the last 20 runs for trend graphs.
  • Deploy to GitHub Pages uses peaceiris/actions-gh-pages@v4 to push the generated report to the gh-pages branch. This makes your report accessible at a public URL.
  • Save test-pipeline.yml.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file at .github/workflows/test-pipeline.yml.

ⓧ I'd like to double check the full code

Here is the complete workflow file for comparison:

name: Mobile Test Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: self-hosted
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Start Appium server
        run: |
          Start-Process -NoNewWindow appium
          Start-Sleep -Seconds 5
        shell: pwsh

      - name: Run tests
        run: pytest --alluredir=allure-results
        continue-on-error: true

      - name: Stop Appium server
        if: always()
        run: Stop-Process -Name "node" -ErrorAction SilentlyContinue
        shell: pwsh

      - name: Get Allure history
        uses: actions/checkout@v4
        if: always()
        continue-on-error: true
        with:
          ref: gh-pages
          path: gh-pages

      - name: Generate Allure Report
        uses: simple-elf/allure-report-action@v1.9
        if: always()
        with:
          allure_results: allure-results
          allure_history: allure-history
          keep_reports: 20

      - name: Deploy to GitHub Pages
        if: always()
        uses: peaceiris/actions-gh-pages@v4
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_branch: gh-pages
          publish_dir: allure-history

Seeing YAML indentation errors?

YAML is whitespace-sensitive. Make sure each step is indented with exactly 6 spaces (under the steps: key). Use spaces, not tabs. Most editors show a red squiggle or warning if the indentation is wrong.

Help me fix YAML indentation issues in my GitHub Actions workflow

Enable GitHub Pages and trigger the pipeline

The workflow is ready. Now you will push all your project code to GitHub, which triggers the pipeline and creates the gh-pages branch for hosting the Allure report.

  • Make sure your Samsung S9+ is connected via USB and USB debugging is enabled.
  • Make sure Appium is not already running locally (the workflow starts its own Appium instance).
  • In your terminal, commit and push all project files by running these commands:
git add .
git commit -m "Add test framework with CI/CD pipeline"
git push origin main

This push triggers your workflow automatically.

  • In your browser, navigate to the Actions tab on your repository.
  • You should see a workflow run labeled Mobile Test Pipeline in progress (yellow dot).

What happens during the run?

GitHub dispatches the job to your self-hosted runner. The runner checks out your code, installs dependencies, launches Appium, runs your tests against the Samsung S9+, stops Appium, generates the Allure report, and deploys it to GitHub Pages. This takes a few minutes.

  • Wait for the workflow to complete. Once it finishes, go to Settings > Pages in your repository.
  • Under Build and deployment, confirm the source is set to Deploy from a branch.
  • Select gh-pages as the branch and / (root) as the folder.
  • Click Save.

Your Allure report is now live. The URL follows the pattern https://your-username.github.io/your-repo-name/.

Pipeline failed or no gh-pages branch?

If the workflow failed, click the failed run in the Actions tab and expand the failing step to see error details. Common issues:

  • Runner went offline during the run. Check that the PowerShell window running run.cmd stayed open.
  • Phone disconnected. Verify USB debugging is still active with adb devices.
  • Permission denied on Pages deploy. Confirm you set Workflow permissions to Read and write permissions under Settings > Actions > General.

Help me debug my GitHub Actions pipeline failure

If the gh-pages branch does not appear in your Pages settings, the workflow has not completed its first successful deploy yet. Re-run the workflow after fixing any failures.

Your CI/CD pipeline is live. Every push to main now triggers automated tests on your real device and publishes a fresh Allure report. Next up, you will write a professional README and add badges to tie the whole portfolio piece together.

Write the Professional README and Push

Your framework is fully functional, but an interviewer who clicks your GitHub link sees a list of files with no explanation.

A professional README transforms a repository into a portfolio piece. It tells hiring managers what you built, how it works, and where to see results. In this step, you will write that README, add status badges, and push the final project to GitHub.

In this step, get ready to:
  • Write a README.md with a project overview, text-based architecture diagram, and setup instructions.
  • Add a GitHub Actions CI badge to the README.
  • Push to GitHub, verify the pipeline runs green, and confirm the Allure report is accessible.
Create the README.md file

The README is the first thing anyone sees when they visit your repository on GitHub. It renders directly on the repo homepage.

  • In VS Code, open the README.md file at the root of your project (GitHub created this when you initialized the repo).
  • Delete everything currently in the file.
  • Replace the contents with this complete README:
# Mobile Automation Framework

![CI](https://github.com/[[GITHUB_USERNAME="your-github-username"]]/[[GITHUB_REPO="your-repo-name"]]/actions/workflows/test-pipeline.yml/badge.svg)

A production-grade mobile test automation framework built with Appium, pytest, and GitHub Actions.

## Architecture

```
Project Root
|
|-- pages/              # Page Object Model classes
|   |-- base_page.py    # Reusable element interaction methods
|   |-- login_page.py   # Login screen locators and actions
|   |-- forms_page.py   # Forms screen locators and actions
|
|-- tests/              # Data-driven test cases
|   |-- test_login.py   # Login validation scenarios
|   |-- test_forms.py   # Form input scenarios
|
|-- test_data/          # External JSON test data
|   |-- login_data.json
|   |-- forms_data.json
|
|-- utils/              # Logging utilities
|-- config.py           # Environment-aware configuration
|-- conftest.py         # Appium driver fixture + hooks
|-- pytest.ini          # pytest configuration
|-- requirements.txt    # Python dependencies
```

## Tech Stack

- **Appium** - Mobile automation engine (UiAutomator2 driver)
- **pytest** - Test runner with parametrize for data-driven tests
- **Allure** - Professional HTML test reporting
- **GitHub Actions** - CI/CD pipeline with self-hosted runner
- **Python** - Framework language

## Prerequisites

- Python 3.9+
- Node.js (for Appium server)
- Appium Server 3.x
- Android device connected via USB with Developer Mode enabled
- ADB installed and device recognized

## Local Setup

1. Clone this repository
2. Install dependencies: `pip install -r requirements.txt`
3. Update `config.py` with your device UDID
4. Start Appium: `appium`
5. Run tests: `pytest`

## Running Tests

```bash
# Run all tests with Allure results
pytest --alluredir=allure-results

# View the Allure report locally
allure serve allure-results
```

## CI/CD Pipeline

Every push to `main` triggers the GitHub Actions pipeline:
1. Checks out the code
2. Installs Python dependencies
3. Starts the Appium server
4. Runs pytest against the connected device
5. Generates and deploys the Allure report to GitHub Pages

## Live Allure Report

View the latest test results: `https://[[GITHUB_USERNAME="your-github-username"]].github.io/[[GITHUB_REPO="your-repo-name"]]/`

What does this README do?

The CI badge at the top dynamically shows whether your latest pipeline passed or failed. GitHub renders this image live from your workflow status.

The text-based architecture diagram gives interviewers an instant overview of your project structure without needing to browse folders. The Live Allure Report link lets them click straight through to your professional test dashboard.

  • Replace the two your-github-username placeholders with your actual GitHub username.
  • Replace the two your-repo-name placeholders with your actual repository name.
  • Save README.md.

Customize your README

This README is your interview talking points. Feel free to add a section explaining your design decisions, like why you chose Page Object Model over a flat script approach, or why JSON over YAML for test data.

Badge showing 'no status' or broken image?

Double-check that your username and repo name are spelled exactly as they appear in your GitHub URL. The badge URL is case-sensitive.

Also confirm the workflow filename matches test-pipeline.yml exactly. Help me fix my README badge.

Push to GitHub and verify the pipeline

With the README saved, you need to commit and push so GitHub receives the final file and triggers a pipeline run.

  • Stage, commit, and push the README by running these commands in your terminal:
git add README.md
git commit -m "Add professional README with badges and architecture"
git push origin main

What do these commands do?

  • git add README.md stages only the README file for commit.
  • git commit saves a snapshot with a descriptive message.
  • git push origin main uploads the commit to GitHub, which triggers your CI pipeline.
  • Open your repository on GitHub in your browser.
  • Click the Actions tab at the top of your repo page.
  • You should see a new workflow run triggered by your push. Wait for it to complete with a green checkmark.

How long does the pipeline take?

Since you are using a self-hosted runner connected to your physical phone, the pipeline typically takes 2-5 minutes depending on how quickly your device responds to Appium commands.

  • Once the pipeline shows green, go back to the Code tab of your repository.
  • Scroll down to see your rendered README with the CI badge displayed at the top.

Pipeline failed or stuck?

Make sure your self-hosted runner is still active. Check the runner terminal window on your machine for any error output. Your Samsung S9+ must be connected via USB with the screen unlocked.

Help me debug my failed GitHub Actions pipeline.

Confirm the Allure report is live on GitHub Pages

The final verification is confirming that your Allure report is publicly accessible. This is the URL you will put on your resume.

  • Open your Allure report by navigating to:
https://[[GITHUB_USERNAME="your-github-username"]].github.io/[[GITHUB_REPO="your-repo-name"]]/
  • You should see the Allure dashboard with your test results, including pass/fail charts, test case details, and execution history.
  • Click into individual test cases to see the step-by-step breakdown you defined with allure.step context managers.

Seeing a 404 page?

GitHub Pages can take 1-2 minutes to deploy after the pipeline finishes. Refresh the page after waiting.

If it still shows 404, go to your repo Settings > Pages and confirm the source is set to the gh-pages branch. Help me fix my GitHub Pages deployment.

✔️ Awesome, I've got everything!

Great. Double check you have saved your README.md and that your pipeline badge shows green on your repo homepage.

ⓧ I'd like to double check the full code

Here is the full README.md for comparison:

# Mobile Automation Framework

![CI](https://github.com/[[GITHUB_USERNAME="your-github-username"]]/[[GITHUB_REPO="your-repo-name"]]/actions/workflows/test-pipeline.yml/badge.svg)

A production-grade mobile test automation framework built with Appium, pytest, and GitHub Actions.

## Architecture

```
Project Root
|
|-- pages/              # Page Object Model classes
|   |-- base_page.py    # Reusable element interaction methods
|   |-- login_page.py   # Login screen locators and actions
|   |-- forms_page.py   # Forms screen locators and actions
|
|-- tests/              # Data-driven test cases
|   |-- test_login.py   # Login validation scenarios
|   |-- test_forms.py   # Form input scenarios
|
|-- test_data/          # External JSON test data
|   |-- login_data.json
|   |-- forms_data.json
|
|-- utils/              # Logging utilities
|-- config.py           # Environment-aware configuration
|-- conftest.py         # Appium driver fixture + hooks
|-- pytest.ini          # pytest configuration
|-- requirements.txt    # Python dependencies
```

## Tech Stack

- **Appium** - Mobile automation engine (UiAutomator2 driver)
- **pytest** - Test runner with parametrize for data-driven tests
- **Allure** - Professional HTML test reporting
- **GitHub Actions** - CI/CD pipeline with self-hosted runner
- **Python** - Framework language

## Prerequisites

- Python 3.9+
- Node.js (for Appium server)
- Appium Server 3.x
- Android device connected via USB with Developer Mode enabled
- ADB installed and device recognized

## Local Setup

1. Clone this repository
2. Install dependencies: `pip install -r requirements.txt`
3. Update `config.py` with your device UDID
4. Start Appium: `appium`
5. Run tests: `pytest`

## Running Tests

```bash
# Run all tests with Allure results
pytest --alluredir=allure-results

# View the Allure report locally
allure serve allure-results
```

## CI/CD Pipeline

Every push to `main` triggers the GitHub Actions pipeline:
1. Checks out the code
2. Installs Python dependencies
3. Starts the Appium server
4. Runs pytest against the connected device
5. Generates and deploys the Allure report to GitHub Pages

## Live Allure Report

View the latest test results: `https://[[GITHUB_USERNAME="your-github-username"]].github.io/[[GITHUB_REPO="your-repo-name"]]/`

Your repository is now complete and portfolio-ready. You have a green CI badge, a live Allure report, and a professional README that explains your architecture decisions. Next up is an optional Secret Mission to add retry logic for flaky tests.

Secret mission

Add Test Retry with Allure History

Mobile tests are notoriously flaky. Network hiccups, animation timing, and device state can all cause a test to fail once and pass the next second. In production frameworks, you never let a single flaky failure mark the whole pipeline red. Add a retry mechanism that gives unstable tests a second chance, then track retry trends in your Allure report so you can spot which tests need fixing.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on free services (GitHub, GitHub Pages, self-hosted runner), so there are no ongoing costs.

Resources you used:

  • GitHub repository (free, no ongoing cost).
  • GitHub Pages site for Allure reports (free, included with GitHub).
  • Self-hosted runner process on your Windows machine (no cost, but runs in the background).

Keep everything running

No action needed. Choose this if you are still actively building on this framework or want tests to run automatically every time you push code.

  • Leave the self-hosted runner active. Every push to main triggers your test pipeline automatically.
  • Your Allure report on GitHub Pages stays live and accessible via its URL.
  • The runner process uses minimal system resources when idle.

Pause - I'll come back to this later

Stop the runner process to free up background resources. Your repository and GitHub Pages site remain intact.

  • Open the folder where you installed the self-hosted runner on your machine.
  • Stop the runner by closing the terminal window running run.cmd, or stop the service if you configured it as a Windows service.
  • Your GitHub repository, code, and Allure report on GitHub Pages all remain accessible.
  • To restart later, navigate back to the runner folder and run run.cmd again.

Delete - I don't want to use this again

Remove all project resources and start fresh. The Allure report on GitHub Pages will no longer be accessible after deletion.

  • Stop the self-hosted runner process.
  • In your GitHub repository, go to Settings > Actions > Runners and remove the self-hosted runner.
  • Delete the runner folder from your machine.
  • In your repository Settings, scroll to the bottom and click Delete this repository.
  • Delete the local project folder from your Desktop.

Nice Work!

Nice Work!

You just built a job-ready mobile automation portfolio from scratch. This single GitHub repository demonstrates every core skill that QA hiring managers look for, from framework architecture to real device CI/CD.

You've learned how to:

  • Build a complete Page Object Model framework with reusable base classes, clean separation of concerns, and data-driven tests powered by external JSON files.
  • Set up a GitHub Actions CI/CD pipeline with a self-hosted runner that executes tests on your real Samsung S9+ on every push.
  • Deploy professional Allure reports to GitHub Pages with a shareable link for your resume, plus environment-aware configuration and Python logging for production-grade debugging.
  • Add a retry mechanism for flaky tests with historical trend tracking in Allure (Secret Mission).

Ready to quiz yourself?