├── .DS_Store ├── Pipfile ├── README.md ├── cases ├── .DS_Store ├── conftest.py ├── pytest.ini └── sample_test │ ├── .DS_Store │ └── test_demo.py ├── config.py ├── configuration.yaml ├── data ├── .DS_Store └── app-debug.apk ├── img ├── Xnip2019-05-08_14-33-11.jpg ├── Xnip2019-05-09_17-01-37.jpg ├── Xnip2019-05-09_17-07-08.jpg ├── Xnip2019-05-09_17-08-34.jpg ├── Xnip2019-05-09_17-18-27.jpg ├── Xnip2019-05-09_17-22-23.jpg ├── Xnip2019-05-10_11-00-55.jpg ├── Xnip2019-05-10_11-03-28.jpg ├── Xnip2019-05-10_11-06-46.jpg ├── Xnip2019-05-10_11-14-51.jpg ├── Xnip2019-05-24_18-14-10.jpg ├── Xnip2019-05-30_17-48-41.jpg └── Xnip2019-07-10_19-40-53.jpg ├── run.sh └── src ├── common.air ├── .DS_Store └── common.py ├── pages ├── base_page.air │ ├── .DS_Store │ └── base_page.py ├── home_page.air │ ├── .DS_Store │ └── home_page.py └── second_page.air │ ├── .DS_Store │ └── second_page.py └── utils ├── .DS_Store ├── analysis_ipa.py ├── function.py └── rw_xml.py /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/.DS_Store -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | pytest = "*" 10 | pocoui = "*" 11 | allure-pytest = "*" 12 | pytest-rerunfailures = "*" 13 | airtest = "*" 14 | pyyaml = "*" 15 | tenacity = "~=5.0.3" 16 | xlsxwriter = "*" 17 | 18 | [requires] 19 | python_version = "3.6" 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sample for Automated UI Test of Apps. 2 | ![](/img/Xnip2019-05-10_11-00-55.jpg) 3 | ![](/img/Xnip2019-05-24_18-14-10.jpg) 4 | ![](/img/Xnip2019-05-10_11-14-51.jpg) 5 | ![](/img/Xnip2019-05-10_11-03-28.jpg) 6 | ![](/img/Xnip2019-05-10_11-06-46.jpg) 7 | ## 使用以下优秀工具: 8 | - pipenv 9 | - pytest 10 | - allure 11 | - airtest 12 | - jenkins 13 | 14 | ## 使用Jenkins插件: 15 | - git 16 | - Extended Choice Parameter 17 | - Allure 18 | 19 | ## iOS 所需工具: 20 | - Apple Configurator 2(可从App Store安装) 命令行工具 cfgutil 21 | 22 | ## 说明: 23 | - run.sh 工作流程 24 | - 使用 pipenv 管理虚拟环境,--skip-lock 应对 pipenv lock问题 25 | - 在 config.py 中获取 Jenkins Job 参数,写入配置文件,安装APP,参数写入 allure environment 文件用以展示 26 | - 使用 pytest 来组织管理和运行用例 27 | - 使用 airtest 来操作APP页面 28 | - 使用 allure 来记录信息和生成报告 29 | - 采用 PageObject 组织页面,以复用代码和后期维护 30 | - 使用魔法方法 __ __getattribute__ __ 实现简化控件获取使用和动态获取页面对象 31 | - 控件只需以字典方式定义,便可自动获取并实例化 32 | - 页面只需创建,便可自动获取并实例化 33 | - 使用 命令行工具 cfgutil 来管理 iOS APP 34 | - 屏幕截图存放在 data/report.xlsx 中 35 | 36 | ## 运行配置: 37 | - phones 可定义多个,则会在每个设备上轮流运行用例 38 | - iOS 要按照airtest的说明配置iOS-Tagent,和iproxy 39 | 40 | ## 注意: 41 | - data 目录下存放着一个Android 示例应用 42 | - 需测试 iOS 的话,应该在 Mac 机上部署,比如在 Macmini 上。 43 | - 示例中没有使用git仓库,可自行配置 44 | - 本地运行单个用例建议使用 pycharm,添加 pytest 并设置参数运行 45 | ![pycharm配置测试示例](/img/Xnip2019-05-08_14-33-11.jpg) 46 | ![Pycharm pytest 运行结果图](/img/Xnip2019-05-09_17-18-27.jpg) 47 | - [Jenkins示例参数化配置](/img/Xnip2019-05-09_17-01-37.jpg) 48 | 49 | ## Jenkins中查看Allure Report: 50 | ![](/img/Xnip2019-07-10_19-40-53.jpg) 51 | ![](/img/Xnip2019-05-09_17-22-23.jpg) 52 | ![](/img/Xnip2019-05-09_17-07-08.jpg) 53 | ![](/img/Xnip2019-05-09_17-08-34.jpg) 54 | 55 | ## 截图表格 56 | ![](/img/Xnip2019-05-30_17-48-41.jpg) 57 | 58 | ## 建议: 59 | - 深入学习编程技术 60 | - 深入学习 自动化测试技术 61 | - 深入学习 Pytest, Allure, Airtest 等框架 62 | 63 | 64 | Todo: 65 | - 丰富功能 66 | - [x] 异常捕获并截屏 67 | - [x] wait exists, click 68 | - [x] 绑定 wait exists, click 69 | - [x] 截图到Excel中 70 | - [x] 重试连接设备 71 | - [x] 绑定可重试图像操作 72 | - 重构 73 | - [x] 动态获取页面 74 | -------------------------------------------------------------------------------- /cases/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/cases/.DS_Store -------------------------------------------------------------------------------- /cases/conftest.py: -------------------------------------------------------------------------------- 1 | 2 | from collections import namedtuple 3 | from functools import wraps 4 | 5 | import allure 6 | import pytest 7 | import xlsxwriter 8 | import yaml 9 | from logging import getLogger 10 | from airtest.core.api import * 11 | from airtest.core.helper import device_platform, ST 12 | 13 | # 导入 common 包之前先设置 ST.PROJECT_ROOT,因为 using 依赖此路径 14 | from src.utils.function import generate_random_num_str 15 | 16 | ST.PROJECT_ROOT = os.environ['PROJECT_SPACE_ROOT'] 17 | 18 | using("src/common.air") 19 | from common import AndroidApp, IOSApp 20 | 21 | ANDROID_PLATFORM = "Android" 22 | IOS_PLATFORM = "iOS" 23 | TESTCASEPATTERN = "https://demo.feie.work/projects/2/cases/{}" 24 | 25 | Phone = namedtuple('Phone', ['platform', 'ip', 'port', 'uuid', 'package']) 26 | 27 | logger = getLogger(__name__) 28 | logger.info("get PROJECT_ROOT: {}".format(ST.PROJECT_ROOT)) 29 | 30 | CLIENT_CONFIGURATION_PATH = os.path.join(ST.PROJECT_ROOT, "configuration.yaml") 31 | logger.info("CLIENT_CONFIGURATION_PATH : {}".format(CLIENT_CONFIGURATION_PATH)) 32 | 33 | # 读取配置 34 | with open(CLIENT_CONFIGURATION_PATH, 'r', encoding='utf-8') as f: 35 | config = yaml.load(f, Loader=yaml.FullLoader) 36 | logger.info("get client configuration: \n {}".format(config)) 37 | 38 | # 把运行平台设置到环境中 39 | os.environ['client_platform'] = config['client_platform'] 40 | 41 | phone_ip = config['client_ip'] 42 | phone_port = config['client_port'] 43 | app_name = config['app_name'] 44 | test_phones = config['phones'] 45 | 46 | 47 | @allure.step("尝试唤醒当前设备:") 48 | def wake_device(current_device): 49 | current_device.wake() 50 | 51 | 52 | @pytest.fixture(params=test_phones, scope="session") 53 | def app_fixture(request): 54 | """ 55 | 相当于setupclass、teardownclas,提供公共资源,测试完毕释放资源 56 | """ 57 | with allure.step("工程初始化和生成APP对象"): 58 | """ 59 | setup code 60 | """ 61 | logger.info("Session start test.") 62 | 63 | ST.THRESHOLD_STRICT = 0.6 64 | client_platform = os.getenv('client_platform') 65 | 66 | try: 67 | app = None 68 | if client_platform == ANDROID_PLATFORM: 69 | app = AndroidApp(Phone(client_platform, phone_ip, phone_port, request.param, app_name)) 70 | elif client_platform == IOS_PLATFORM: 71 | app = IOSApp(Phone(client_platform, phone_ip, phone_port, request.param, app_name)) 72 | app.base_page.app_name = app.app_name 73 | except Exception as e: 74 | logger.error("create app fail: {}".format(e)) 75 | allure.attach(body='', 76 | name="create app fail: {}".format(e), 77 | attachment_type=allure.attachment_type.TEXT) 78 | pytest.exit("create app fail: {}".format(e)) 79 | 80 | assert (app is not None) 81 | 82 | app.ensure_current_device() 83 | 84 | logger.info("current test platform: {}".format(device_platform())) 85 | logger.info("start app {0} in {1}:{2}".format(app.app_name, client_platform, G.DEVICE.uuid)) 86 | 87 | wake_device(G.DEVICE) 88 | 89 | def teardown_test(): 90 | """ 91 | teardown code 92 | """ 93 | with allure.step("teardown session"): 94 | pass 95 | 96 | logger.info("Session stop test.") 97 | 98 | request.addfinalizer(teardown_test) # 注册teardown, 这样即使setup出现异常,也可以最终调用到 99 | 100 | return app 101 | 102 | 103 | class SnapShotWriter(object): 104 | def __init__(self): 105 | # 创建一个新Excel文件并添加一个工作表。 106 | excel_path = ST.PROJECT_ROOT + "/data/report.xlsx" 107 | self.workbook = xlsxwriter.Workbook(excel_path) 108 | self.current_worksheet = self.workbook.add_worksheet("default") 109 | self.sheets = {} 110 | 111 | self.android_img_cell_format = {'x_scale': 0.27, 'y_scale': 0.27, 'x_offset': 10, 'y_offset': 10} 112 | self.ios_img_cell_format = {'x_scale': 0.4, 'y_scale': 0.4, 'x_offset': 10, 'y_offset': 10} 113 | self.img_cell_format = {'x_scale': 0.4, 'y_scale': 0.4, 'x_offset': 10, 'y_offset': 10} 114 | 115 | if os.environ['client_platform'] == ANDROID_PLATFORM: 116 | self.img_cell_format = self.android_img_cell_format 117 | elif os.environ['client_platform'] == IOS_PLATFORM: 118 | self.img_cell_format = self.ios_img_cell_format 119 | 120 | self.merge_format = self.workbook.add_format({'bold': True, 'bg_color': '#C0C0C0', 'align': 'center'}) 121 | 122 | self.first_column = 0 123 | self.first_row = 0 124 | self.merge_column_number = 4 125 | self.internal_column_number = 1 126 | self.internal_row_number = 1 127 | self.write_positions = {} 128 | 129 | def next(self): 130 | write_position = self.write_positions.get(self.current_worksheet.name, [self.first_row, self.first_column]) 131 | 132 | self.write_positions[self.current_worksheet.name][0] = write_position[0] 133 | self.write_positions[self.current_worksheet.name][1] = write_position[1] \ 134 | + self.merge_column_number + 1 + self.internal_column_number 135 | 136 | def reset(self): 137 | self.write_positions.setdefault(self.current_worksheet.name, [self.first_row, self.first_column]) 138 | self.write_positions[self.current_worksheet.name][0] = self.first_row 139 | self.write_positions[self.current_worksheet.name][1] = self.first_column 140 | 141 | def switch_sheet(self, name=None): 142 | """ 143 | 切换sheet 144 | :param name: 145 | """ 146 | if name: 147 | self.current_worksheet = self.sheets[name] 148 | 149 | def add_worksheet(self, name=None): 150 | """ 151 | 添加工作表 152 | :param name: 153 | """ 154 | if name and str(name) not in self.sheets.keys(): 155 | worksheet = self.workbook.add_worksheet(str(name)) 156 | self.sheets[str(name)] = worksheet 157 | self.current_worksheet = worksheet 158 | self.reset() 159 | return 160 | if str(name) in self.sheets.keys(): 161 | self.current_worksheet = self.sheets[str(name)] 162 | return 163 | 164 | def insert_snapshot(self, name=None, path=None): 165 | """ 166 | 插入截屏,只需要知道名字和路径,位置由类处理 167 | :param name: 168 | :param path: 169 | """ 170 | if name is None or path is None: 171 | logger.info("required parameter missed {} {}".format(name, path)) 172 | return 173 | 174 | logger.info("insert cell {}, file path: {}".format(self.write_positions[self.current_worksheet.name], path)) 175 | self.current_worksheet.merge_range(self.write_positions[self.current_worksheet.name][0], 176 | self.write_positions[self.current_worksheet.name][1], 177 | self.write_positions[self.current_worksheet.name][0], 178 | self.write_positions[self.current_worksheet.name][ 179 | 1] + self.merge_column_number, name, 180 | self.merge_format) 181 | self.current_worksheet.insert_image( 182 | self.write_positions[self.current_worksheet.name][0] + self.internal_row_number, 183 | self.write_positions[self.current_worksheet.name][1], 184 | path, 185 | self.img_cell_format) 186 | 187 | def snapshot_to_excel(self, name=None): 188 | """ 189 | 截屏并写入表格 190 | :param name: 191 | """ 192 | file_name = '{}.png'.format(name) 193 | file_path = os.path.join(allure_results_dir, file_name) 194 | device().snapshot(file_path) 195 | 196 | logger.info("snapshot path: {}, name: {}".format(file_path, name)) 197 | self.insert_snapshot(name, file_path) 198 | self.next() 199 | 200 | def close(self): 201 | self.workbook.close() 202 | 203 | 204 | @pytest.fixture(scope="session") 205 | def snapshot_writer(): 206 | writer = SnapShotWriter() 207 | 208 | yield writer 209 | 210 | # 关闭文件 211 | writer.close() 212 | 213 | 214 | allure_results_dir = os.path.join(ST.PROJECT_ROOT, 'allure-results') 215 | 216 | 217 | def catch_error(func): 218 | """ 219 | 装饰器,获取cases异常,进行截图并attach, 将log传入allure 220 | """ 221 | 222 | @wraps(func) 223 | def wrapper(*args, **kwargs): 224 | result = None 225 | try: 226 | result = func(*args, **kwargs) 227 | except Exception as e: 228 | file_name = '{}.png'.format(generate_random_num_str()) 229 | file_path = os.path.join(allure_results_dir, file_name) 230 | device().snapshot(file_path) 231 | sleep(0.5) # 避免截图操作慢时找不到图片 232 | if os.getenv('client_platform') == IOS_PLATFORM: 233 | allure.attach.file(file_path, 234 | name="current orientation {} and screen shot".format(device().orientation), 235 | attachment_type=allure.attachment_type.PNG) 236 | elif os.getenv('client_platform') == ANDROID_PLATFORM: 237 | allure.attach.file(file_path, 238 | name="current orientation {} and screen shot".format( 239 | device().display_info['orientation']), 240 | attachment_type=allure.attachment_type.PNG) 241 | logger.exception("Catch Exception\n") 242 | raise e 243 | return result 244 | 245 | wrapper.__name__ = 'catch_error' 246 | return wrapper 247 | -------------------------------------------------------------------------------- /cases/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | log_cli=true 3 | log_cli_level=INFO 4 | log_format = %(asctime)s [%(levelname)-5s] %(name)s %(funcName)s : %(message)s 5 | log_date_format = %Y-%m-%d %H:%M:%S 6 | 7 | # show all extra test summary info 8 | addopts = -ra 9 | -------------------------------------------------------------------------------- /cases/sample_test/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/cases/sample_test/.DS_Store -------------------------------------------------------------------------------- /cases/sample_test/test_demo.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import allure 3 | import pytest 4 | from airtest.core.api import * 5 | 6 | from cases.conftest import catch_error 7 | 8 | logger = logging.getLogger(__name__) 9 | epic = os.getenv('client_platform') 10 | feature = 'app' 11 | story = 'all' 12 | 13 | 14 | @allure.epic(epic) 15 | @allure.feature(feature) 16 | @allure.story(story) 17 | class TestDemo(object): 18 | @pytest.fixture(scope="function") 19 | def app(self, request, app_fixture, snapshot_writer): 20 | app_ins = app_fixture 21 | 22 | @allure.step 23 | def setup_func(): 24 | """ 25 | 用例前置 26 | """ 27 | # 创建sheet,不存在会创建,已存在就切换为当前sheet,默认sheet 为 default, 28 | snapshot_writer.add_worksheet(feature) 29 | 30 | app_ins.start_phone_app() 31 | sleep(2) 32 | 33 | @allure.step 34 | def teardown_func(): 35 | """ 36 | 用例后置 37 | """ 38 | app_ins.stop_phone_app() 39 | sleep(2) 40 | 41 | request.addfinalizer(teardown_func) # 注册teardown, 这样即使setup出现异常,也可以最终调用到 42 | setup_func() 43 | return app_ins 44 | 45 | @allure.severity(allure.severity_level.BLOCKER) 46 | @catch_error 47 | def test_home_to_second(self, app, snapshot_writer): 48 | """ 49 | 测试home to second 50 | """ 51 | app.home_page.click_next() 52 | sleep(2) 53 | assert app.second_page.is_second_page(), "确认进入second页失败" 54 | 55 | snapshot_writer.snapshot_to_excel("second页") 56 | 57 | app.second_page.click_back() 58 | sleep(2) 59 | assert app.home_page.is_home_page(), "确认退出到home页失败" 60 | 61 | snapshot_writer.snapshot_to_excel("home页") 62 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import yaml 3 | import subprocess 4 | import time 5 | import requests 6 | from pathlib import Path 7 | from logging import getLogger 8 | from src.utils.analysis_ipa import analyze_ipa_with_plistlib 9 | from src.utils.rw_xml import dict_to_xml_tree, out_xml 10 | 11 | PROJECT_ROOT = os.environ["PROJECT_SPACE_ROOT"] # 该环境变量运行时在run.sh中设置过 12 | 13 | ALLURE_RESULT_XML_PATH = os.path.join(PROJECT_ROOT, "allure-results/environment.xml") 14 | CLIENT_CONFIGURATION_PATH = os.path.join(PROJECT_ROOT, "configuration.yaml") 15 | 16 | ANDROID_PLATFORM = "Android" 17 | IOS_PLATFORM = "iOS" 18 | BRANCH_DEBUG = "Debug" 19 | BRANCH_RELEASES = "Releases" 20 | 21 | ANDROID_DEBUG_PACKAGE_NAME = "com.app.demo" 22 | ANDROID_RELEASES_PACKAGE_NAME = "" 23 | IOS_DEBUG_PACKAGE_NAME = "" 24 | IOS_RELEASES_PACKAGE_NAME = "" 25 | 26 | logger = getLogger(__name__) 27 | 28 | logger.info("set PROJECT_ROOT: {}".format(PROJECT_ROOT)) 29 | logger.info("set allure results path: {}".format(ALLURE_RESULT_XML_PATH)) 30 | logger.info("set client configuration file path: {}".format(CLIENT_CONFIGURATION_PATH)) 31 | 32 | 33 | def cp_file(src, dst): 34 | """拷贝路径文件到指定路径""" 35 | recode = subprocess.call("cp {} {}".format(src, dst), shell=True) 36 | if recode != 0: 37 | logger.info("cp file failed, src: {}, dst: {}".format(src, dst)) 38 | 39 | 40 | def download_file(src, dst): 41 | """从源地址下载文件写入到目标路径""" 42 | r = requests.get(src) 43 | if r.status_code != requests.codes.ok: 44 | logger.info("down load file failed, url: {}".format(src)) 45 | logger.debug(r.content) 46 | return False 47 | else: 48 | with open(dst, "wb") as f: 49 | f.write(r.content) 50 | return True 51 | 52 | 53 | def parse_android_appversion(device, app_name): 54 | """get android app version through adb dumpsys package""" 55 | version_string = subprocess.check_output("adb -s {} shell dumpsys package {} | grep versionName".format(device, app_name), shell=True) 56 | version = version_string.decode("utf-8").replace("\r\n", "").split("=")[1] 57 | logger.info("Get android versions: {} {} ".format(version, device)) 58 | return version 59 | 60 | 61 | def parse_ios_appversion(file_path): 62 | """parse ios app version from local file.""" 63 | version = analyze_ipa_with_plistlib(file_path) 64 | logger.info("Get ios versions: {} {} ".format(version, file_path)) 65 | return version 66 | 67 | 68 | def get_parametr(client_info): 69 | """从环境中获取设备信息写入配置文件""" 70 | device_info = { 71 | 'client_platform': os.environ["Client_Platform"].strip(), 72 | 'client_ip': os.environ["Client_IP"].strip(), 73 | 'client_port': os.environ["Client_Port"].strip(), 74 | 'client_reinstall': os.environ["Client_ReInstall"].strip(), 75 | 'app_name': os.environ["APP_Name"].strip(), 76 | 'app_branch': os.environ["APP_Branch"].strip(), 77 | 'phones': os.environ["Phones"].strip().split(','), 78 | 'android_debug_app_localPath': os.environ["Android_Debug_APP_LocalPath"].strip(), 79 | 'ios_debug_app_localPath': os.environ["iOS_Debug_APP_LocalPath"].strip(), 80 | 'android_release_app_localPath': os.environ["Android_Release_APP_LocalPath"].strip(), 81 | 'ios_release_app_localPath': os.environ["iOS_Release_APP_LocalPath"].strip(), 82 | } 83 | try: 84 | pass 85 | except KeyError: 86 | raise KeyError("please check parameters") 87 | 88 | with open(CLIENT_CONFIGURATION_PATH, 'w', encoding='utf-8') as f: 89 | yaml.dump(device_info, f) 90 | 91 | with open(CLIENT_CONFIGURATION_PATH, 'r', encoding='utf-8') as f: 92 | data = yaml.load(f, Loader=yaml.FullLoader) 93 | logger.info("input parameters: {}".format(data)) 94 | 95 | client_info.update(device_info) 96 | 97 | 98 | def write_to_configuration(client_info): 99 | with open(CLIENT_CONFIGURATION_PATH, 'w', encoding='utf-8') as f: 100 | yaml.dump(client_info, f) 101 | 102 | 103 | def get_app_info(client_info): 104 | """获取设备信息,解析对应APP版本信息""" 105 | version = None 106 | app_name = None 107 | device = client_info["phones"][0] # 目前只测试一个设备 108 | if client_info["client_platform"] == ANDROID_PLATFORM: 109 | 110 | if client_info['app_branch'] == BRANCH_DEBUG: 111 | version = parse_android_appversion(device, ANDROID_DEBUG_PACKAGE_NAME) 112 | app_name = ANDROID_DEBUG_PACKAGE_NAME 113 | elif client_info['app_branch'] == BRANCH_RELEASES: 114 | app_name = ANDROID_RELEASES_PACKAGE_NAME 115 | version = parse_android_appversion(device, ANDROID_RELEASES_PACKAGE_NAME) 116 | 117 | elif client_info["client_platform"] == IOS_PLATFORM: 118 | if client_info['app_branch'] == BRANCH_DEBUG: 119 | app_name = IOS_DEBUG_PACKAGE_NAME 120 | version = parse_ios_appversion(client_info["ios_debug_app_localPath"]) 121 | elif client_info['app_branch'] == BRANCH_RELEASES: 122 | app_name = IOS_RELEASES_PACKAGE_NAME 123 | version = parse_ios_appversion(client_info["ios_release_app_localPath"]) 124 | 125 | assert version, "获取APP版本version失败" 126 | assert app_name, "获取APP包名app_name失败" 127 | client_info.update(app_version=version, app_name=app_name) 128 | 129 | write_to_configuration(client_info) 130 | 131 | 132 | def get_android_app(file_path): 133 | """从指定文件路径获取iOS APP""" 134 | apk_location = os.path.join(PROJECT_ROOT, "data/android_latest.apk") 135 | if os.path.isfile(apk_location): # file可能不存在 136 | logger.info("apk file exists") 137 | os.remove(apk_location) 138 | if not os.path.exists(os.path.dirname(apk_location)): 139 | logger.info("文件目录路径不存在:{}".format(os.path.dirname(apk_location))) 140 | os.mkdir(os.path.dirname(apk_location)) 141 | cp_file(file_path, apk_location) 142 | return apk_location 143 | 144 | 145 | def get_ios_app(file_path): 146 | """从指定文件路径获取iOS APP""" 147 | ipa_location = os.path.join(PROJECT_ROOT, "data/ios_latest.ipa") 148 | if os.path.isfile(ipa_location): # file可能不存在 149 | logger.info("ipa file exists") 150 | os.remove(ipa_location) 151 | if not os.path.exists(os.path.dirname(ipa_location)): 152 | os.mkdir(os.path.dirname(ipa_location)) 153 | cp_file(file_path, ipa_location) 154 | return ipa_location 155 | 156 | 157 | def install_android_app(device_ids, path): 158 | """向指定Android设备安装指定位置APP""" 159 | # 路径校验 160 | logger.info("install file path: {}".format(path)) 161 | p = Path(path) 162 | if not p.is_file(): 163 | raise ValueError("please check file exists: {}.".format(path)) 164 | if p.suffix != ".apk": 165 | raise ValueError("please check file whether a apk.") 166 | 167 | # 安装apk 168 | time.sleep(3) 169 | for device_id in device_ids: 170 | subprocess.call("adb -s {} install {}".format(device_id, path), shell=True) 171 | 172 | 173 | def uninstall_android_app(device_ids, app_name): 174 | """卸载Android APP""" 175 | for device_id in device_ids: 176 | subprocess.call("adb -s {} uninstall {}".format(device_id, app_name), shell=True) 177 | 178 | 179 | def install_ios_app(device_ids, path): 180 | """向指定设备安装指定位置APP""" 181 | # 路径校验 182 | p = Path(path) 183 | if not p.is_file(): 184 | raise ValueError("please check file exists: {}".format(path)) 185 | if p.suffix != ".ipa": 186 | raise ValueError("please check file whether a ipa.") 187 | 188 | # 安装ipa 189 | for device_id in device_ids: 190 | p = subprocess.Popen("cfgutil -e {} install-app {}".format(device_id, path), shell=True, stdout=subprocess.PIPE, 191 | stderr=subprocess.STDOUT) 192 | while p.poll() is None: 193 | line = p.stdout.readline().decode("utf-8") 194 | line = line.strip() 195 | if line: 196 | logger.debug('cfgutil install output: [{}]'.format(line)) 197 | if p.returncode == 0: 198 | logger.info('cfgutil install success') 199 | time.sleep(60) 200 | logger.debug("cfgutil install should be done") 201 | else: 202 | logger.info('cfgutil install failed') 203 | 204 | 205 | def uninstall_ios_app(device_ids, app_name): 206 | """卸载APP""" 207 | for device_id in device_ids: 208 | p = subprocess.Popen("cfgutil -e {} remove-app {}".format(device_id, app_name), shell=True, 209 | stdout=subprocess.PIPE, stderr=subprocess.STDOUT) 210 | while p.poll() is None: 211 | line = p.stdout.readline().decode("utf-8") 212 | line = line.strip() 213 | if line: 214 | logger.debug('cfgutil uninstall output: [{}]'.format(line)) 215 | if p.returncode == 0: 216 | logger.info('cfgutil uninstall success: {}'.format(device_id)) 217 | else: 218 | logger.info('cfgutil uninstall failed: {}'.format(device_id)) 219 | 220 | 221 | def install_client_app(client_info): 222 | """通过设备信息安装APP""" 223 | if client_info["client_reinstall"] == "false": 224 | return 225 | 226 | device_ids = client_info["phones"] 227 | file_uri = None 228 | if client_info["client_platform"] == ANDROID_PLATFORM: 229 | if client_info['app_branch'] == BRANCH_DEBUG: 230 | file_uri = client_info['android_debug_app_localPath'] 231 | elif client_info['app_branch'] == BRANCH_RELEASES: 232 | file_uri = client_info['android_release_app_localPath'] 233 | 234 | assert file_uri, "未获取到安装文件路径" 235 | local_path = get_android_app(file_uri) 236 | install_android_app(device_ids, local_path) 237 | 238 | elif client_info["client_platform"] == IOS_PLATFORM: 239 | if client_info['app_branch'] == BRANCH_DEBUG: 240 | file_uri = client_info['ios_debug_app_localPath'] 241 | elif client_info['app_branch'] == BRANCH_RELEASES: 242 | file_uri = client_info['ios_release_app_localPath'] 243 | 244 | assert file_uri, "未获取到安装文件路径" 245 | local_path = get_ios_app(file_uri) 246 | install_ios_app(device_ids, local_path) 247 | 248 | 249 | def uninstall_client_app(client_info): 250 | """通过设备信息卸载APP""" 251 | device_id = client_info['phones'] 252 | app_name = None 253 | if client_info["client_platform"] == ANDROID_PLATFORM: 254 | if client_info['app_branch'] == BRANCH_DEBUG: 255 | app_name = ANDROID_DEBUG_PACKAGE_NAME 256 | elif client_info['app_branch'] == BRANCH_RELEASES: 257 | app_name = ANDROID_RELEASES_PACKAGE_NAME 258 | 259 | assert app_name, "未获取到应用包名" 260 | uninstall_android_app(device_id, app_name) 261 | 262 | elif client_info["client_platform"] == IOS_PLATFORM: 263 | if client_info['app_branch'] == BRANCH_DEBUG: 264 | app_name = IOS_DEBUG_PACKAGE_NAME 265 | elif client_info['app_branch'] == BRANCH_RELEASES: 266 | app_name = IOS_RELEASES_PACKAGE_NAME 267 | 268 | assert app_name, "未获取到应用包名" 269 | uninstall_ios_app(device_id, app_name) 270 | 271 | 272 | def write_appinfo_xml(client_info): 273 | """读取版本信息,写入allure-results中的environment.xml""" 274 | platform = client_info['client_platform'] 275 | phones = client_info['phones'] 276 | version = client_info['app_version'] 277 | branch = client_info['app_branch'] 278 | 279 | app_name = None 280 | app_local_path = None 281 | if client_info["client_platform"] == ANDROID_PLATFORM: 282 | if client_info['app_branch'] == BRANCH_DEBUG: 283 | app_name = ANDROID_DEBUG_PACKAGE_NAME 284 | app_local_path = client_info['android_debug_app_localPath'] 285 | elif client_info['app_branch'] == BRANCH_RELEASES: 286 | app_name = ANDROID_RELEASES_PACKAGE_NAME 287 | app_local_path = client_info['android_release_app_localPath'] 288 | 289 | elif client_info["client_platform"] == IOS_PLATFORM: 290 | if client_info['app_branch'] == BRANCH_DEBUG: 291 | app_name = IOS_DEBUG_PACKAGE_NAME 292 | app_local_path = client_info['ios_debug_app_localPath'] 293 | elif client_info['app_branch'] == BRANCH_RELEASES: 294 | app_name = IOS_RELEASES_PACKAGE_NAME 295 | app_local_path = client_info['ios_release_app_localPath'] 296 | 297 | d = { 298 | 0: {'key': 'test platform', 'value': platform}, 299 | 1: {'key': 'test app name', 'value': app_name}, 300 | 2: {'key': 'test app version', 'value': version}, 301 | 3: {'key': 'test app branch', 'value': branch}, 302 | 4: {'key': 'test app local path', 'value': app_local_path}, 303 | 5: {'key': 'test phones', 'value': "|".join(phones)}, 304 | } 305 | 306 | tree = dict_to_xml_tree(d, "environment", "parameter") 307 | out_xml(tree, ALLURE_RESULT_XML_PATH) 308 | 309 | 310 | def main(): 311 | """ 312 | 1. 获取安装APP 313 | 2. 信息写入xml 314 | """ 315 | 316 | client_info = {} 317 | get_parametr(client_info) 318 | 319 | if client_info["client_reinstall"] == "true": 320 | uninstall_client_app(client_info) 321 | install_client_app(client_info) 322 | 323 | get_app_info(client_info) 324 | write_appinfo_xml(client_info) 325 | 326 | 327 | if __name__ == '__main__': 328 | main() 329 | -------------------------------------------------------------------------------- /configuration.yaml: -------------------------------------------------------------------------------- 1 | android_debug_app_localPath: /Users/ninebot/PycharmProjects/Pytest_Allure_Airtest_Demo/data/app-debug.apk 2 | android_release_app_localPath: demo 3 | app_branch: Debug 4 | app_name: com.app.demo 5 | app_version: '1.0' 6 | client_ip: 127.0.0.1 7 | client_platform: Android 8 | client_port: '5037' 9 | client_reinstall: 'true' 10 | ios_debug_app_localPath: demo 11 | ios_release_app_localPath: demo 12 | phones: 13 | - T7G5T15519000886 14 | -------------------------------------------------------------------------------- /data/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/data/.DS_Store -------------------------------------------------------------------------------- /data/app-debug.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/data/app-debug.apk -------------------------------------------------------------------------------- /img/Xnip2019-05-08_14-33-11.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/img/Xnip2019-05-08_14-33-11.jpg -------------------------------------------------------------------------------- /img/Xnip2019-05-09_17-01-37.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/img/Xnip2019-05-09_17-01-37.jpg -------------------------------------------------------------------------------- /img/Xnip2019-05-09_17-07-08.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/img/Xnip2019-05-09_17-07-08.jpg -------------------------------------------------------------------------------- /img/Xnip2019-05-09_17-08-34.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/img/Xnip2019-05-09_17-08-34.jpg -------------------------------------------------------------------------------- /img/Xnip2019-05-09_17-18-27.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/img/Xnip2019-05-09_17-18-27.jpg -------------------------------------------------------------------------------- /img/Xnip2019-05-09_17-22-23.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/img/Xnip2019-05-09_17-22-23.jpg -------------------------------------------------------------------------------- /img/Xnip2019-05-10_11-00-55.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/img/Xnip2019-05-10_11-00-55.jpg -------------------------------------------------------------------------------- /img/Xnip2019-05-10_11-03-28.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/img/Xnip2019-05-10_11-03-28.jpg -------------------------------------------------------------------------------- /img/Xnip2019-05-10_11-06-46.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/img/Xnip2019-05-10_11-06-46.jpg -------------------------------------------------------------------------------- /img/Xnip2019-05-10_11-14-51.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/img/Xnip2019-05-10_11-14-51.jpg -------------------------------------------------------------------------------- /img/Xnip2019-05-24_18-14-10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/img/Xnip2019-05-24_18-14-10.jpg -------------------------------------------------------------------------------- /img/Xnip2019-05-30_17-48-41.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/img/Xnip2019-05-30_17-48-41.jpg -------------------------------------------------------------------------------- /img/Xnip2019-07-10_19-40-53.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/img/Xnip2019-07-10_19-40-53.jpg -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | echo "运行虚拟环境检查和安装" 2 | pipenv --venv 3 | if [[ "$?" -ne 0 ]]; then 4 | echo "no virtualenv found, create venv and install package" 5 | pipenv install --skip-lock --python 3.6 6 | else 7 | echo "virtualenv found, try install package" 8 | pipenv install --skip-lock 9 | fi 10 | 11 | echo "授予adb执行权限" 12 | export VENV_HOME_DIR=$(pipenv --venv) 13 | source $VENV_HOME_DIR/bin/activate 14 | chmod +x $VENV_HOME_DIR/lib/python3.6/site-packages/airtest/core/android/static/adb/mac/adb 15 | 16 | echo "设置工程根路径" 17 | export PROJECT_SPACE_ROOT=$(pwd) 18 | 19 | echo "清理报告碎片文件" 20 | python3 ./allure-results/clear_results.py 21 | 22 | echo "删除上次报告" 23 | rm -rf ./allure-report/* 24 | 25 | echo "运行配置" 26 | python3 config.py 27 | 28 | echo "运行测试" 29 | python3 -m pytest --reruns 1 ./cases --alluredir=./allure-results/ 30 | 31 | echo "执行完毕" 32 | exit 0 33 | -------------------------------------------------------------------------------- /src/common.air/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/src/common.air/.DS_Store -------------------------------------------------------------------------------- /src/common.air/common.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from importlib import import_module 3 | 4 | import allure 5 | from airtest.core.api import * 6 | from poco.drivers.android.uiautomation import AndroidUiautomationPoco 7 | from poco.drivers.ios import iosPoco 8 | from tenacity import Retrying, wait_fixed, stop_after_attempt 9 | from wda import WDAError 10 | 11 | ANDROID_PLATFORM = "Android" 12 | IOS_PLATFORM = "iOS" 13 | ANDROID_PAGE_PREFIX = "Android" 14 | ANDROID_PAGE_SUFFIX = "Page" 15 | IOS_PAGE_PREFIX = "IOS" 16 | IOS_PAGE_SUFFIX = "Page" 17 | 18 | 19 | class App(object): 20 | def __init__(self, phone_tuple): 21 | self.logger = logging.getLogger(__name__) 22 | self.client_platform = phone_tuple.platform 23 | self.app_name = phone_tuple.package 24 | 25 | self.phone_ip = phone_tuple.ip 26 | self.phone_port = phone_tuple.port 27 | self.phone_uuid = phone_tuple.uuid 28 | self.device_phone = None 29 | self.poco_phone_driver = None 30 | self.phone_dev = None 31 | 32 | pages_path = os.path.join(ST.PROJECT_ROOT, 'src/pages') 33 | packages = os.listdir(pages_path) 34 | for p in packages: 35 | absp = os.path.join(pages_path, p) 36 | using(absp) 37 | 38 | def __getattribute__(self, attr): 39 | """ 40 | 检测到获取page时,动态导入并初始化返回 41 | :param attr: 42 | :return: 43 | """ 44 | sub_page = None 45 | if attr.endswith('page'): # 检测是否获取的为page 46 | page = import_module(attr) 47 | assert page is not None, f"未从pages目录下获取到指定页面:{attr}" 48 | 49 | if attr == 'base_page': 50 | return getattr(page, 'BasePage') 51 | 52 | if self.client_platform == ANDROID_PLATFORM: # 判断平台 53 | for item in page.__dict__: 54 | if item.startswith(ANDROID_PAGE_PREFIX) and item.endswith(ANDROID_PAGE_SUFFIX): 55 | sub_page = getattr(page, item) 56 | elif self.client_platform == IOS_PLATFORM: 57 | for item in page.__dict__: 58 | if item.startswith(IOS_PAGE_PREFIX) and item.endswith(IOS_PAGE_SUFFIX): 59 | sub_page = getattr(page, item) 60 | 61 | assert sub_page is not None, f'未从pages目录下获取到 {self.client_platform} 平台指定页面:{attr}' 62 | return sub_page(self.poco_phone_driver) 63 | else: 64 | # 非特殊属性直接返回 65 | return object.__getattribute__(self, attr) 66 | 67 | def ensure_current_device(self): 68 | if device().uuid != self.phone_dev: 69 | set_current(self.phone_dev) 70 | 71 | @allure.step('点击home键') 72 | def home(self): 73 | """点击手机home键""" 74 | home() 75 | 76 | @allure.step('启动phoneAPP') 77 | def start_phone_app(self): 78 | """启动phoneAPP""" 79 | self.ensure_current_device() 80 | start_app(self.app_name) 81 | self.logger.info('start phone app.') 82 | 83 | @allure.step('关闭phoneAPP') 84 | def stop_phone_app(self): 85 | """关闭phoneAPP""" 86 | self.ensure_current_device() 87 | stop_app(self.app_name) 88 | self.logger.info('stop phone app.') 89 | 90 | 91 | class AndroidApp(App): 92 | def __init__(self, phone_tuple): 93 | super().__init__(phone_tuple) 94 | self.init_driver() 95 | self.set_system() 96 | 97 | @allure.step('初始化驱动') 98 | def init_driver(self): 99 | # 创建设备驱动 100 | self.device_phone = my_retry_connect("android://{0}:{1}/{2}".format( 101 | self.phone_ip, self.phone_port, self.phone_uuid)) 102 | self.phone_dev = self.phone_uuid 103 | self.poco_phone_driver = AndroidUiautomationPoco(self.device_phone, poll_interval=1) 104 | # 关闭截图 105 | self.poco_phone_driver.screenshot_each_action = False 106 | 107 | @allure.step('设置系统') 108 | def set_system(self): 109 | # 授予权限 110 | # self.device_phone.shell("pm grant {} {}".format(self.app_name, "android.permission.ACCESS_FINE_LOCATION")) # 授予位置权限 111 | # self.device_phone.shell("pm grant {} {}".format(self.app_name, "android.permission.WRITE_EXTERNAL_STORAGE")) # 授予存储权限 112 | # 隐藏状态栏 113 | # self.device_phone.shell("settings put global policy_control immersive.status=*") # 隐藏状态栏 114 | # self.device_phone.shell("settings put global policy_control immersive.navigation=*") # 隐藏导航栏 115 | # self.device_phone.shell("settings put global policy_control immersive.full=*") # 隐藏状态栏和导航栏 116 | pass 117 | 118 | 119 | class IOSApp(App): 120 | 121 | def __init__(self, phone_tuple): 122 | super().__init__(phone_tuple) 123 | self.init_driver() 124 | self.set_system() 125 | 126 | @allure.step('初始化驱动') 127 | def init_driver(self): 128 | # 创建设备驱动 129 | self.device_phone = my_retry_connect("ios:///http://{0}:{1}".format(self.phone_ip, self.phone_port)) 130 | self.phone_dev = "http://{0}:{1}".format(self.phone_ip, self.phone_port) 131 | self.poco_phone_driver = iosPoco(self.device_phone, poll_interval=1) 132 | # 关闭截图 133 | self.poco_phone_driver.screenshot_each_action = False 134 | 135 | @allure.step('设置系统') 136 | def set_system(self): 137 | pass 138 | 139 | 140 | def my_before_sleep(retry_state): 141 | if retry_state.attempt_number < 1: 142 | loglevel = logging.INFO 143 | else: 144 | loglevel = logging.WARNING 145 | 146 | logging.log( 147 | loglevel, 148 | 'Retrying %s: attempt %s ended with: %s', 149 | retry_state.fn, 150 | retry_state.attempt_number, 151 | retry_state.outcome, 152 | ) 153 | 154 | 155 | def my_retry_connect(uri=None, whether_retry=True, sleeps=10, max_attempts=3): 156 | """ 157 | 可重试 connect 158 | :param sleeps: 重试间隔时间 159 | :param uri: device uri 160 | :param whether_retry: not retry will set the max attempts to 1 161 | :param max_attempts: max retry times 162 | """ 163 | logger = logging.getLogger(__name__) 164 | 165 | if not whether_retry: 166 | max_attempts = 1 167 | 168 | r = Retrying(wait=wait_fixed(sleeps), stop=stop_after_attempt(max_attempts), before_sleep=my_before_sleep, reraise=True) 169 | try: 170 | return r(connect_device, uri) 171 | except Exception as e: 172 | if isinstance(e, (WDAError,)): 173 | logger.info("Can't connect iphone, please check device or wda state!") 174 | logger.info("try connect device {} 3 times per wait 10 sec failed.".format(uri)) 175 | raise e 176 | finally: 177 | logger.info("retry connect statistics: {}".format(str(r.statistics))) 178 | -------------------------------------------------------------------------------- /src/pages/base_page.air/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/src/pages/base_page.air/.DS_Store -------------------------------------------------------------------------------- /src/pages/base_page.air/base_page.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import types 3 | 4 | import allure 5 | from tenacity import Retrying, stop_after_attempt, wait_fixed, RetryError, retry_if_result, retry_if_exception_type 6 | 7 | from airtest.core.api import touch, ST 8 | from airtest.core.cv import loop_find 9 | from poco.exceptions import PocoTargetTimeout 10 | from airtest.core.error import TargetNotFoundError 11 | 12 | Logger = logging.getLogger(__name__) 13 | 14 | 15 | class BasePage(object): 16 | app_name = '' 17 | 18 | def __init__(self, poco_driver): 19 | self.__driver_poco = poco_driver 20 | self.logger = logging.getLogger(__name__) 21 | 22 | def poco(self, poco_pos): 23 | """ 24 | 通过参数返回实例 25 | :param poco_pos: poco 控件参数 26 | :return: poco 控件实例 27 | """ 28 | if 'p0' in poco_pos and 'p1' in poco_pos and 'p2' in poco_pos: 29 | return self.__driver_poco(poco_pos['p0']).child(poco_pos['p1']).child(poco_pos['p2']).child(poco_pos['name']) 30 | elif 'p0' in poco_pos and 'p1' in poco_pos: 31 | return self.__driver_poco(poco_pos['p0']).child(poco_pos['p1']).child(poco_pos['name']) 32 | elif 'p0' in poco_pos: 33 | return self.__driver_poco(poco_pos['p0']).child(poco_pos['name']) 34 | else: 35 | return self.__driver_poco(**poco_pos) 36 | 37 | def __getattribute__(self, attr): 38 | """ 39 | 自定义实例属性获取方法,可以在此处动态绑定方法以简化操作控件。 40 | """ 41 | _dict = object.__getattribute__(self, '__dict__') 42 | if attr.startswith('p_'): # poco 属性前缀 p 用以区别 air 属性 43 | _proxy = self.poco(_dict[attr]) 44 | if len(_proxy) > 1: # 含有多个相同元素,每个都需要绑定方法 45 | for i, p in enumerate(_proxy): 46 | _proxy[i].wait_click = types.MethodType(wait_click, _proxy[i]) 47 | _proxy[i].wait_exists = types.MethodType(wait_exists, _proxy[i]) 48 | else: 49 | _proxy.wait_click = types.MethodType(wait_click, _proxy) 50 | _proxy.wait_exists = types.MethodType(wait_exists, _proxy) 51 | return _proxy 52 | 53 | elif attr.startswith('a_'): # template img 属性前缀 a 用以区别 poco 属性 54 | _template = _dict[attr] 55 | _template.retry_air_touch = types.MethodType(retry_air_touch, _template) 56 | _template.retry_air_exists = types.MethodType(retry_air_exists, _template) 57 | return _template 58 | 59 | # 非特殊属性直接返回 60 | return object.__getattribute__(self, attr) 61 | 62 | def get_poco(self, pos): 63 | """ 64 | 直接获取控件实例,动态控件用此方法。 65 | :param pos: poco 控件参数 66 | :return: poco 控件实例 67 | """ 68 | poco_ins = self.__driver_poco(**pos) 69 | if len(poco_ins) > 1: # 含有多个相同元素,每个都需要绑定方法 70 | for i, p in enumerate(poco_ins): 71 | poco_ins[i].wait_click = types.MethodType(wait_click, poco_ins[i]) 72 | poco_ins[i].wait_exists = types.MethodType(wait_exists, poco_ins[i]) 73 | else: 74 | poco_ins.wait_click = types.MethodType(wait_click, poco_ins) 75 | poco_ins.wait_exists = types.MethodType(wait_exists, poco_ins) 76 | return poco_ins 77 | 78 | def get_poco_driver(self): 79 | return self.__driver_poco 80 | 81 | 82 | @allure.step("等待点击控件, 参数:") 83 | def wait_click(self, timeout=5, **kwargs): 84 | """ 85 | 等待点击控件 86 | :param self: 87 | :param timeout: 等待时长 88 | :param kwargs: 89 | """ 90 | try: 91 | self.wait_for_appearance(timeout=timeout) 92 | self.click(**kwargs) 93 | Logger.info("poco click {}".format(str(self))) 94 | allure.attach(body='', 95 | name="poco click {}".format(self), 96 | attachment_type=allure.attachment_type.TEXT) 97 | except PocoTargetTimeout as e: 98 | Logger.info("poco not find: {}".format(str(self))) 99 | allure.attach(body='', 100 | name="poco not find {}".format(self), 101 | attachment_type=allure.attachment_type.TEXT) 102 | raise e 103 | 104 | 105 | @allure.step("等待检测控件, 参数:") 106 | def wait_exists(self, timeout=5): 107 | """ 108 | 等待检测控件 109 | :param self: 110 | :param timeout: 等待时长 111 | :return: 112 | """ 113 | try: 114 | self.wait_for_appearance(timeout=timeout) 115 | if self.exists(): 116 | Logger.info("poco exists {}".format(str(self))) 117 | allure.attach(body='', 118 | name="poco exists {}".format(self), 119 | attachment_type=allure.attachment_type.TEXT) 120 | return True 121 | else: 122 | Logger.info("poco not find: {}".format(str(self))) 123 | allure.attach(body='', 124 | name="poco not find {}".format(self), 125 | attachment_type=allure.attachment_type.TEXT) 126 | return False 127 | except PocoTargetTimeout: 128 | logging.info("poco not find: {}".format(str(self))) 129 | allure.attach(body='', 130 | name="poco not find {}".format(self), 131 | attachment_type=allure.attachment_type.TEXT) 132 | return False 133 | 134 | 135 | @allure.step("重试间隔操作打印数据") 136 | def my_before_sleep(retry_state): 137 | log_level = logging.DEBUG 138 | logging.log( 139 | log_level, 140 | 'Retrying %s: attempt %s ended with: %s', 141 | retry_state.fn, 142 | retry_state.attempt_number, 143 | retry_state.outcome, 144 | ) 145 | 146 | 147 | @allure.step("重试点击图像, 参数:") 148 | def retry_air_touch(self, whether_retry=True, sleeps=2, max_attempts=2, **kwargs): 149 | """ 150 | 可重试 touch 151 | Args: 152 | self: img Template 153 | whether_retry: whether to retry 154 | sleeps: time between retry 155 | max_attempts: max retry times 156 | """ 157 | with allure.step("点击UI图像: {}".format(str(self))): 158 | 159 | if not whether_retry: 160 | max_attempts = 1 161 | 162 | r = Retrying(retry=retry_if_exception_type(TargetNotFoundError), 163 | wait=wait_fixed(sleeps), 164 | stop=stop_after_attempt(max_attempts), 165 | before_sleep=my_before_sleep, 166 | reraise=True) 167 | 168 | res = None 169 | try: 170 | res = r(touch, self, **kwargs) 171 | except Exception as e: 172 | res = False 173 | raise e 174 | finally: 175 | logging.info("aircv touch img result: {} on size: {}".format(False if res is False else res, G.DEVICE.get_current_resolution())) 176 | logging.debug("retry aircv touch statistics: {}".format(str(r.statistics))) 177 | allure.attach.file(self.filepath, 178 | name="aircv touch img result: {}, on size: {}".format(False if res is False else res, G.DEVICE.get_current_resolution()), 179 | attachment_type=allure.attachment_type.PNG) 180 | 181 | 182 | @allure.step("重试检测图像, 参数:") 183 | def retry_air_exists(self, whether_retry=True, sleeps=1.5, max_attempts=3, threshold=None): 184 | """ 185 | 可重试 exists 186 | Args: 187 | threshold: 188 | self: Img Template 189 | whether_retry: whether to retry 190 | sleeps: time between retry 191 | max_attempts: max retry times 192 | 193 | Returns: pos or False 194 | 195 | """ 196 | with allure.step("检测UI图像: {}".format(str(self))): 197 | if not whether_retry: 198 | max_attempts = 1 199 | 200 | def retry_exists(): 201 | try: 202 | logging.debug("img template threshold: {}".format(self.threshold)) 203 | pos = loop_find(self, timeout=ST.FIND_TIMEOUT_TMP, threshold=threshold) 204 | except TargetNotFoundError: 205 | return False 206 | else: 207 | return pos 208 | 209 | def need_retry(value): 210 | """ 211 | value为False时需要重试 212 | Args: 213 | value: function的返回值,自动填入 214 | 215 | Returns: 216 | 217 | """ 218 | logging.debug("need retry aircv exists?: {}".format(value is False)) 219 | return value is False 220 | 221 | r = Retrying(retry=retry_if_result(need_retry), 222 | stop=stop_after_attempt(max_attempts), 223 | wait=wait_fixed(sleeps), 224 | before_sleep=my_before_sleep) 225 | res = None 226 | try: 227 | res = r(r, retry_exists) 228 | except RetryError: 229 | res = False 230 | finally: 231 | logging.info("aircv find {}: {}".format(str(self), False if res is False else res)) 232 | logging.debug("retry aircv exists statistics: {}".format(str(r.statistics))) 233 | allure.attach.file(self.filepath, 234 | name="aircv find img result: {}, img:".format(False if res is False else res), 235 | attachment_type=allure.attachment_type.PNG) 236 | return res 237 | 238 | -------------------------------------------------------------------------------- /src/pages/home_page.air/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/src/pages/home_page.air/.DS_Store -------------------------------------------------------------------------------- /src/pages/home_page.air/home_page.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import allure 4 | from airtest.core.api import * 5 | 6 | using("src/pages/base_page.air") 7 | from base_page import BasePage 8 | 9 | 10 | class HomePage(BasePage): 11 | def __init__(self, poco_driver): 12 | super().__init__(poco_driver) 13 | self.logger = logging.getLogger(__name__) 14 | 15 | @allure.step("检测是否为Home页面") 16 | def is_home_page(self): 17 | if self.p_title.wait_exists(): 18 | self.logger.info("检测到控件:{}".format(self.p_title)) 19 | return True 20 | else: 21 | self.logger.info("未检测到控件:{}".format(self.p_title)) 22 | return False 23 | 24 | @allure.step("点击下一步") 25 | def click_next(self): 26 | if self.p_btn_next.wait_exists(): 27 | self.logger.info("检测到控件:{}".format(self.p_title)) 28 | else: 29 | self.logger.info("未检测到控件:{}".format(self.p_title)) 30 | self.p_btn_next.wait_click() 31 | 32 | 33 | class AndroidHomePage(HomePage): 34 | def __init__(self, poco_driver): 35 | super().__init__(poco_driver) 36 | 37 | self.p_btn_next = {"name": "com.app.demo:id/btn_next"} 38 | self.p_title = {"text": "Home"} 39 | 40 | 41 | class IOSHomePage(HomePage): 42 | def __init__(self, poco_driver): 43 | super().__init__(poco_driver) 44 | 45 | self.p_btn_next = {"name": "Next", "type": "Button"} 46 | self.p_title = {"name": "Home", "type": "StaticText"} 47 | -------------------------------------------------------------------------------- /src/pages/second_page.air/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/src/pages/second_page.air/.DS_Store -------------------------------------------------------------------------------- /src/pages/second_page.air/second_page.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import allure 4 | from airtest.core.api import * 5 | 6 | using("src/pages/base_page.air") 7 | from base_page import BasePage 8 | 9 | 10 | class SecondPage(BasePage): 11 | def __init__(self, poco_driver): 12 | super().__init__(poco_driver) 13 | self.logger = logging.getLogger(__name__) 14 | 15 | @allure.step("检测是否为Second页面") 16 | def is_second_page(self): 17 | if self.p_title.wait_exists(): 18 | self.logger.info("检测到控件:{}".format(self.p_title)) 19 | return True 20 | else: 21 | self.logger.info("未检测到控件:{}".format(self.p_title)) 22 | return False 23 | 24 | @allure.step("点击返回") 25 | def click_back(self): 26 | if self.p_btn_back.wait_exists(): 27 | self.logger.info("检测到控件:{}".format(self.p_title)) 28 | else: 29 | self.logger.info("未检测到控件:{}".format(self.p_title)) 30 | self.p_btn_back.wait_click() 31 | 32 | 33 | class AndroidSecondPage(SecondPage): 34 | def __init__(self, poco_driver): 35 | super().__init__(poco_driver) 36 | 37 | self.p_btn_back = {"name": "转到上一层级", 'type': "android.widget.ImageButton"} 38 | self.p_title = {"name": "com.app.demo:id/title_second", "text": "Second"} 39 | 40 | 41 | class IOSSecondPage(SecondPage): 42 | def __init__(self, poco_driver): 43 | super().__init__(poco_driver) 44 | 45 | self.p_btn_back = {"name": "black", "type": "Button"} 46 | self.p_title = {"name": "Second", "type": "StaticText"} 47 | -------------------------------------------------------------------------------- /src/utils/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StandOutstar/Pytest_Allure_Airtest_Demo/16105fd6d051e443490d68ef85b20b9ec5d4a46f/src/utils/.DS_Store -------------------------------------------------------------------------------- /src/utils/analysis_ipa.py: -------------------------------------------------------------------------------- 1 | import plistlib 2 | import re 3 | import sys 4 | import zipfile 5 | 6 | 7 | def analyze_ipa_with_plistlib(ipa_path): 8 | ipa_file = zipfile.ZipFile(ipa_path) 9 | plist_path = find_plist_path(ipa_file) 10 | plist_data = ipa_file.read(plist_path) 11 | plist_root = plistlib.loads(plist_data) 12 | # print(plist_root) 13 | # print_ipa_info(plist_root) 14 | return plist_root['CFBundleShortVersionString'] 15 | 16 | 17 | def print_ipa_info(plist_root): 18 | print('Display Name: %s' % plist_root['CFBundleName']) 19 | print('Bundle Identifier: %s' % plist_root['CFBundleIdentifier']) 20 | print('Version: %s' % plist_root['CFBundleShortVersionString']) 21 | 22 | 23 | def find_plist_path(zip_file): 24 | name_list = zip_file.namelist() 25 | # print name_list 26 | pattern = re.compile(r'Payload/[^/]*.app/Info.plist') 27 | for path in name_list: 28 | m = pattern.match(path) 29 | if m is not None: 30 | return m.group() 31 | 32 | 33 | if __name__ == '__main__': 34 | args = sys.argv[1:] 35 | if len(args) < 1: 36 | print('Usage: python ipaInfo3.py /path/to/ipa') 37 | exit(0) 38 | 39 | ipa_path = args[0] 40 | analyze_ipa_with_plistlib(ipa_path) 41 | -------------------------------------------------------------------------------- /src/utils/function.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | 3 | 4 | def generate_random_num_str(length=20): 5 | """ 6 | 生成随机字母与数字混合字符串 7 | :param length: 8 | :return: 9 | """ 10 | letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 11 | 'v', 'w', 'x', 'y', 'z'] 12 | random_list = [] 13 | for _ in range(length): 14 | random_list.append(randint(0, len(letters)) - 1) 15 | 16 | for i, v in enumerate(random_list): 17 | if i % 2 == 0: 18 | v %= 10 19 | random_list[i] = str(v) 20 | continue 21 | random_list[i] = letters[v] 22 | return ''.join(random_list) -------------------------------------------------------------------------------- /src/utils/rw_xml.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | 3 | """ 4 | 读写xml 5 | """ 6 | 7 | import xml.etree.ElementTree as ET 8 | import xml.dom.minidom as minidom 9 | 10 | 11 | def read_xml(in_path): 12 | """读取并解析xml文件 13 | in_path: xml路径 14 | return: tree 15 | """ 16 | tree = ET.parse(in_path) 17 | return tree 18 | 19 | 20 | def xml_tree_to_dict(tree): 21 | """xml生成为dict, 22 | 将tree中个节点添加到list中,将list转换为字典dict_init 23 | 叠加生成多层字典dict_new 24 | """ 25 | dict_new = {} 26 | for key, valu in enumerate(tree.getroot()): 27 | dict_init = {} 28 | list_init = [] 29 | for item in valu: 30 | list_init.append([item.tag, item.text]) 31 | for lists in list_init: 32 | dict_init[lists[0]] = lists[1] 33 | dict_new[key] = dict_init 34 | return dict_new 35 | 36 | 37 | def dict_to_xml_tree(input_dict, root_tag, node_tag): 38 | """ 定义根节点root_tag,定义第二层节点node_tag 39 | 第三层中将字典中键值对对应参数名和值 40 | return: xml的tree结构 """ 41 | root_name = ET.Element(root_tag) 42 | for (k, v) in input_dict.items(): 43 | node_name = ET.SubElement(root_name, node_tag) 44 | for (key, val) in sorted(v.items(), key=lambda e:e[0], reverse=True): 45 | key = ET.SubElement(node_name, key) 46 | key.text = val 47 | return root_name 48 | 49 | 50 | def out_xml(root, path): 51 | """格式化root转换为xml文件""" 52 | rough_string = ET.tostring(root, 'utf-8') 53 | reared_content = minidom.parseString(rough_string) 54 | with open(path, 'w+') as fs: 55 | reared_content.writexml(fs, addindent=" ", newl="\n", encoding="utf-8") 56 | return True 57 | 58 | 59 | def main(): 60 | # root = read_xml('/Users/mac/MyCode/Repo/PycharmProjects/LoomoTestingTool/allure-results/environment.xml') 61 | # print(repr(root)) 62 | # print(xml_tree_to_dict(root)) 63 | 64 | d = {0: {'key': 'App.Platform', 'value': 'Android'}, 1: {'key': 'App.Version', 'value': '0.8.49'}, 2: {'key': 'App.Branch', 'value': 'test'}} 65 | tree = dict_to_xml_tree(d, "environment", "parameter") 66 | out_xml(tree, "./test.xml") 67 | 68 | 69 | if __name__ == '__main__': 70 | main() --------------------------------------------------------------------------------