Shadowstep is a modular UI automation framework for Android applications built on top of Appium.
- Lazy element lookup and interaction (driver is touched only when necessary)
- PageObject generation
- PageObject navigation engine with page auto-discovery
- Reconnect logic on session loss
- Integration with ADB and an Appium/SSH "terminal"
- DSL-style assertions for readable checks (
should.have
,should.be
) - Image-based actions on screen
- Installation
- Quick Start
- Test Setup (Pytest)
- Element API (
Element
) - DSL Assertions
- Page Objects and Navigation
- ADB and Terminal
- Image Operations
- Logcat Logs
- Page Object module (generation)
- Components
- Quick Start (PO generation)
- Templates
- Limitations and Details
- Code References
- Architecture Notes
- Limitations
- License
pip install appium-python-client-shadowstep
from shadowstep.shadowstep import Shadowstep
application = Shadowstep()
capabilities = {
"platformName": "android",
"appium:automationName": "uiautomator2",
"appium:UDID": "192.168.56.101:5555",
"appium:noReset": True,
"appium:autoGrantPermissions": True,
"appium:newCommandTimeout": 900,
}
application.connect(server_ip='127.0.0.1', server_port=4723, capabilities=capabilities)
- You may pass
command_executor
directly (e.g.,http://127.0.0.1:4723/wd/hub
), thenserver_ip/port
are optional. - If you pass
capabilities
as adict
, they will be converted intoUiAutomator2Options
internally.
Session-scoped fixture example:
import pytest
from shadowstep.shadowstep import Shadowstep
@pytest.fixture(scope='session', autouse=True)
def app():
application = Shadowstep()
APPIUM_IP = '127.0.0.1'
APPIUM_PORT = 4723
APPIUM_COMMAND_EXECUTOR = f'http://{APPIUM_IP}:{APPIUM_PORT}/wd/hub'
capabilities = {
"platformName": "android",
"appium:automationName": "uiautomator2",
"appium:UDID": "192.168.56.101:5555",
"appium:noReset": True,
"appium:autoGrantPermissions": True,
"appium:newCommandTimeout": 900,
}
application.connect(server_ip=APPIUM_IP,
server_port=APPIUM_PORT,
command_executor=APPIUM_COMMAND_EXECUTOR,
capabilities=capabilities)
yield application
application.disconnect()
Run tests:
pytest -svl --log-cli-level INFO --tb=short tests/test_shadowstep.py
Run Appium server locally:
npm i -g appium@next
appium driver install uiautomator2
appium server -ka 800 --log-level debug -p 4723 -a 0.0.0.0 -pa /wd/hub --allow-insecure=adb_shell
el = app.get_element({"resource-id": "android:id/title"})
el.tap()
el.text
el.get_attribute("enabled")
Call chains
el = app.get_element({"resource-id": "android:id/title"})
el.zoom().click()
Lazy DOM navigation (declarative):
el = app.get_element({'class': 'android.widget.ImageView'}).\
get_parent().\
get_sibling({'resource-id': 'android:id/summary'}).\
get_cousin(cousin_locator={'resource-id': 'android:id/summary'}).\
get_element({"resource-id": "android:id/switch_widget"})
Key features:
-
Lazy evaluation: the actual
find_element
happens on the first interaction with an element: el = app.get_element({'class': 'android.widget.ImageView'}) # find_element is not called here el.swipe_left() # find_element is called here -
Locators:
dict
and XPath (tuples default to XPath strategy) -
Built-in retries and auto-reconnect on session failures
-
Rich API:
tap
,click
,scroll_to
,get_sibling
,get_parent
,drag_to
,send_keys
,wait_visible
, and more
item = app.get_element({'text': 'Network & internet'})
item.should.have.text("Network & internet").have.resource_id("android:id/title")
item.should.be.visible()
item.should.not_be.focused()
See more examples in tests/test_element_should.py
.
Base page class is PageBaseShadowstep
.
A page must:
- inherit from
PageBaseShadowstep
- have class name starting with
Page
- provide
edges: Dict[str, Callable[[], PageBaseShadowstep]]
— navigation graph edges - implement
is_current_page()
Example page:
import logging
from shadowstep.element.element import Element
from shadowstep.page_base import PageBaseShadowstep
class PageAbout(PageBaseShadowstep):
def __init__(self):
super().__init__()
self.logger = logging.getLogger(__name__)
def __repr__(self):
return f"{self.name} ({self.__class__.__name__})"
@property
def edges(self):
return {"PageMain": self.to_main}
def to_main(self):
self.shadowstep.terminal.press_back()
return self.shadowstep.get_page("PageMain")
@property
def name(self) -> str:
return "About"
@property
def title(self) -> Element:
return self.shadowstep.get_element(locator={'text': 'About', 'class': 'android.widget.TextView'})
def is_current_page(self) -> bool:
try:
return self.title.is_visible()
except Exception as error:
self.logger.error(error)
return False
Auto-discovery of pages:
- classes inheriting
PageBaseShadowstep
and starting withPage
- files
page*.py
(usuallypages/page_*.py
) in project paths - pages are registered automatically when
Shadowstep
is created
Navigation:
self.shadowstep.navigator.navigate(from_page=self.page_main, to_page=self.page_display)
assert self.page_display.is_current_page()
Two ways to perform low-level actions:
-
app.adb.*
— direct ADB viasubprocess
(good for local runs) -
app.terminal.*
—mobile: shell
via Appium or SSH transport (ifssh_user/ssh_password
were provided inconnect()
)
ADB examples:
app.adb.press_home()
app.adb.install_app(source="/path/app.apk", udid="192.168.56.101:5555")
app.adb.input_text("hello")
Terminal examples:
app.terminal.start_activity(package="com.example", activity=".MainActivity")
app.terminal.tap(x=1345, y=756)
app.terminal.past_text(text='hello')
image = app.get_image(image="tests/test_data/connected_devices.png", threshold=0.5, timeout=3.0)
assert image.is_visible()
image.tap()
image.scroll_down(max_attempts=3)
image.zoom().unzoom().drag(to=(100, 100))
Under the hood it uses opencv-python
, numpy
, Pillow
.
app.start_logcat("device.logcat")
# ... test steps ...
app.stop_logcat()
- The element tree is not fetched upfront
- Reconnects on session loss (
InvalidSessionIdException
,NoSuchDriverException
) - Works well with Pytest and CI/CD
- Modular architecture:
element
,elements
,navigator
,terminal
,image
,utils
Tools to automatically generate PageObject classes from UI XML (uiautomator2), enrich them while scrolling, merge results, and generate baseline tests.
- Generate
PageObject
from currentpage_source
via Jinja2 template - Detect title, main container (recycler/scrollable), anchors and related elements (summary/switch)
- Discover additional items inside scrollable lists and merge results
- Generate a simple test class for quick smoke coverage of page properties
-
PageObjectParser
- Parses XML (
uiautomator2
) into aUiElementNode
tree - Filters by white/black lists for classes and resource-id, plus a container whitelist
- API:
parse(xml: str) -> UiElementNode
- Parses XML (
-
PageObjectGenerator
- Generates a Python page class from
UiElementNode
tree usingtemplates/page_object.py.j2
- Determines
title
,name
, optionalrecycler
, properties, anchors/summary, etc. - API:
generate(ui_element_tree: UiElementNode, output_dir: str, filename_prefix: str = "") -> (path, class_name)
- Generates a Python page class from
-
PageObjectRecyclerExplorer
- Scrolls the screen, re-captures
page_source
, re-generates PO and merges them - Requires active
Shadowstep
session (scroll/adb_shell) - API:
explore(output_dir: str) -> str
(path to merged file)
- Scrolls the screen, re-captures
-
PageObjectMerger
- Merges two generated classes into one: preserves imports/header and combines unique methods
- API:
merge(file1, file2, output_path) -> str
-
PageObjectTestGenerator
- Generates a basic Pytest class for an existing PageObject (
templates/page_object_test.py.j2
) - Verifies visibility of properties at minimum
- API:
generate_test(input_path: str, class_name: str, output_dir: str) -> (test_path, test_class_name)
- Generates a basic Pytest class for an existing PageObject (
Note: crawler.py
and scenario.py
are conceptual notes/ideas, not stable API.
- Capture XML and generate a page class
from shadowstep.shadowstep import Shadowstep
from shadowstep.page_object.page_object_parser import PageObjectParser
from shadowstep.page_object.page_object_generator import PageObjectGenerator
app = Shadowstep.get_instance() # or Shadowstep()
xml = app.driver.page_source
parser = PageObjectParser()
tree = parser.parse(xml)
pog = PageObjectGenerator()
path, class_name = pog.generate(ui_element_tree=tree, output_dir="pages")
print(path, class_name)
- Explore recycler and merge results
from shadowstep.page_object.page_object_recycler_explorer import PageObjectRecyclerExplorer
explorer = PageObjectRecyclerExplorer(base=app, translator=None)
merged_path = explorer.explore(output_dir="pages")
print(merged_path)
- Generate a test for the page
from shadowstep.page_object.page_object_test_generator import PageObjectTestGenerator
tg = PageObjectTestGenerator()
test_path, test_class_name = tg.generate_test(input_path=path, class_name=class_name, output_dir="tests/pages")
print(test_path, test_class_name)
-
templates/page_object.py.j2
— PageObject Python class template -
templates/page_object_test.py.j2
— Pytest class template
To tweak generated code structure, edit these files. (The generator uses the local templates
folder.)
- Focused on Android (XML and uiautomator2 attributes)
- Generator heuristics:
- Find
title
viatext
/content-desc
- Treat
scrollable==true
container asrecycler
if present - Switch ↔ anchor pairs,
summary
fields, filtering structural/non-informative classes - Remove
text
from locators for classes where text search is not supported
- Find
-
PageObjectRecyclerExplorer
requires an active session andmobile: shell
capability; uses swipes andadb_shell
- Merge result is saved as a separate file (see prefix/path in
explore()
)
shadowstep/page_object/page_object_parser.py
shadowstep/page_object/page_object_generator.py
shadowstep/page_object/page_object_recycler_explorer.py
shadowstep/page_object/page_object_merger.py
shadowstep/page_object/page_object_test_generator.py
For some reason, jinja templates are not downloaded to the folder when installed via pip. Insert them manually from this sources into .venv/Lib/site-packages/shadowstep/page_object/templates/ I don't know how to solve this yet.
start_logcat (mobile: startLogsBroadcast) is not working with my Selenium Grid now, need experiments with plugins. Solve it later
- Android only (no iOS or Web)
MIT — see LICENSE
.