Test Automation with Selenium, Appium and Jenkins
The standard starting set for test automation is Selenium (or Playwright) for the web, Appium for mobile, Jenkins to run it all, and JMeter for load testing.
This article was published in 2024 and revised in September 2026. The Appium code shown at the time uses a form that Appium 2 removed and no longer runs. The Selenium sections also carried some outdated assumptions, now updated.
- Appium 2 removed the
/wd/hubbase path. The default is now/ - Selenium 4.10 removed the
desired_capabilitiesargument. Pass an Options object viaoptions= - Selenium 4.6 and later include Selenium Manager, so you no longer download ChromeDriver by hand
Decide what to automate first
Automating your manual test suite as-is will fail.
A check worth doing once is not the same as a check worth repeating. Automation suits:
| Good fit | Poor fit |
|---|---|
| Primary flows verified every release | One-off acceptance checks |
| Features where breakage is costly (payment, login) | Fine visual adjustments |
| Volumes impractical by hand (many combinations) | Screens still under active design |
| Load testing, which people cannot do at all | Exploratory testing |
Writing many E2E tests against a moving specification burns your budget on maintaining tests. Decide what you are protecting before you start—covered in what to protect with E2E tests.
Selenium: automating the browser
Installation is now just pip
pip install selenium
From Selenium 4.6, driver setup is automatic. Selenium Manager fetches a driver matching the installed browser.
The old failure mode—”Chrome updated, ChromeDriver no longer matches, every test fails”—is gone. Instructions in older articles about installing webdriver-manager are no longer needed.
The basic shape
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
options = webdriver.ChromeOptions()
options.add_argument("--window-size=1280,900")
# options.add_argument("--headless=new")
driver = webdriver.Chrome(options=options)
try:
driver.get("https://www.selenium.dev/")
wait = WebDriverWait(driver, 10)
search_box = wait.until(
EC.element_to_be_clickable((By.NAME, "q"))
)
search_box.send_keys("webdriver")
search_box.send_keys(Keys.RETURN)
wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, ".search-result")))
finally:
driver.quit() # close the browser even on failure
Waiting is where tests are won or lost
The most common cause of automation failure is how you wait. Get it wrong and you produce flaky tests that pass and fail unpredictably.
| Approach | Verdict | Why |
|---|---|---|
time.sleep(5) |
Do not use | Wasteful when fast, insufficient when slow |
implicitly_wait(5) |
Limited | Only waits for elements to exist |
WebDriverWait + expected_conditions |
Recommended | Waits on a stated condition |
implicitly_wait() is a setting, not a wait instruction. Calling it does not pause anything, so it cannot express “wait until the results appear”. Use WebDriverWait.
Also, do not mix implicitly_wait() with WebDriverWait—the combined timeouts become unpredictable.
Locators
# avoid string locators
driver.find_element("name", "q")
# use By
driver.find_element(By.NAME, "q")
driver.find_element(By.CSS_SELECTOR, "[data-testid='submit']")
A dedicated test attribute such as data-testid is the most durable locator. CSS class names break with every redesign, and long absolute XPath expressions break when a single element is inserted.
Appium: automating mobile apps
Appium sits on the same WebDriver protocol as Selenium, so the code reads similarly—but Appium 2 changed how you connect.
Appium 2 setup
npm install -g appium
appium driver install uiautomator2 # install the Android driver separately
appium # starts on http://localhost:4723
Appium 2 separated drivers from the core. Install the driver for each platform you target—uiautomator2 for Android, xcuitest for iOS.
pip install Appium-Python-Client
from appium import webdriver
from appium.options.android import UiAutomator2Options
from appium.webdriver.common.appiumby import AppiumBy
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
capabilities = {
"platformName": "Android",
"appium:automationName": "UiAutomator2",
"appium:deviceName": "emulator-5554",
"appium:app": "/path/to/app.apk",
}
# wrap them in an Options object
options = UiAutomator2Options().load_capabilities(capabilities)
# the base path is / , not /wd/hub
driver = webdriver.Remote("http://localhost:4723", options=options)
try:
wait = WebDriverWait(driver, 20)
login_button = wait.until(EC.element_to_be_clickable(
(AppiumBy.ID, "com.example.app:id/login_button")
))
login_button.click()
wait.until(EC.presence_of_element_located(
(AppiumBy.ACCESSIBILITY_ID, "home-screen")
))
finally:
driver.quit()
| Appium 1 (old) | Appium 2 (current) | |
|---|---|---|
| Server URL | http://localhost:4723/wd/hub |
http://localhost:4723 |
| Capabilities | Second positional argument | An Options object via options= |
| Vendor capabilities | No prefix | appium: prefix required |
| Drivers | Bundled | Installed with appium driver install |
Forgetting the appium: prefix is another common stumble. Every capability not standardised in the W3C specification needs it—platformName is the exception, being standard.
Prefer accessibility ids
AppiumBy.ACCESSIBILITY_ID maps to content-desc on Android and accessibilityIdentifier on iOS. It is the easiest way to share test code across both platforms, so it is worth asking the development team to set them.
Jenkins: making it run continuously
Automation scripts have limited value while you run them by hand. The value appears once they run on every change and someone notices when they fail.
The usual flow:
- A developer pushes to GitHub
- Jenkins detects the change
- Selenium and Appium tests run
- Results go to Slack or email
Keeping a Jenkinsfile in the repository beats configuring shell steps in the UI—the configuration becomes reviewable code.
pipeline {
agent any
stages {
stage('Setup') {
steps {
sh 'python -m venv .venv'
sh '.venv/bin/pip install -r requirements.txt'
}
}
stage('Test') {
steps {
sh '.venv/bin/pytest tests/ --junitxml=results.xml'
}
}
}
post {
always {
junit 'results.xml'
archiveArtifacts artifacts: 'screenshots/**', allowEmptyArchive: true
}
}
}
Capture screenshots on failure
Without this you cannot diagnose a CI failure. Logs alone do not reconstruct what was on screen.
import pytest
from pathlib import Path
@pytest.fixture
def driver():
d = webdriver.Chrome(options=options)
yield d
d.quit()
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == "call" and report.failed:
drv = item.funcargs.get("driver")
if drv:
Path("screenshots").mkdir(exist_ok=True)
drv.save_screenshot(f"screenshots/{item.name}.png")
Do not paper over flaky tests with retries. Making them pass on a second attempt also hides genuine defects. Find the cause and fix the waiting.
JMeter: load testing
Questions like “what happens when 1,000 people hit this API at once” cannot be answered manually.
Three parameters define a run.
| Setting | Meaning |
|---|---|
| Thread count | Concurrent virtual users |
| Ramp-up period | Seconds taken to reach that count |
| Loop count | Repetitions per user |
Do not set ramp-up to zero. Everyone connecting simultaneously creates a spike that does not occur in reality, and the numbers stop meaning anything.
Reading the results
Never judge by the mean alone.
| Metric | How to read it |
|---|---|
| Mean response time | Indicative only; outliers distort it |
| 90th / 95th percentile | What users actually experience. Judge on this |
| Error rate | Anything above zero needs investigating |
| Throughput | Find where it plateaus |
A 250ms mean with a 3-second 95th percentile means one user in twenty waits three seconds. Averages conceal problems—always read the percentiles.
In CI, run it headless rather than through the GUI.
jmeter -n -t test-plan.jmx -l result.jtl -e -o report/
The JMeter GUI is not for generating load. Use it to build the plan, then always run with -n. Loading through the GUI makes JMeter itself the bottleneck and skews the measurements.
For load tests written in Python, Locust is an alternative—see 5 ways to make QA work faster with Python.
The order to introduce these
Trying to stand everything up at once fails. Add one piece at a time, in order of payoff.
- 1. Write three to five E2E tests for primary flows: login, purchase, contact. Do not aim for coverage
- 2. Run them in CI: local-only runs become ceremonial
- 3. Keep screenshots and reports on failure: without diagnosis, failures get ignored
- 4. Add more once they are stable: flaky tests destroy trust in the whole suite
The worst outcome is a suite whose failures nobody looks at. At that point it is pure liability. Establishing that failures always get acted on matters more than the number of tests.
Summary
- Selenium 4.6+ needs no manual driver setup (Selenium Manager)
- Wait with
WebDriverWait—nottime.sleep(), notimplicitly_wait() - Locate elements by a dedicated attribute such as
data-testid - Appium 2 changed the base path from
/wd/hubto/ - Wrap capabilities in
UiAutomator2Optionsand pass viaoptions=. Do not forget theappium:prefix - Keep the
Jenkinsfilein the repo and always save screenshots on failure - Run JMeter headless (
-n) and judge on the 95th percentile, not the mean - Establish “failures always get acted on” before growing the suite
Tooling information dates quickly here. Checking the version in the official documentation before you start saves a surprising amount of time.