├── .gitignore ├── README.md ├── apk └── v2ex.apk ├── base ├── __init__.py └── action.py ├── data ├── config.ini └── environment_info.yaml ├── exception ├── __init__.py └── exceptions.py ├── page ├── __init__.py ├── pages.py ├── template │ └── pages ├── tools.py └── yaml │ ├── home_page.yaml │ └── login_page.yaml ├── run.py ├── s.py ├── sDoc.txt ├── screenshot └── report_shot.jpeg ├── test ├── __init__.py ├── conftest.py ├── steps.py └── test_home.py ├── utils ├── __init__.py ├── config.py ├── environment.py ├── log.py ├── shell.py └── tools.py └── watch_dog.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | .idea 30 | report/ 31 | .cache 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *,cover 51 | .hypothesis/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # dotenv 87 | .env 88 | 89 | # virtualenv 90 | .venv 91 | venv/ 92 | ENV/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Link 3 | 4 | [移动端自动化测试系列之一——Appium环境搭建](http://mio4kon.com/2017/04/12/%E7%A7%BB%E5%8A%A8%E7%AB%AF%E8%87%AA%E5%8A%A8%E5%8C%96%E6%B5%8B%E8%AF%95%E7%B3%BB%E5%88%97%E4%B9%8B%E4%B8%80%E2%80%94%E2%80%94Appium%E7%8E%AF%E5%A2%83%E6%90%AD%E5%BB%BA/) 5 | 6 | [移动端自动化测试系列之二——pytest入门详解](http://mio4kon.com/2017/04/12/%E7%A7%BB%E5%8A%A8%E7%AB%AF%E8%87%AA%E5%8A%A8%E5%8C%96%E6%B5%8B%E8%AF%95%E7%B3%BB%E5%88%97%E4%B9%8B%E4%BA%8C%E2%80%94%E2%80%94pytest%E5%85%A5%E9%97%A8%E8%AF%A6%E8%A7%A3/) 7 | 8 | [移动端自动化测试系列之三——Allure测试报告](http://mio4kon.com/2017/04/12/%E7%A7%BB%E5%8A%A8%E7%AB%AF%E8%87%AA%E5%8A%A8%E5%8C%96%E6%B5%8B%E8%AF%95%E7%B3%BB%E5%88%97%E4%B9%8B%E4%B8%89%E2%80%94%E2%80%94Allure%E6%B5%8B%E8%AF%95%E6%8A%A5%E5%91%8A/) 9 | 10 | [移动端自动化测试系列之四——生成定位元素](http://mio4kon.com/2017/04/13/%E7%A7%BB%E5%8A%A8%E7%AB%AF%E8%87%AA%E5%8A%A8%E5%8C%96%E6%B5%8B%E8%AF%95%E7%B3%BB%E5%88%97%E4%B9%8B%E5%9B%9B%E2%80%94%E2%80%94%E7%94%9F%E6%88%90%E5%AE%9A%E4%BD%8D%E5%85%83%E7%B4%A0/) 11 | 12 | [移动端自动化测试系列之五——AppiumLich框架使用](http://mio4kon.com/2017/04/13/%E7%A7%BB%E5%8A%A8%E7%AB%AF%E8%87%AA%E5%8A%A8%E5%8C%96%E6%B5%8B%E8%AF%95%E7%B3%BB%E5%88%97%E4%B9%8B%E4%BA%94%E2%80%94%E2%80%94AppiumLich%E6%A1%86%E6%9E%B6%E4%BD%BF%E7%94%A8/) 13 | 14 | 15 | # Environment 16 | 17 | ## Python3: 18 | 19 | brew install python3 20 | pip3 install 21 | 22 | * Appium-Python-Client 23 | * Jinja2 24 | * PyYAML 25 | * pytest 26 | * pytest-allure-adaptor 27 | * watchdog 28 | * termcolor (not needed) 29 | 30 | ## Appium 31 | 32 | npm install -g appium 33 | npm install -g appium-doctor 34 | 35 | `appium-doctor` to ensure your system is set up properly 36 | 37 | [more](https://github.com/appium/appium) 38 | 39 | ## Allure-Commandline 40 | 41 | **Allure Framework** is a flexible lightweight multi-language test report tool with the possibility to add screenshots, logs and so on. It provides modular architecture and neat web reports with the ability to store attachments, steps, parameters and many more. 42 | 43 | brew tap qatools/formulas 44 | brew install allure-commandline 45 | 46 | 47 | **TODO**: use Jenkins Plugin 48 | 49 | [more](https://github.com/allure-framework/allure1/wiki) 50 | 51 | # Run Test 52 | 53 | start appium service: 54 | 55 | appium --address 127.0.0.1 --port 4723 --log "log_path" --log-timestamp --local-timezone --session-override 56 | 57 | run test: 58 | 59 | cd project_path 60 | python3 run.py 61 | 62 | **Html-Report** will be generate on `project_path/report/html/index.html` 63 | 64 | report shot: 65 | 66 | ![](screenshot/report_shot.jpeg) 67 | 68 | 69 | 70 | # Write Test Case 71 | 72 | ## 开启watchdog 73 | 74 | cd project_path 75 | python3 watch_dog.py 76 | 77 | 打开 `project_path/page/yaml/xxx_page.yaml`,以下面模板定位元素: 78 | 79 | ```xml 80 | 81 | --- 82 | LoginPage: 83 | dec: 登录页面 84 | locators: 85 | - 86 | name: 注册 87 | timeOutInSeconds: 20 88 | type: name 89 | value: 注册 90 | ``` 91 | 92 | 93 | ## 写测试case 94 | 95 | ```python 96 | class TestLogin: 97 | def test_login(self, action: ElementActions): 98 | L.d('test_login') 99 | account = Steps.get_account() 100 | action.click(HomePage.登录入口) 101 | action.text(LoginPage.账户, account[0]) 102 | action.text(LoginPage.密码, account[1]) 103 | action.sleep(1) 104 | action.click(LoginPage.登录) 105 | assert action.is_toast_show('欢迎回来') 106 | ``` 107 | 108 | # ChangeLog 109 | 110 | * 将原先所有元素全都写在 `pages.yaml`中方式改为可拆分的形式(`xxx_page.yaml`),方便管理.详见: `/page/yaml/` 111 | 112 | # TODO 113 | 114 | * 兼容iOS 115 | * 集成 [stf](https://github.com/openstf/stf) 116 | 117 | 118 | # License 119 | 120 | MIT 121 | 122 | 123 | -------------------------------------------------------------------------------- /apk/v2ex.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mio4kon/appium-lich/0400891f0cd53e658fad63a04407014b2721d535/apk/v2ex.apk -------------------------------------------------------------------------------- /base/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Mio4kon' 3 | from appium import webdriver 4 | -------------------------------------------------------------------------------- /base/action.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __author__ = 'Mio4kon' 4 | import time 5 | from appium import webdriver 6 | from selenium.webdriver.support.ui import WebDriverWait 7 | from utils import L 8 | from exception.exceptions import * 9 | from appium.webdriver.common.touch_action import TouchAction 10 | from selenium.common.exceptions import TimeoutException 11 | 12 | 13 | def singleton(class_): 14 | instances = {} 15 | 16 | def getinstance(*args, **kwargs): 17 | if class_ not in instances: 18 | instances[class_] = class_(*args, **kwargs) 19 | return instances[class_] 20 | 21 | return getinstance 22 | 23 | 24 | @singleton 25 | class ElementActions: 26 | def __init__(self, driver: webdriver.Remote): 27 | self.driver = driver 28 | self.width = self.driver.get_window_size()['width'] 29 | self.height = self.driver.get_window_size()['height'] 30 | 31 | def reset(self, driver: webdriver.Remote): 32 | """因为是单例,所以当driver变动的时候,需要重置一下driver 33 | 34 | Args: 35 | driver: driver 36 | 37 | """ 38 | self.driver = driver 39 | self.width = self.driver.get_window_size()['width'] 40 | self.height = self.driver.get_window_size()['height'] 41 | return self 42 | 43 | @staticmethod 44 | def sleep(s): 45 | return time.sleep(s) 46 | 47 | def back_press(self): 48 | self._send_key_event('KEYCODE_BACK') 49 | 50 | def dialog_ok(self, wait=5): 51 | locator = {'name': '对话框确认键', 'timeOutInSeconds': wait, 'type': 'id', 'value': 'android:id/button1'} 52 | self.click(locator) 53 | 54 | def set_number_by_soft_keyboard(self, nums): 55 | """模仿键盘输入数字,主要用在输入取餐码类似场景 56 | 57 | Args: 58 | nums: 数字 59 | """ 60 | list_nums = list(nums) 61 | for num in list_nums: 62 | self._send_key_event('KEYCODE_NUM', num) 63 | 64 | def swip_left(self, count=1): 65 | """向左滑动,一般用于ViewPager 66 | 67 | Args: 68 | count: 滑动次数 69 | 70 | """ 71 | for x in range(count): 72 | self.sleep(1) 73 | self.driver.swipe(self.width * 9 / 10, self.height / 2, self.width / 10, self.height / 2, 1500) 74 | 75 | def click(self, locator, count=1): 76 | """基础的点击事件 77 | 78 | Args: 79 | locator:定位器 80 | count: 点击次数 81 | """ 82 | el = self._find_element(locator) 83 | if count == 1: 84 | self.sleep(1) 85 | 86 | el.click() 87 | else: 88 | touch_action = TouchAction(self.driver) 89 | try: 90 | for x in range(count): 91 | touch_action.tap(el).perform() 92 | except: 93 | pass 94 | 95 | def get_text(self, locator): 96 | """获取元素中的text文本 97 | 98 | Args: 99 | locator:定位器 100 | count: 点击次数 101 | 102 | Returns: 103 | 如果没有该控件返回None 104 | 105 | Examples: 106 | TextView 是否显示某内容 107 | """ 108 | el = self._find_elements(locator) 109 | if el.__len__() == 0: 110 | return None 111 | return el[0].get_attribute("text") 112 | 113 | def text(self, locator, value, clear_first=False, click_first=True): 114 | """输入文本 115 | 116 | Args: 117 | locator: 定位器 118 | value: 文本内容 119 | clear_first: 是否先清空原来文本 120 | click_first: 是否先点击选中 121 | Raises: 122 | NotFoundElementError 123 | 124 | """ 125 | if click_first: 126 | self._find_element(locator).click() 127 | if clear_first: 128 | self._find_element(locator).clear() 129 | self._find_element(locator).send_keys(value) 130 | 131 | def swip_down(self, count=1, method=None): 132 | """向下滑动,常用于下拉刷新 133 | 134 | Args: 135 | count: 滑动次数 136 | method: 传入的方法 method(action) ,如果返回为True,则终止刷新 137 | 138 | Examples: 139 | action.swip_down(count=100, method=lambda action: not action.is_key_text_displayed("暂无可配送的订单")) 140 | 上面代码意思:当页面不展示"暂无可配送的订单"时停止刷新,即有单停止刷新 141 | """ 142 | if count == 1: 143 | self.driver.swipe(self.width / 2, self.height * 2 / 5, self.width / 2, self.height * 4 / 5, 2000) 144 | self.sleep(1) 145 | else: 146 | for x in range(count): 147 | self.driver.swipe(self.width / 2, self.height * 2 / 5, self.width / 2, self.height * 4 / 5, 2000) 148 | self.sleep(1) 149 | try: 150 | if method(self): 151 | break 152 | except: 153 | pass 154 | 155 | def is_toast_show(self, message, wait=20): 156 | """Android检查是否有对应Toast显示,常用于断言 157 | 158 | Args: 159 | message: Toast信息 160 | wait: 等待时间,默认20秒 161 | 162 | Returns: 163 | True 显示Toast 164 | 165 | """ 166 | locator = {'name': '[Toast] %s' % message, 'timeOutInSeconds': wait, 'type': 'xpath', 167 | 'value': '//*[contains(@text,\'%s\')]' % message} 168 | try: 169 | el = self._find_element(locator, is_need_displayed=False) 170 | return el is not None 171 | except NotFoundElementError: 172 | L.w("[Toast] 页面中未能找到 %s toast" % locator) 173 | return False 174 | 175 | def is_text_displayed(self, text, is_retry=True, retry_time=5, is_raise=False): 176 | """检查页面中是否有文本关键字 177 | 178 | 如果希望检查失败的话,不再继续执行case,使用 is_raise = True 179 | 180 | Args: 181 | text: 关键字(请确保想要的检查的关键字唯一) 182 | is_retry: 是否重试,默认为true 183 | retry_time: 重试次数,默认为5 184 | is_raise: 是否抛异常 185 | Returns: 186 | True: 存在关键字 187 | Raises: 188 | 如果is_raise = true,可能会抛NotFoundElementError 189 | 190 | """ 191 | 192 | try: 193 | if is_retry: 194 | return WebDriverWait(self.driver, retry_time).until( 195 | lambda driver: self._find_text_in_page(text)) 196 | else: 197 | return self._find_text_in_page(text) 198 | except TimeoutException: 199 | L.w("[Text]页面中未找到 %s 文本" % text) 200 | if is_raise: 201 | raise NotFoundTextError 202 | else: 203 | return False 204 | 205 | def is_element_displayed(self, locator, is_retry=True, ): 206 | """检查控件是否显示 207 | 208 | Args: 209 | is_retry:是否重试检查,重试时间为'timeOutInSeconds' 210 | locator: 定位器 211 | Returns: 212 | true: 显示 213 | false: 不显示 214 | """ 215 | if is_retry: 216 | el = self._find_element(locator, is_need_displayed=True) 217 | return el is not None 218 | else: 219 | el = self._get_element_by_type(self.driver, locator) 220 | return el.is_displayed() 221 | 222 | # ======================= private ==================== 223 | 224 | def _find_text_in_page(self, text): 225 | """检查页面中是否有文本关键字 226 | 拿到页面全部source,暴力检查text是否在source中 227 | Args: 228 | text: 检查的文本 229 | 230 | Returns: 231 | True : 存在 232 | 233 | """ 234 | L.i("[查找] 文本 %s " % text) 235 | return text in self.driver.page_source 236 | 237 | def _find_element(self, locator, is_need_displayed=True): 238 | """查找单个元素,如果有多个返回第一个 239 | 240 | Args: 241 | locator: 定位器 242 | is_need_displayed: 是否需要定位的元素必须展示 243 | 244 | Returns: 元素 245 | 246 | Raises: NotFoundElementError 247 | 未找到元素会抛 NotFoundElementError 异常 248 | 249 | """ 250 | if 'timeOutInSeconds' in locator: 251 | wait = locator['timeOutInSeconds'] 252 | else: 253 | wait = 20 254 | 255 | try: 256 | if is_need_displayed: 257 | WebDriverWait(self.driver, wait).until( 258 | lambda driver: self._get_element_by_type(driver, locator).is_displayed()) 259 | else: 260 | WebDriverWait(self.driver, wait).until( 261 | lambda driver: self._get_element_by_type(driver, locator) is not None) 262 | return self._get_element_by_type(self.driver, locator) 263 | except Exception as e: 264 | L.e("[element] 页面中未能找到 %s 元素" % locator) 265 | raise NotFoundElementError 266 | 267 | def _find_elements(self, locator): 268 | """查找多元素(不会抛异常) 269 | 270 | Args: 271 | locator: 定位器 272 | 273 | Returns:元素列表 或 [] 274 | 275 | """ 276 | if 'timeOutInSeconds' in locator: 277 | wait = locator['timeOutInSeconds'] 278 | else: 279 | wait = 20 280 | 281 | try: 282 | WebDriverWait(self.driver, wait).until( 283 | lambda driver: self._get_element_by_type(driver, locator, False).__len__() > 0) 284 | return self._get_element_by_type(self.driver, locator, False) 285 | except: 286 | L.w("[elements] 页面中未能找到 %s 元素" % locator) 287 | return [] 288 | 289 | @staticmethod 290 | def _get_element_by_type(driver, locator, element=True): 291 | """通过locator定位元素(默认定位单个元素) 292 | 293 | Args: 294 | driver:driver 295 | locator:定位器 296 | element: 297 | true:查找单个元素 298 | false:查找多个元素 299 | 300 | Returns:单个元素 或 元素list 301 | 302 | """ 303 | value = locator['value'] 304 | ltype = locator['type'] 305 | L.i("[查找]元素 %s " % locator) 306 | if ltype == 'name': 307 | ui_value = 'new UiSelector().textContains' + '(\"' + value + '\")' 308 | return driver.find_element_by_android_uiautomator( 309 | ui_value) if element else driver.find_elements_by_android_uiautomator(ui_value) 310 | else: 311 | return driver.find_element(ltype, value) if element else driver.find_elements(ltype, value) 312 | 313 | def _send_key_event(self, arg, num=0): 314 | """ 315 | 操作实体按键 316 | Code码:https://developer.android.com/reference/android/view/KeyEvent.html 317 | Args: 318 | arg: event_list key 319 | num: KEYCODE_NUM 时用到对应数字 320 | 321 | """ 322 | event_list = {'KEYCODE_HOME': 3, 'KEYCODE_BACK': 4, 'KEYCODE_MENU': 82, 'KEYCODE_NUM': 8} 323 | if arg == 'KEYCODE_NUM': 324 | self.driver.press_keycode(8 + int(num)) 325 | elif arg in event_list: 326 | self.driver.press_keycode(int(event_list[arg])) 327 | -------------------------------------------------------------------------------- /data/config.ini: -------------------------------------------------------------------------------- 1 | [name] 2 | apk = v2ex.apk 3 | app_activity = com.czbix.v2ex.ui.MainActivity 4 | app_package = com.czbix.v2ex 5 | 6 | [account] 7 | account_success = miofortest 8 | password_success = 123456789 9 | -------------------------------------------------------------------------------- /data/environment_info.yaml: -------------------------------------------------------------------------------- 1 | !EnvironmentInfo 2 | apk: /Users/mio4kon/code/python_workspace/appium-lich/apk/v2ex.apk 3 | app_activity: com.czbix.v2ex.ui.MainActivity 4 | app_package: com.czbix.v2ex 5 | appium: 1.6.4 6 | devices: 7 | - !DeviceInfo 8 | device_name: LE67A06230261422 9 | platform_name: Android 10 | platform_version: '6.0' 11 | html_report: /Users/mio4kon/code/python_workspace/appium-lich/report/html 12 | pages_yaml: /Users/mio4kon/code/python_workspace/appium-lich/page/yaml 13 | xml_report: /Users/mio4kon/code/python_workspace/appium-lich/report/xml 14 | -------------------------------------------------------------------------------- /exception/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Mio4kon' -------------------------------------------------------------------------------- /exception/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Mio4kon' 3 | 4 | 5 | class NotFoundElementError(Exception): 6 | pass 7 | 8 | 9 | class NotFoundTextError(Exception): 10 | pass 11 | -------------------------------------------------------------------------------- /page/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Mio4kon' -------------------------------------------------------------------------------- /page/pages.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Mio4kon' 3 | from page import tools 4 | 5 | pages = tools.parse() 6 | 7 | 8 | def get_locater(clazz_name, method_name): 9 | locators = pages[clazz_name]['locators'] 10 | for locator in locators: 11 | if locator['name'] == method_name: 12 | return locator 13 | 14 | 15 | class HomePage: 16 | 登录入口 = get_locater('HomePage', '登录入口') 17 | 18 | 19 | class LoginPage: 20 | 账户 = get_locater('LoginPage', '账户') 21 | 密码 = get_locater('LoginPage', '密码') 22 | 登录 = get_locater('LoginPage', '登录') 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /page/template/pages: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Mio4kon' 3 | from page import tools 4 | 5 | pages = tools.parse() 6 | 7 | 8 | def get_locater(clazz_name, method_name): 9 | locators = pages[clazz_name]['locators'] 10 | for locator in locators: 11 | if locator['name'] == method_name: 12 | return locator 13 | 14 | {% for page, locators in page_list.items() %} 15 | class {{page}}:{% for locator in locators %} 16 | {{locator}} = get_locater('{{page}}', '{{locator}}'){% endfor %} 17 | 18 | {% endfor %} 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /page/tools.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Mio4kon' 3 | from utils import L 4 | import yaml 5 | import jinja2 6 | from utils.environment import Environment 7 | import os 8 | import os.path 9 | from utils.config import Config 10 | 11 | pages_path = Environment().get_environment_info().pages_yaml 12 | 13 | 14 | def parse(): 15 | L.i('解析yaml, Path:' + pages_path) 16 | pages = {} 17 | for root, dirs, files in os.walk(pages_path): 18 | for name in files: 19 | watch_file_path = os.path.join(root, name) 20 | with open(watch_file_path, 'r', encoding='utf-8') as f: 21 | page = yaml.safe_load(f) 22 | pages.update(page) 23 | return pages 24 | 25 | 26 | class GenPages: 27 | @staticmethod 28 | def gen_page_list(): 29 | """ 30 | 将page.yaml转换成下面dict: 31 | return: {'HomePage': ['登录入口'], 'LoginPage': ['账户', '密码', '登录']} 32 | """ 33 | _page_list = {} 34 | pages = parse() 35 | for page, value in pages.items(): 36 | locators = value['locators'] 37 | locator_names = [] 38 | for locator in locators: 39 | locator_names.append(locator['name']) 40 | _page_list[page] = locator_names 41 | return _page_list 42 | 43 | @staticmethod 44 | def gen_page_py(): 45 | """ 46 | 利用jinja2生成pages.py文件 47 | """ 48 | base_dir = Config.BASE_PATH_DIR 49 | template_loader = jinja2.FileSystemLoader(searchpath=base_dir + "/page/template") 50 | template_env = jinja2.Environment(loader=template_loader) 51 | page_list = GenPages.gen_page_list() 52 | print(page_list) 53 | _templateVars = { 54 | 'page_list': page_list 55 | } 56 | template = template_env.get_template("pages") 57 | with open(base_dir + '/page/pages.py', 'w', encoding='utf-8') as f: 58 | f.write(template.render(_templateVars)) 59 | 60 | 61 | if __name__ == '__main__': 62 | GenPages.gen_page_py() 63 | -------------------------------------------------------------------------------- /page/yaml/home_page.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | HomePage: 3 | dec: 主界面 4 | locators: 5 | - 6 | name: 登录入口 7 | type: id 8 | value: com.czbix.v2ex:id/username_tv -------------------------------------------------------------------------------- /page/yaml/login_page.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | LoginPage: 3 | dec: 登录页面 4 | locators: 5 | - 6 | name: 账户 7 | type: id 8 | value: com.czbix.v2ex:id/account 9 | - 10 | name: 密码 11 | type: id 12 | value: com.czbix.v2ex:id/password 13 | - 14 | name: 登录 15 | type: id 16 | value: com.czbix.v2ex:id/sign_in 17 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | __author__ = 'Mio4kon' 5 | 6 | import pytest 7 | from utils.environment import Environment 8 | from utils.shell import Shell 9 | from utils import L 10 | import sys 11 | 12 | """ 13 | run all case: 14 | python3 run.py 15 | 16 | run one module case: 17 | python3 run.py test/test_home.py 18 | 19 | run case with key word: 20 | python3 run.py -k 21 | 22 | """ 23 | 24 | if __name__ == '__main__': 25 | env = Environment() 26 | xml_report_path = env.get_environment_info().xml_report 27 | html_report_path = env.get_environment_info().html_report 28 | # 开始测试 29 | args = ['-s', '-q', '--alluredir', xml_report_path] 30 | self_args = sys.argv[1:] 31 | pytest.main(args + self_args) 32 | # 生成html测试报告 33 | cmd = 'allure generate %s -o %s' % (xml_report_path, html_report_path) 34 | try: 35 | Shell.invoke(cmd) 36 | except: 37 | L.e("Html测试报告生成失败,确保已经安装了Allure-Commandline") 38 | -------------------------------------------------------------------------------- /s.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __author__ = 'Mio4kon' 4 | 5 | from appium import webdriver 6 | from base.action import ElementActions 7 | from utils.environment import Environment 8 | 9 | env = Environment().get_environment_info() 10 | capabilities = {'platformName': env.devices[0].platform_name, 11 | 'platformVersion': env.devices[0].platform_version, 12 | 'deviceName': env.devices[0].device_name, 13 | 'app': env.apk, 14 | 'clearSystemFiles': True, 15 | 'appActivity': env.app_activity, 16 | 'appPackage': env.app_package, 17 | 'automationName': 'UIAutomator2', 18 | 'noSign': True, 19 | 'newCommandTimeout': 60 * 100} 20 | host = "http://localhost:4723/wd/hub" 21 | driver = webdriver.Remote(host, capabilities) 22 | action = ElementActions(driver) 23 | -------------------------------------------------------------------------------- /sDoc.txt: -------------------------------------------------------------------------------- 1 | 直接shell中粘贴下面,方便自己实时调试,比如想测试xpath 2 | 3 | import s 4 | action = s.action 5 | driver = s.driver 6 | -------------------------------------------------------------------------------- /screenshot/report_shot.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mio4kon/appium-lich/0400891f0cd53e658fad63a04407014b2721d535/screenshot/report_shot.jpeg -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Mio4kon' -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Mio4kon' 3 | import pytest 4 | from appium import webdriver 5 | from base.action import ElementActions 6 | from utils.environment import Environment 7 | 8 | 9 | @pytest.fixture() 10 | def action(): 11 | env = Environment().get_environment_info() 12 | capabilities = {'platformName': env.devices[0].platform_name, 13 | 'platformVersion': env.devices[0].platform_version, 14 | 'deviceName': env.devices[0].device_name, 15 | 'app': env.apk, 16 | 'clearSystemFiles': True, 17 | 'appActivity': env.app_activity, 18 | 'appPackage': env.app_package, 19 | 'automationName': 'UIAutomator2', 20 | 'noSign': True 21 | } 22 | host = "http://localhost:4723/wd/hub" 23 | driver = webdriver.Remote(host, capabilities) 24 | yield ElementActions(driver).reset(driver) 25 | driver.quit() 26 | 27 | 28 | @pytest.fixture(scope="module") 29 | def action2(): 30 | env = Environment().get_environment_info() 31 | capabilities = {'platformName': env.devices[0].platform_name, 32 | 'platformVersion': env.devices[0].platform_version, 33 | 'deviceName': env.devices[0].device_name, 34 | 'app': env.apk, 35 | 'clearSystemFiles': True, 36 | 'appActivity': env.app_activity, 37 | 'appPackage': env.app_package, 38 | 'automationName': 'UIAutomator2', 39 | 'noSign': True 40 | } 41 | host = "http://localhost:4723/wd/hub" 42 | driver = webdriver.Remote(host, capabilities) 43 | yield ElementActions(driver).reset(driver) 44 | driver.quit() 45 | -------------------------------------------------------------------------------- /test/steps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | __author__ = 'Mio4kon' 5 | import allure 6 | 7 | from utils import L 8 | from utils.environment import Environment 9 | 10 | 11 | class Steps: 12 | @staticmethod 13 | @allure.step(title="获取账号和密码") 14 | def get_account(): 15 | account = Environment().get_inited_config().account_success 16 | pwd = Environment().get_inited_config().password_success 17 | L.d('账号:%s 密码 %s' % (account, pwd)) 18 | return [account, pwd] 19 | -------------------------------------------------------------------------------- /test/test_home.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Mio4kon' 3 | from base.action import ElementActions 4 | from page.pages import * 5 | from test.steps import Steps 6 | from utils import L 7 | 8 | 9 | class TestLogin: 10 | def test_login(self, action: ElementActions): 11 | L.d('test_login') 12 | account = Steps.get_account() 13 | action.click(HomePage.登录入口) 14 | action.text(LoginPage.账户, account[0]) 15 | action.text(LoginPage.密码, account[1]) 16 | action.sleep(1) 17 | action.click(LoginPage.登录) 18 | assert action.is_toast_show('欢迎回来') 19 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | __author__ = 'Mio4kon' 4 | from .log import Log as L -------------------------------------------------------------------------------- /utils/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Mio4kon' 3 | from configparser import ConfigParser 4 | import os 5 | from utils import L 6 | 7 | 8 | def singleton(class_): 9 | instances = {} 10 | 11 | def getinstance(*args, **kwargs): 12 | if class_ not in instances: 13 | instances[class_] = class_(*args, **kwargs) 14 | return instances[class_] 15 | 16 | return getinstance 17 | 18 | 19 | class Config: 20 | DEFAULT_CONFIG_DIR = str(os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, "data/config.ini"))) 21 | BASE_PATH_DIR = str(os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))) 22 | 23 | # titles: 24 | TITLE_NAME = "name" 25 | TITLE_ACCOUNT = "account" 26 | # values: 27 | # [name] 28 | VALUE_APP = "apk" 29 | VALUE_APP_ACTIVITY = "app_activity" 30 | VALUE_APP_PACKAGE = "app_package" 31 | # [account] 32 | VALUE_ACCOUNT_SUCCESS = "account_success" 33 | VALUE_PASSWORD_SUCCESS = "password_success" 34 | 35 | def __init__(self): 36 | self.path = Config.DEFAULT_CONFIG_DIR 37 | self.cp = ConfigParser() 38 | self.cp.read(self.path) 39 | L.i('初始化config...config path: ' + self.path) 40 | apk_name = self.get_config(Config.TITLE_NAME, Config.VALUE_APP) 41 | self.apk_path = Config.BASE_PATH_DIR + '/apk/' + apk_name 42 | self.xml_report_path = Config.BASE_PATH_DIR + '/report/xml' 43 | self.html_report_path = Config.BASE_PATH_DIR + '/report/html' 44 | self.pages_yaml_path = Config.BASE_PATH_DIR + '/page/yaml' 45 | self.env_yaml_path = Config.BASE_PATH_DIR + '/data/environment_info.yaml' 46 | self.app_activity = self.get_config(Config.TITLE_NAME, Config.VALUE_APP_ACTIVITY) 47 | self.app_package = self.get_config(Config.TITLE_NAME, Config.VALUE_APP_PACKAGE) 48 | self.account_success = self.get_config(Config.TITLE_ACCOUNT, Config.VALUE_ACCOUNT_SUCCESS) 49 | self.password_success = self.get_config(Config.TITLE_ACCOUNT, Config.VALUE_PASSWORD_SUCCESS) 50 | 51 | def set_config(self, title, value, text): 52 | self.cp.set(title, value, text) 53 | with open(self.path, "w+") as f: 54 | return self.cp.write(f) 55 | 56 | def add_config(self, title): 57 | self.cp.add_section(title) 58 | with open(self.path, "w+") as f: 59 | return self.cp.write(f) 60 | 61 | def get_config(self, title, value): 62 | return self.cp.get(title, value) 63 | 64 | -------------------------------------------------------------------------------- /utils/environment.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Mio4kon' 3 | from utils.config import Config 4 | from utils import L 5 | from utils.tools import Device 6 | from utils.shell import Shell 7 | from utils.shell import ADB 8 | import yaml 9 | 10 | 11 | class EnvironmentInfo(yaml.YAMLObject): 12 | yaml_loader = yaml.SafeLoader 13 | yaml_tag = u'!EnvironmentInfo' 14 | 15 | def __init__(self, appium, devices, apk, pages_yaml, xml_report, html_report, app_activity, app_package): 16 | self.appium = appium 17 | self.pages_yaml = pages_yaml 18 | self.xml_report = xml_report 19 | self.html_report = html_report 20 | self.apk = apk 21 | self.devices = devices 22 | self.app_activity = app_activity 23 | self.app_package = app_package 24 | 25 | 26 | class DeviceInfo(yaml.YAMLObject): 27 | yaml_loader = yaml.SafeLoader 28 | yaml_tag = u'!DeviceInfo' 29 | 30 | def __init__(self, device_name, platform_name, platform_version): 31 | self.device_name = device_name 32 | self.platform_name = platform_name 33 | self.platform_version = platform_version 34 | 35 | 36 | def singleton(class_): 37 | instances = {} 38 | 39 | def getinstance(*args, **kwargs): 40 | if class_ not in instances: 41 | instances[class_] = class_(*args, **kwargs) 42 | return instances[class_] 43 | 44 | return getinstance 45 | 46 | 47 | @singleton 48 | class Environment: 49 | def __init__(self): 50 | self.devices = Device.get_android_devices() 51 | self.appium_v = Shell.invoke('appium -v').splitlines()[0].strip() 52 | self.config = Config() 53 | self.check_environment() 54 | self.save_environment() 55 | 56 | def check_environment(self): 57 | L.i('检查环境...') 58 | # 检查appium版本 59 | if '1.6' not in self.appium_v: 60 | L.e('appium 版本有问题') 61 | exit() 62 | else: 63 | L.i('appium version {}'.format(self.appium_v)) 64 | # 检查设备 65 | if not self.devices: 66 | L.e('没有设备连接') 67 | exit() 68 | else: 69 | L.i('已连接设备:', self.devices) 70 | 71 | def save_environment(self): 72 | infos = [] 73 | env_path = self.config.env_yaml_path 74 | apk_path = self.config.apk_path 75 | pages_yaml_path = self.config.pages_yaml_path 76 | xml_report_path = self.config.xml_report_path 77 | html_report_path = self.config.html_report_path 78 | app_activity = self.config.app_activity 79 | app_package = self.config.app_package 80 | for deviceName in self.devices: 81 | info = DeviceInfo(deviceName, "Android", ADB(deviceName).get_android_version()) 82 | infos.append(info) 83 | env_info = EnvironmentInfo(self.appium_v, infos, apk_path, pages_yaml_path, xml_report_path, html_report_path, 84 | app_activity, app_package) 85 | with open(env_path, 'w') as f: 86 | yaml.dump(env_info, f, default_flow_style=False) 87 | L.i('保存环境配置 Path:' + env_path) 88 | 89 | def get_environment_info(self) -> EnvironmentInfo: 90 | env_path = self.config.env_yaml_path 91 | with open(env_path, 'r') as f: 92 | env_info = yaml.safe_load(f) 93 | return env_info 94 | 95 | def get_inited_config(self): 96 | return self.config 97 | 98 | 99 | if __name__ == '__main__': 100 | env = Environment() 101 | # 检查运行环境 102 | env.check_environment() 103 | # 将环境存在本地 104 | env.save_environment() 105 | info = env.get_environment_info() 106 | print(info.app_package) 107 | -------------------------------------------------------------------------------- /utils/log.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Mio4kon' 3 | import time 4 | 5 | 6 | class Log: 7 | @staticmethod 8 | def e(msg, list_msg=[]): 9 | if list_msg: 10 | Log.show_list(msg, list_msg, Log.e) 11 | else: 12 | ColorLog.show_error(get_now_time() + " [Error]:" + "".join(msg)) 13 | 14 | @staticmethod 15 | def w(msg, list_msg=[]): 16 | if list_msg: 17 | Log.show_list(msg, list_msg, Log.w) 18 | else: 19 | ColorLog.show_warn(get_now_time() + " [Warn]:" + "".join(msg)) 20 | 21 | @staticmethod 22 | def i(msg, list_msg=[]): 23 | if list_msg: 24 | Log.show_list(msg, list_msg, Log.i) 25 | else: 26 | ColorLog.show_info(get_now_time() + " [Info]:" + "".join(msg)) 27 | 28 | @staticmethod 29 | def d(msg, list_msg=[]): 30 | if list_msg: 31 | Log.show_list(msg, list_msg, Log.d) 32 | else: 33 | ColorLog.show_debug(get_now_time() + " [Debug]:" + "".join(msg)) 34 | 35 | @staticmethod 36 | def show_list(msg, list_msg, f): 37 | temp = msg + "[ " + "\t".join(list_msg) + " ]" 38 | f(temp) 39 | 40 | 41 | 42 | class ColorLog: 43 | @staticmethod 44 | def c(msg, colour): 45 | try: 46 | from termcolor import colored, cprint 47 | p = lambda x: cprint(x, '%s' % colour) 48 | return p(msg) 49 | except: 50 | print(msg) 51 | 52 | @staticmethod 53 | def show_verbose(msg): 54 | ColorLog.c(msg, 'white') 55 | 56 | @staticmethod 57 | def show_debug(msg): 58 | ColorLog.c(msg, 'blue') 59 | 60 | @staticmethod 61 | def show_info(msg): 62 | ColorLog.c(msg, 'green') 63 | 64 | @staticmethod 65 | def show_warn(msg): 66 | ColorLog.c(msg, 'yellow') 67 | 68 | @staticmethod 69 | def show_error(msg): 70 | ColorLog.c(msg, 'red') 71 | 72 | 73 | def get_now_time(): 74 | return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())) 75 | -------------------------------------------------------------------------------- /utils/shell.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import platform 4 | 5 | __author__ = 'Mio4kon' 6 | import subprocess 7 | 8 | 9 | class Shell: 10 | @staticmethod 11 | def invoke(cmd): 12 | # shell设为true,程序将通过shell来执行 13 | # stdin, stdout, stderr分别表示程序的标准输入、输出、错误句柄。 14 | # 他们可以是PIPE,文件描述符或文件对象,也可以设置为None,表示从父进程继承。 15 | # subprocess.PIPE实际上为文本流提供一个缓存区 16 | output, errors = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).communicate() 17 | o = output.decode("utf-8") 18 | return o 19 | 20 | 21 | # 判断是否设置环境变量ANDROID_HOME 22 | if "ANDROID_HOME" in os.environ: 23 | command = os.path.join( 24 | os.environ["ANDROID_HOME"], 25 | "platform-tools", 26 | "adb") 27 | else: 28 | raise EnvironmentError( 29 | "Adb not found in $ANDROID_HOME path: %s." % 30 | os.environ["ANDROID_HOME"]) 31 | 32 | 33 | class ADB(object): 34 | """ 35 | 参数: device_id 36 | """ 37 | 38 | def __init__(self, device_id=""): 39 | 40 | if device_id == "": 41 | self.device_id = "" 42 | else: 43 | self.device_id = "-s %s" % device_id 44 | 45 | def adb(self, args): 46 | cmd = "%s %s %s" % (command, self.device_id, str(args)) 47 | return Shell.invoke(cmd) 48 | 49 | def shell(self, args): 50 | cmd = "%s %s shell %s" % (command, self.device_id, str(args),) 51 | return Shell.invoke(cmd) 52 | 53 | def get_device_state(self): 54 | """ 55 | 获取设备状态: offline | bootloader | device 56 | """ 57 | return self.adb("get-state").stdout.read().strip() 58 | 59 | def get_device_id(self): 60 | """ 61 | 获取设备id号,return serialNo 62 | """ 63 | return self.adb("get-serialno").stdout.read().strip() 64 | 65 | def get_android_version(self): 66 | """ 67 | 获取设备中的Android版本号,如4.2.2 68 | """ 69 | return self.shell( 70 | "getprop ro.build.version.release").strip() 71 | 72 | def get_sdk_version(self): 73 | """ 74 | 获取设备SDK版本号 75 | """ 76 | return self.shell("getprop ro.build.version.sdk").strip() 77 | -------------------------------------------------------------------------------- /utils/tools.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Mio4kon' 3 | from utils.shell import Shell 4 | from utils import L 5 | 6 | 7 | class Device: 8 | @staticmethod 9 | def get_android_devices(): 10 | android_devices_list = [] 11 | for device in Shell.invoke('adb devices').splitlines(): 12 | if 'device' in device and 'devices' not in device: 13 | device = device.split('\t')[0] 14 | android_devices_list.append(device) 15 | return android_devices_list 16 | 17 | 18 | if __name__ == '__main__': 19 | devices = Device.get_android_devices() 20 | L.i("devices: ", devices) 21 | -------------------------------------------------------------------------------- /watch_dog.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __author__ = 'Mio4kon' 4 | 5 | import time 6 | 7 | from watchdog.events import PatternMatchingEventHandler 8 | from watchdog.observers import Observer 9 | 10 | from page.tools import GenPages 11 | from utils import L 12 | from utils.environment import Environment 13 | 14 | 15 | def gen_page_py(): 16 | GenPages.gen_page_py() 17 | 18 | 19 | class WatchHandler(PatternMatchingEventHandler): 20 | patterns = ["*.yaml"] 21 | 22 | def on_modified(self, event): 23 | L.i('监听到文件: yaml 发生了变化') 24 | try: 25 | gen_page_py() 26 | except Exception as e: 27 | L.e('\n!!!!!!!---pages.yaml---!!!!!!\n解析文件 pages.yaml 错误\n' 28 | '请到{}路径下检查修改后重新保存.'.format(self.watch_path)) 29 | 30 | 31 | if __name__ == "__main__": 32 | event_handler = WatchHandler() 33 | full_path = Environment().get_environment_info().pages_yaml 34 | print(full_path) 35 | observer = Observer() 36 | observer.schedule(event_handler, full_path) 37 | observer.start() 38 | try: 39 | while True: 40 | time.sleep(1) 41 | except KeyboardInterrupt: 42 | observer.stop() 43 | observer.join() 44 | --------------------------------------------------------------------------------