kyapp 使用手册
面向"要写安卓 / iOS UI 自动化用例"的人。看完这一篇能照着自己项目搭起来,跑通第一个用例。
参考实现:仓库里的 demo/(一个完整的可运行工程,建议先跑通它再改成自己的)。
1. 它能给你什么
- 一套 API 同时覆盖 iOS 和安卓:同一份页面对象、同一份用例,靠
platform参数切换。 - 页面对象(PO)模式:
Elem声明式定位,Page承载页面。 - 自动处理设备服务:iOS 自动起 tunnel / WDA / 端口转发;安卓自动连 uiautomator2。
- 失败现场:元素找不到自动截图并挂到 allure。
- 录屏、报告:一条
start_record/stop_record,报告用 allure 生成。 - 弹窗兜底:配置一组"总是要关掉的弹窗",查找元素失败时会自动尝试点掉。
它不提供断言库、不提供数据驱动 DSL、不提供用例管理平台——断言直接用 assert,数据用 pytest 原生能力。
2. 环境准备
Python
kyapp 要求 Python 3.10 ~ 3.12(3.13 装不上,因为 numpy 1.26 没有 cp313 wheel)。
python -m venv .venv
source .venv/bin/activate
pip install kyapp # 内网用私有源: pip install kyapp -i <你的源>
iOS 侧(只有跑 iOS 才需要)
| 依赖 | 说明 | 检查命令 |
|---|---|---|
| macOS + Xcode | WDA 依赖 Xcode 的工具链 | xcodebuild -version |
| go-ios | 起隧道、跑 WDA,可执行文件叫 ios |
ios version |
| tidevice | WDA / MJPEG 的端口转发 | tidevice --version |
| ffmpeg | 录屏编码 | ffmpeg -version |
| WebDriverAgent | 需要自己签名并装到设备上 | 见下方注意 |
注意(重要):框架里 WDA 的 bundle id 是写死的
com.lute.WebDriverAgentRunner.xctrunner(kyapp/core/ios/local_wda.py的_wda_cmd)。 你要么按这个 id 签名安装 WDA,要么改那一处代码改成自己的。签名不对时表现为wda启动失败,详细原因看~/.kyapp/logs/<udid>/runwda.log。
安卓侧
| 依赖 | 说明 | 检查命令 |
|---|---|---|
| adb | 设备连接 | adb devices |
| uiautomator2 初始化 | 首次需给设备装 ATX agent | python -m uiautomator2 init |
| scrcpy | 录屏用(不录屏可不装) | scrcpy --version |
报告
brew install allure # 或参考 allure 官方安装方式
allure --version
3. 五分钟跑通
3.1 目录结构
your_project/
├── run_ios.py # iOS 入口(可多个,一个平台一个)
├── run_adr.py # 安卓入口
├── config.py # 自己的测试数据(账号密码等)
├── pages/ # 页面对象
│ ├── __init__.py
│ ├── home_page.py
│ └── login_page.py
└── tests/ # 用例
├── __init__.py
└── test_go_me.py
3.2 页面对象
pages/home_page.py:
from kyapp import Elem, Page
class HomePage(Page):
me_tab = Elem(
title='底部我的tab入口',
adr=('resourceId', 'com.lute.momcozy:id/i_me'),
ios=('label', 'Me'),
)
title:元素的中文描述,出现在日志和失败截图文件名里,写清楚能省很多排查时间。adr/ios:(定位方式, 定位值)。两个都必须写(见 7.1)。
3.3 用例
tests/test_go_me.py:
import kyapp
from pages.home_page import HomePage
class TestGoMe(kyapp.TestCase):
def start(self):
"""每个用例开始都会执行:启动 App、准备页面对象"""
self.start_app()
self.home_page = HomePage(self.dr)
def test_go_me(self):
self.home_page.me_tab.click()
assert self.home_page.me_tab.exists(), '进入"我的"页面失败'
要点:
- 类名必须以
Test开头、继承kyapp.TestCase;方法名以test_开头(pytest 规则)。 self.dr由框架创建,在start()里就能用。- 断言就是普通
assert。
3.4 入口
run_ios.py:
import kyapp
if __name__ == '__main__':
kyapp.main(
case_path='tests',
did='00008150-0018581E0A8A401C', # iOS 传 udid
pkg='com.root.momcozy', # iOS 传 bundle id
platform='ios',
)
run_adr.py:
import kyapp
if __name__ == '__main__':
kyapp.main(
case_path='tests',
did='R5CY40W0Q2J', # 安卓传 serial(adb devices 第二列)
pkg='com.lute.momcozy', # 安卓传 package name
platform='android',
pop_list=[
('多设备连接弹窗', 'resourceId', 'com.lute.momcozy:id/ivClose'),
],
)
运行(必须在本项目目录下,case_path 是相对路径):
python run_ios.py
allure serve report # 看报告
3.5 kyapp.main 参数
| 参数 | 必填 | 说明 |
|---|---|---|
case_path |
✅ | 用例目录,如 'tests';相对当前工作目录 |
did |
✅ | iOS 传 udid,安卓传 serial |
pkg |
✅ | iOS 传 bundle id,安卓传 package name |
platform |
✅ | 只能是 'ios' 或 'android'(写错会在入口直接报错) |
report_path |
allure 结果目录,默认 'report' |
|
pop_list |
弹窗兜底列表,见第 6 节 |
调用后框架会执行 pytest.main(['tests', '-sv', '--alluredir', 'report', '--clean-alluredir'])。
4. 用例生命周期
每个测试方法都会走一遍:
setup_class() → start_class() # 类级,一次
setup_method() → 建 driver → start()
test_xxx()
teardown_method() → end() → 关 driver
teardown_class() → end_class() # 类级,一次
四条 hook 都是空实现,按需覆盖:
| hook | 时机 | 典型用途 |
|---|---|---|
start_class / end_class |
整个类前后各一次 | 造数据、登录一次 |
start |
每个用例前 | start_app()、实例化页面对象 |
end |
每个用例后 | 清理、回到首页 |
关键行为:每个 test_ 方法都会重建一次 driver。 iOS 上还会先清掉旧的 tunnel / runwda / relay 进程再重建,所以单个用例有 5~15 秒固定开销。因此:
- 一个
test_方法里应该走完一个完整场景,不要拆成一堆只点一下的微用例; start_class里做的准备不会跨用例保留 driver(driver 每个方法都换)。
setup_method里如果start()抛异常,框架只打logger.error不失败用例。 所以关键前置条件(比如"必须登录成功")要写成test_里的assert,别只写在start()。
TestCase 自带的便捷方法:
| 方法 | 说明 |
|---|---|
self.start_app(force=True, activity=None, use_monkey=False) |
启动 App,force=True 会先停掉 |
self.stop_app() |
停止 App |
self.back() |
返回上一页(iOS 用右滑手势,安卓用 back 键) |
self.sleep(n) |
带日志的 time.sleep |
self.shot(name, delay=0) |
截图并自动挂到 allure |
self.start_record(name) / self.stop_record() |
录屏,见第 8 节 |
5. 元素定位
5.1 统一写法
Elem(
title='登录按钮',
adr=('resourceId', 'com.lute.momcozy:id/btnLogin'),
ios=('label', 'Log In'),
)
同一个元素两套定位符,框架按 platform 自动选一个。
5.2 常用定位方式
loc_key |
安卓(uiautomator2) | iOS(facebook-wda) |
|---|---|---|
text |
文案精确匹配 | 文案匹配(label/name/value) |
label |
— | 无障碍 label |
name |
— | 控件 name |
resourceId |
资源 id,如 com.x:id/btn |
— |
className |
类名,如 android.widget.Button |
类名,如 TextField、SecureTextField、Button |
textContains |
文案包含 | — |
description |
content-desc | — |
xpath |
//*[@text="登录"] |
//Button[@name="登录"] |
除 xpath 外,其余都直接透传给底层库的选择器(uiautomator2 / facebook-wda),所以两个库支持的参数基本都能用。
5.3 元素方法
| 方法 | 说明 |
|---|---|
find(timeout=5, retry=True, error_screen=True) |
找元素,返回底层元素对象;失败时截图并抛异常 |
exists(timeout=5, retry=False) |
是否存在,返回 bool,不抛异常、不截图 |
click(timeout=5) |
点击 |
click_exists(timeout=5) |
存在才点,不存在静默跳过 |
input(text, timeout=5) |
输入(iOS 用 set_text,安卓点一下再输入) |
clear() |
清空输入框 |
get_text(timeout=5) |
取文本 |
retry 的含义:find(retry=True)(默认)在第一次找不到时会重试 3 轮,每轮先尝试点掉弹窗(第 6 节)再找。这个路径比较慢,只想判断"在不在"时用 exists()。
5.4 页面对象
Page 只是个持有 driver 的容器:
from kyapp import Elem, Page
class MyPage(Page):
my_profile = Elem(
title='My Profile入口',
ios=('label', 'My Profile'),
adr=('text', 'My Profile'),
)
页面方法里可以直接 self.driver,也可以把"登录"这类业务动作封在页面对象或单独的 Api 类里(demo/pages/login_page.py 的 LoginApi 就是这种写法)。
6. 弹窗处理
系统权限弹窗、升级弹窗、地区弹窗会挡住元素。传 pop_list 给入口:
kyapp.main(
...,
pop_list=[
('多设备连接弹窗', 'resourceId', 'com.lute.momcozy:id/ivClose'),
('升级提示', 'text', '稍后再说'),
],
)
每个元素是 (描述, 定位方式, 定位值)。触发时机:Elem.find() 的重试过程中——最多 3 轮,每轮并行匹配列表里的所有弹窗,命中就点掉,再继续找目标元素。
不传(None)则完全跳过弹窗处理。
7. 容易踩的坑
7.1 adr 和 ios 必须都写
Elem 构造时会把当前平台对应的元组解包,另一边是 None 会直接:
TypeError: cannot unpack non-iterable NoneType object
即使你只跑一个平台,也要把另一边的定位符随便写上(比如复制一份)。
7.2 页面模块不能在 App.platform 设置之前被 import
Elem 在导入时就决定用哪套定位符:
if App.platform == 'ios':
self.loc_key, self.loc_value = ios
else:
self.loc_key, self.loc_value = adr
所以不要在 run_ios.py 顶部 from pages.home_page import HomePage——那时 App.platform 还是 None,会静默走成安卓分支。正确做法是由 tests/ 里的用例模块去 import 页面(pytest 收集发生在 kyapp.main() 内部,此时参数已经写好)。
7.3 case_path 相对当前目录
python run_ios.py 要在项目根目录执行。在别的目录跑会报 file or directory not found: tests。
7.4 order() / depend() 需要额外装插件
kyapp.order 和 kyapp.depend 只是生成 pytest 标记:
import kyapp
class TestX(kyapp.TestCase):
@kyapp.order(1)
def test_a(self): ...
@kyapp.depend('test_a')
def test_b(self): ...
这两个标记分别要 pytest-ordering 和 pytest-dependency 才生效。没装时 pytest 只打 warning、标记静默失效,用例顺序/依赖不会按你写的来。需要就用:
pip install pytest-ordering==0.6 pytest-dependency==0.5.1
(这两个目前不在 kyapp 的依赖里,属于可选。)
7.5 iOS 出问题先看日志
每台设备的子进程日志在 ~/.kyapp/logs/<udid>/:
| 文件 | 看什么 |
|---|---|
tunnel.log |
隧道有没有建起来、端口有没有冲突 |
runwda.log |
最常见的失败原因:WDA 没装、签名不对、设备没信任 |
relay.log |
端口转发(8100)起不来 |
排查顺序:先 runwda.log,再 tunnel.log。
7.6 别依赖 kytest 命令
wheel 里声明的 console script 是 kytest = kytest.cli:main,但本发行包内没有 kytest 包,装了也跑不起来。用 python run_xxx.py 调 kyapp.main。
7.7 磁盘产物
截图在运行目录的 shots/,录像在 videos/,文件名带时间戳 + 设备标识(并发跑多设备不会覆盖)。这两个目录会越来越大,记得定期清。
8. 截图与录屏
def test_demo(self):
self.shot('首页') # 截图 + 自动 attach 到 allure
self.start_record('下单流程')
try:
...
finally:
self.stop_record() # 结束录屏 + attach 到 allure
shot()一直可用。start_record()返回录像路径,录制没起来返回None;真正的产物以stop_record()的返回值为准。- 录屏失败不会让用例变红,只打 ERROR 日志——它是辅助能力。缺
ffmpeg/scrcpy这类环境问题才会抛FileNotFoundError。 - 安卓录屏会给每台设备固定分配一个本地端口(27183–27199 里的一个,记在
~/.kyapp/scrcpy_ports.json)。 这个范围只有 17 个,正常退出时会释放;如果看到"端口池已用尽",说明有残留的 scrcpy 进程, 按提示执行pgrep -fl scrcpy清掉即可。日志里的(端口 127.0.0.1:2718x)就是本次用的端口。
9. OCR 与图像定位
元素定位表里 loc_key 写 ocr 或 image 时,走截图识别而不是控件树:
class HomePage(Page):
banner = Elem(
title='首页banner',
adr=('ocr', '限时活动'),
ios=('ocr', '限时活动'),
)
close_btn = Elem(
title='关闭按钮',
adr=('image', 'images/close.png'),
ios=('image', 'images/close.png'),
)
('ocr', '关键词'):截图做 OCR 后按文本找,点击文字中心。('image', '图片路径'):模板匹配。- 只支持
click()/click_exists(),find()/exists()/get_text()这类走控件树的方法对它们无效。 - OCR 默认中文(
zh-Hans);中英混排时英文关键词若第一次没命中,会自动用en-US再识别一次。可用环境变量KYAPP_OCR_LANG覆盖。
适合控件树拿不到的东西:Canvas 绘制、WebView 里的文字、游戏界面。
10. 多设备并行
App 是进程级全局,所以多设备就是多进程,每个进程各自调一次 kyapp.main:
import multiprocessing
import kyapp
def run_case(did):
kyapp.main(case_path='tests', did=did, pkg='com.root.momcozy', platform='ios')
if __name__ == '__main__':
for did in ['00008150-0018581E0A8A401C', '00008110-001579541AF8401E']:
multiprocessing.Process(target=run_case, args=(did,)).start()
注意几点:
- 每个子进程都要自己传参,不要靠继承父进程的
App(macOS 默认 spawn,父进程的值根本不会带过来;Linux 上 fork 会带一份旧快照,更容易出错)。 - 每个进程都有独立的
report目录才会各自出报告,否则会互相覆盖——多设备时给每个进程传不同的report_path。 - 同一台机器跑两台 iOS 设备时,隧道端口由框架按设备自动分配,不需要手动错开。
11. 报告
allure serve report # 直接起服务看
allure generate report -o report/html --clean # 生成静态报告
报告里包含:用例步骤、日志、失败截图、录屏附件。想在报告里加标注,直接用 allure 的装饰器(kyapp 已重新导出):
import kyapp
class TestX(kyapp.TestCase):
@kyapp.feature('登录')
@kyapp.story('账号密码登录')
@kyapp.title('登录成功后进入首页')
def test_login(self):
with kyapp.step('输入账号密码'):
...
12. 环境变量
| 变量 | 默认 | 作用 |
|---|---|---|
KYAPP_WDA_TIMEOUT |
60 |
facebook-wda 单次 HTTP 请求超时(秒)。链路断了会卡这么久才报错 |
KYAPP_OCR_LANG |
zh-Hans |
OCR 主语言,只接受单个语言码 |
KYAPP_OCR_FALLBACK_LANG |
en-US |
英文关键词兜底语言,置空关闭 |
KYAPP_TUNNEL_PORT_BASE |
21800 |
iOS 隧道端口池起点(每设备占一个 100 宽度的窗口) |
13. API 速查
import kyapp
kyapp.main(case_path='tests', did='<udid/serial>', pkg='<bundle/package>',
platform='ios', report_path='report', pop_list=None)
kyapp.TestCase # 用例基类
kyapp.Page # 页面基类
kyapp.Elem # 元素描述符
kyapp.App # 当前运行的会话参数(did / pkg / platform / pop_list)
kyapp.logger # loguru logger
kyapp.data # pytest.mark.parametrize 的简写
kyapp.order # 用例顺序(需 pytest-ordering)
kyapp.depend # 用例依赖(需 pytest-dependency)
kyapp.feature / story / title / step # allure 标注
用例里读会话参数:
from kyapp import App
print(App.did, App.pkg, App.platform)
页面/工具类里读会话参数请用 getattr(App, 'x', 默认值) 这种软依赖写法,这样脱离 kyapp.main 也能单独用。
Metadata
Release files for kyapp 0.0.26
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| kyapp-0.0.26.tar.gz | 67.4 kB | Without provenance | Details for kyapp-0.0.26.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| kyapp-0.0.26-py3-none-any.whl | Python 3 | none | any | Details for kyapp-0.0.26-py3-none-any.whl |
Total release size: 142.4 kB
Release files / kyapp-0.0.26.tar.gz
| Download URL | kyapp-0.0.26.tar.gz |
|---|---|
| Size | 67.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9600ef5085044e6598c69d9709625d9893591917490e1be48b7ff6e9ecb57e4a
|
|
BLAKE2b-256 checksum How to use checksums |
1eaf3428107e128f97e384a29c79cf6d4ab8255ab26424eca1b9dc5e75c370f1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.10.9
|
Release files / kyapp-0.0.26-py3-none-any.whl
| Download URL | kyapp-0.0.26-py3-none-any.whl |
|---|---|
| Size | 75.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ff39338b5786b081fb1a0e841603f634be7e0a22b0691d08ce8baa97e1dba4ca
|
|
BLAKE2b-256 checksum How to use checksums |
00869f74ed8927dac1e63564e41c0578595587f871cf8a1abf1843b3fc95aacc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.10.9
|
Security for kyapp 0.0.26
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.