【周末】用户端App自动化测试实战
用户端 App 自动化测试实战
预习准备
- 提前先预习完以下相关的知识,再开始本章节的学习。
知识模块 | 等级 | 知识点 | Python 班级 |
---|---|---|---|
用户端 APP 自动化测试 | L3 | 自动化关键数据记录 | Python 版录播 |
用户端 APP 自动化测试 | L3 | app 弹窗异常处理 | |
用户端 APP 自动化测试 | L3 | 自动化测试架构优化 | |
用户端 APP 自动化测试 | L3 | 【实战】基于 page object 模式的测试框架优化实战 |
课程目标
- 掌握 App 自动化测试框架封装能力
- 掌握 App 自动化测试框架优化能力
知识点总览
点击查看:App 自动化测试知识点梳理.xmind
需求说明
被测对象
- 企业微信
- 腾讯微信团队打造的企业通讯与办公工具,具有与微信一致的沟通体验,丰富的 OA 应用,和连接微信生态的能力。
- 可帮助企业连接内部、连接生态伙伴、连接消费者。专业协作、安全管理、人即服务。
- 前提条件:
- 手机端安装好企业微信 App。
- 企业微信注册用户。
测试需求
- 完成 App 自动化测试框架搭建
- 在自动化测试框架中编写自动化测试用例
- 优化测试框架
- 输出测试报告
实战思路
使用 PO 模式封装测试框架
- 马丁福勒个人博客:https://martinfowler.com/bliki/PageObject.html
PO 模式六大原则
- 属性意义
- 不要暴露页面内部的元素给外部
- 不需要建模 UI 内的所有元素
- 方法意义
- 用公共方法代表 UI 所提供的功能
- 方法应该返回其他的 PageObject 或者返回用于断言的数据
- 同样的行为不同的结果可以建模为不同的方法
- 不要在方法内加断言
构造页面相关类和方法
- 基础层:对底层工具进行二次封装,例如元素查找方法。
- 公共业务层:app 的启动配置。
- 页面层:每个页面或功能模块作为一个类,类中包含该页面的元素和操作方法。
- 测试用例层:使用 pytest 编写测试用例,调用业务逻辑层的方法进行测试。
- 公共方法层:一些通用的工具函数,例如日志记录、数据读取等。
目录结构
Hogwarts $ tree
.
├── __init__.py
├── base
│ ├── __init__.py
│ ├── app.py
│ └── base_page.py
├── cases
│ ├── __init__.py
│ └── test_xxx.py
├── log
│ ├── test.log
├── datas
│ └── xxx.yml
├── page
│ ├── __init__.py
│ ├── main_page.py
│ ├── xxx_page.py
│ └── xxx_page.py
├── conftest.py
└── utils
├── __init__.py
└── log_utils.py
代码实现
# 代码详见仓库
课堂练习
- 使用 PO 模式完成企业微信 App 测试框架搭建
填充测试框架
完成 App 启动与页面服务定义
# 代码详见仓库
BasePage 封装
添加日志
utils 包下创建 log_util.py
import logging
import os
from logging.handlers import RotatingFileHandler
# 绑定绑定句柄到logger对象
logger = logging.getLogger(__name__)
# 获取当前工具文件所在的路径
root_path = os.path.dirname(os.path.abspath(__file__))
# 拼接当前要输出日志的路径
log_dir_path = os.sep.join([root_path, '..', f'/logs'])
if not os.path.isdir(log_dir_path):
os.mkdir(log_dir_path)
# 创建日志记录器,指明日志保存路径,每个日志的大小,保存日志的上限
file_log_handler = RotatingFileHandler(os.sep.join([log_dir_path, 'log.txt']), maxBytes=1024 * 1024, backupCount=10 , encoding="utf-8")
# 设置日志的格式
date_string = '%Y-%m-%d %H:%M:%S'
formatter = logging.Formatter(
'[%(asctime)s] [%(levelname)s] [%(filename)s]/[line: %(lineno)d]/[%(funcName)s] %(message)s ', date_string)
# 日志输出到控制台的句柄
stream_handler = logging.StreamHandler()
# 将日志记录器指定日志的格式
file_log_handler.setFormatter(formatter)
stream_handler.setFormatter(formatter)
# 为全局的日志工具对象添加日志记录器
# 绑定绑定句柄到logger对象
logger.addHandler(stream_handler)
logger.addHandler(file_log_handler)
# 设置日志输出级别
logger.setLevel(level=logging.INFO)
基类中封装常用方法
# base_page.py
class BasePage:
def __init__(self, driver: WebDriver=None):
self.driver = driver
def find_ele(self, by, value):
'''
查找元素,返回元素
:param by: 定位方式
:param value: 元素定位表达式
:return: 定位到的元素对象
'''
step_text = f"查找单个元素的定位:{by},{value}"
logger.info(step_text)
ele = self.driver.find_element(by, value)
return ele
def find_eles(self, by, value):
'''
查找多个元素
:param by: 定位方式
:param value: 元素定位表达式
:return: 定位到的元素列表
'''
logger.info(f"查找多个元素的定位:{by},{value}")
eles = self.driver.find_elements(by, value)
return eles
def find_and_click(self, by, value):
'''
查找元素并点击
:param by: 定位方式
:param value: 元素定位表达式
'''
logger.info(f"查找元素 {by},{value} 并点击")
self.find_ele(by, value).click()
def find_and_sendkeys(self, by, value, text):
'''
查找元素并输入
:param text: 输入的文本
:param by: 定位方式
:param value: 元素定位表达式
'''
logger.info(f"查找元素 {by},{value} 并输入内容 {text}")
self.find_ele(by, value).send_keys(text)
def set_implicitly_wait(self, time=1):
'''
设置隐式等待
:param time: 隐式等待时间
'''
logger.info(f"设置隐式等待时间为 {time}")
self.driver.implicitly_wait(time)
def wait_ele_located(self, by, value, timeout=10):
'''
显式等待元素可以被定位
:param by: 元素定位方式
:param value: 元素定位表达式
:param timetout: 等待时间
:return: 定位到的元素对象
'''
logger.info(f"显式等待 {by} {value} 出现,等待时间为 {timeout}")
ele = WebDriverWait(self.driver, timeout).until(
expected_conditions.invisibility_of_element_located((by, value))
)
return ele
def wait_ele_click(self, by, value, timeout=10):
'''
显式等待元素可以被点击
:param by: 元素定位方式
:param value: 元素定位表达式
:param timeout: 等待时间
'''
logger.info(f"显式等待 {by} {value} 出现,等待时间为 {timeout}")
ele = WebDriverWait(self.driver, timeout).until(
expected_conditions.element_to_be_clickable((by, value))
)
return ele
def wait_for_text(self, text, timeout=5):
'''
等待某一个文本出现
'''
logger.info(f"显式等待 {text} 出现,等待时间为 {timeout}")
try:
WebDriverWait(self.driver, timeout).until(
lambda x: x.find_element(AppiumBy.XPATH, f"//*[@text='{text}']")
)
logger.info(f"{text}元素出现")
return True
except:
logger.info(f"{text}元素未出现")
return False
def swipe_window(self):
'''
滑动界面
'''
# 滑动操作
# 获取设备的尺寸
size = self.driver.get_window_size()
# {"width": xx, "height": xx}
logger.info(f"设备尺寸为 {size}")
width = size.get("width")
height = size.get('height')
# # 获取滑动操作的坐标值
start_x = width / 2
start_y = height * 0.8
end_x = start_x
end_y = height * 0.2
logger.info(f"滑动,起始坐标为 {start_x, start_y} 结束坐标为 {end_x, end_y}")
# swipe(起始x坐标,起始y坐标,结束x坐标,结束y坐标,滑动时间(单位毫秒))
self.driver.swipe(start_x, start_y, end_x, end_y, 2000)
def swipe_find(self, text, max_num=5):
'''
滑动查找
通过文本来查找元素,如果没有找到元素,就滑动,
如果找到了,就返回元素
'''
# 为了滑动操作更快速,不用等待隐式等待设置的时间
self.set_implicitly_wait()
for num in range(max_num):
try:
# 正常通过文本查找元素
ele = self.find_ele(AppiumBy.XPATH, f"//*[@text='{text}']")
logger.info(f"找到元素 {ele}")
# 能找到则把隐式等待恢复原来的时间
self.set_implicitly_wait(15)
# 返回找到的元素对象
return ele
except Exception:
# 当查找元素发生异常时
logger.info(f"没有找到元素,开始滑动")
logger.info(f"滑动第{num + 1}次")
# 滑动操作
self.swipe_window()
# 把隐式等待恢复原来的时间
self.set_implicitly_wait(15)
# 抛出找不到元素的异常
raise NoSuchElementException(f"滑动之后,未找到 {text} 元素")
def get_toast_tips(self):
'''
获取 toast 的文本
:return: 返回获取到的文本内容
'''
toast_text =self.find_ele(
AppiumBy.XPATH,
"//*[@class='android.widget.Toast']"
).text
logger.info(f"获取到的 toast 文本为 {toast_text}")
return toast_text
def go_back(self, num=5):
'''
执行返回操作
:param num: 返回的次数
'''
logger.info(f"点击返回按钮 {num+1} 次")
for i in range(num):
self.driver.back()
业务页面封装
# 代码详见仓库
业务页面中切换为基类中的方法
# 代码详见仓库
复用 driver
class WeworkApp(BasePage):
def start(self):
'''
启动 app
:return:
'''
app_package = "com.tencent.wework"
print(f"driver 为 {self.driver}")
if self.driver:
# 激活未运行或者正在后台运行的应用程序
self.driver.activate_app(app_package)
else:
# Capability 设置定义为字典
caps = {}
# 设置 app 安装的平台(Android、iOS)
caps["platformName"] = "Android"
# 设置 app 安装平台的版本
caps["appium:platformVersion"] = "12"
# 设备的名字
caps["appium:deviceName"] = "4c740d8e"
# 设置 app 的包名
caps["appium:appPackage"] = "com.tencent.wework"
# 设置 app 启动页
caps["appium:appActivity"] = ".launch.LaunchSplashActivity"
# 不清空缓存
caps["appium:noReset"] = True
# app 不重启
caps["appium:dontStopAppOnReset"] = True
# 跳过 appium settings 安装
caps["appium:skipDeviceInitialization"] = True
# 初始化 driver
self.driver = webdriver.Remote("http://127.0.0.1:4723/wd/hub", caps)
# 设置全局的隐式等待
self.driver.implicitly_wait(15)
return self
封装元素为私有属性
把元素定位表达式也拆分出来,定义为私有属性。满足 PO 六大原则中,不要暴露页面内部的元素给外部的要求。
例如:
class MainPage(WeworkApp):
# 通讯录按钮
_CONTACT_BTN = AppiumBy.XPATH, "//*[@text='通讯录']"
def goto_address_list(self):
'''
跳转通讯录页面
:return:
'''
# 点击通讯录按钮
# 解包传参
self.find_and_click(*self._CONTACT_BTN)
return AddressListPage(self.driver)
其余页面做类似的改造。完成后运行一下看效果。可以正常运行,那说明上面的改造是没有问题的。
课堂练习
- 完成企业微信 App 测试框架中基类的封装
- 完成企业微信添加成员测试脚本
- 在框架中完成查询成员的自动化测试
优化测试框架
数据驱动
准备测试数据
# datas/members_info.yaml
member_info:
- - 陈俊
- "15691203895"
- - 胡桂芳
- "14590228232"
- - 陈玉
- "13984932909"
conftest.py 中获取项目路径,并完成项目路径添加到环境变量中的操作。
import os
import sys
from auto_test_app.wework_app_po.utils.log_util import logger
# 添加前项目路径到环境变量
root_path = os.path.dirname(os.path.abspath(__file__))
logger.info(f"当前项目路径为 {root_path}")
sys.path.append(root_path)
在 Utils 中添加 utils.py
class Utils:
@classmethod
def get_file_path(cls, path_name):
'''
获取文件绝对路径
:param path_name: 文件相对路径
:return:
'''
# 拼接 yaml 文件路径
# root_path 为 conftest.py 中获取的数据,导入即可使用
path = os.sep.join([root_path, path_name])
logger.info(f"文件路径为 {path}")
return path
@classmethod
def get_yaml_data(cls, yaml_path):
'''
读取 yaml 文件数据
:param yaml_path: yaml 文件路径
:return: 读取到的数据
'''
with open(yaml_path, encoding="utf-8") as f:
datas = yaml.safe_load(f)
return datas
测试用例中定义读取测试数据的方法。
# test_contact_by_params.py
def get_member_datas():
'''
读取添加成员测试数据
:return:
'''
# 拼接 yaml 文件路径
yaml_path = Utils.get_file_path('datas/member_info.yaml')
print(f"yaml 文件路径为 {yaml_path}")
yaml_datas = Utils.get_yaml_data(yaml_path)
print(yaml_datas)
# 获取对应的测试数据
datas = yaml_datas.get("member_info")
logger.info(f"获取到的成员数据为 ===> {datas}")
return datas
class TestContact:
def setup_class(self):
# 准备测试数据
self.faker = Faker("zh_CN")
# 成员的姓名
self.name = self.faker.name()
# 成员的手机号
self.phonenum = self.faker.phone_number()
# 实例化 App
self.app = WeworkApp()
def setup(self):
# 启动 app,进入首页
self.main = self.app.start().goto_main()
def teardown(self):
# 返回首页
self.app.go_back(1)
def teardown_class(self):
# 关闭 app
self.app.stop()
@pytest.mark.parametrize(
"name, phonenum", get_member_datas()
)
def test_add_contact(self, name, phonenum):
'''
数据驱动测试添加成员
:return:
'''
tips = self.main.goto_address_list().\
goto_add_member().\
goto_menual_input_page().\
input_member(name, phonenum).\
get_tips()
assert "添加成功" == tips
conftest.py 中处理中文乱码
# 解决用例描述中中文乱码的问题
def pytest_collection_modifyitems(
session, config, items
) -> None:
for item in items:
item.name = item.name.encode('utf-8').decode('unicode-escape')
item._nodeid = item.nodeid.encode('utf-8').decode('unicode-escape')
黑名单处理
- 运行过程中不定时弹框(广告弹窗,升级提示框,新消息提示框等等)
- 弹框不是 BUG(UI 界面提示,警告的作用)
- 装饰器优势
- 对原有函数的功能增强
- 不改变原有函数的逻辑
- 使代码更简洁、易维护
装饰器相关概念
通过闭包来实现装饰器,函数作为外层函数的传入参数,然后在内层函数中运行、附加功能,随后把内层函数作为结果返回。
- 闭包定义:
- 在函数嵌套的前提下,
- 内部函数使用了外部函数的变量,并且外部函数返回了内部函数
- 我们把这个使用外部函数变量的内部函数称为闭包
- 闭包的构成条件:
- 在函数嵌套(函数里面在定义函数)的前提下
- 内部函数使用了外部函数的变量(还包括外部函数的参数)
- 外部函数返回了内部函数
装饰器:外部函数传入被装饰函数名,内部函数返回装饰函数名。
特点:
- 不修改被装饰函数的调用方式
- 不修改被装饰函数的源代码
代码实现
先在工具类中定义文件保存目录的创建和获取。
# utils.py
class Utils:
...
@classmethod
def get_current_time(cls):
"""
获取当前的日期与时间
:return:
"""
return time.strftime("%Y-%m-%d-%H-%M-%S")
def save_source_datas(self, source_type):
'''
保存文件
:param source_type: 文件类型,images 为图片,pagesource 为页面源码
:return:
'''
if source_type == "images":
end = ".png"
_path = "images"
elif source_type == "pagesource":
end = "_page_source.xml"
_path = "page_source"
else:
return None
# 以当前时间命名
source_name = Utils.get_current_time() + end
# 拼接当前要输出的路径
source_dir_path = os.sep.join([root_path, _path])
# 资源目录如果不存在则新创建一个
if not os.path.isdir(source_dir_path):
os.mkdir(source_dir_path)
# 拼接资源保存目录
source_file_path = os.sep.join([source_dir_path, source_name])
# 返回保存的路径
return source_file_path
基类中定义截图与保存 page source 的方法。
# base_page.py
class BasePage:
def screenshot(self):
'''
截图
:param path: 截图保存路径
'''
file_path = Utils.save_source_datas("images")
# 截图
self.driver.save_screenshot(file_path)
logger.info(f"截图保存的路径为{file_path}")
# 返回保存图片的路径
return file_path
def save_page_source(self):
'''
保存页面源码
:return: 返回源码文件路径
'''
file_path = Utils.save_source_datas("pagesource")
# 写 page source 文件
with open(file_path, "w", encoding="u8") as f:
f.write(self.driver.page_source)
logger.info(f"源码保存的路径为{file_path}")
# 返回 page source 保存路径
return file_path
utils 包中创建 error_handle.py,实现黑名单处理逻辑
# 弹窗黑名单
black_list = [
(AppiumBy.XPATH, "//*[@text='确定']"),
(AppiumBy.XPATH, "//*[@text='取消']")
]
# 传入的 fun 相当于 find(self, by, value): 方法
def black_wrapper(fun):
def run(*args, **kwargs):
# basepage 相当于传入的第一个参数 self
basepage = args[0]
try:
logger.info(f"开始查找元素:{args[2]}")
return fun(*args, **kwargs)
except Exception as e:
logger.warning("未找到元素,处理异常")
# 遇到异常截图
# 获取当前工具文件所在的路径
image_path = basepage.screenshot()
allure.attach.file(image_path, name="查找元素异常截图", attachment_type=allure.attachment_type.PNG)
# 保存页面源码
pagesource_path = basepage.save_page_source()
allure.attach.file(pagesource_path, name="page_source", attachment_type=allure.attachment_type.TEXT)
for b in black_list:
# 设置隐式等待时间为 1 s
basepage.set_implicitly_wait()
# 查找黑名单中的每一个元素
eles = basepage.driver.find_elements(*b)
if len(eles) > 0:
# 点击弹框
eles[0].click()
# 恢复隐式等待设置
basepage.set_implicitly_wait(15)
# 继续查找元素
return fun(*args, **kwargs)
logger.error(f"遍历黑名单,仍未找到元素,异常信息为 ====> {e}")
logger.error(f"traceback.format_exc() 信息为 ====> {traceback.format_exc()}")
raise e
return run
为基类中查找元素的方法添加装饰器。
# base_page.py
class BasePage:
def __init__(self, driver: WebDriver=None):
self.driver = driver
@black_wrapper
def find_ele(self, by, value):
'''
查找元素,返回元素
:param by: 定位方式
:param value: 元素定位表达式
:return: 定位到的元素对象
'''
step_text = f"查找单个元素的定位:{by},{value}"
logger.info(step_text)
ele = self.driver.find_element(by, value)
return ele
@black_wrapper
def find_eles(self, by, value):
'''
查找多个元素
:param by: 定位方式
:param value: 元素定位表达式
:return: 定位到的元素列表
'''
logger.info(f"查找多个元素的定位:{by},{value}")
eles = self.driver.find_elements(by, value)
return eles
课堂练习
- 为测试框架添加弹窗处理逻辑
测试报告
添加描述信息
添加报告步骤描述。
# base_page.py
class BasePage:
def __init__(self, driver: WebDriver=None):
self.driver = driver
@black_wrapper
def find_ele(self, by, value):
'''
查找元素,返回元素
:param by: 定位方式
:param value: 元素定位表达式
:return: 定位到的元素对象
'''
step_text = f"查找单个元素的定位:{by},{value}"
logger.info(step_text)
with allure.step(step_text):
ele = self.driver.find_element(by, value)
return ele
@black_wrapper
def find_eles(self, by, value):
'''
查找多个元素
:param by: 定位方式
:param value: 元素定位表达式
:return: 定位到的元素列表
'''
step_text = f"查找多个元素的定位:{by},{value}"
logger.info(step_text)
with allure.step(step_text):
eles = self.driver.find_elements(by, value)
return eles
测试用例添加描述。
@allure.feature("企业微信联系人操作")
class TestContact:
...
@allure.story("添加成员")
@allure.title("添加成员冒烟用例")
def test_add_member(self):
'''
添加成员测试用例
1 首页,跳转通讯录页面
2 通讯录页面,跳转添加成员页面
3 添加成员页面,跳转手动添加成员页面
4 手动添加成员页面,输入成员信息,跳转添加成员页面
5 添加成员页面,获取提示信息文本,完成断言
:return:
'''
tips = self.main.goto_address_list().\
goto_add_member().\
goto_menual_input_page().\
input_member(self.name, self.phonenum).\
get_tips()
assert "添加成功" == tips
@allure.story("查找成员")
@allure.title("查找成员冒烟用例")
def test_search_contact(self):
'''
1 首页,点击通讯录按钮,进入通讯录页面
2 通讯录页面上点击放大镜按钮,进入搜索页面
3 搜索页面,输入框输入搜索关键词,搜索框下方展示搜索结果
4 搜索页面,获取搜索结果,进行断言
'''
searchkey = "feier"
eles = self.main.goto_address_list().\
goto_search_page().\
input_search_info(searchkey).get_search_result()
names = [e.text for e in eles]
logger.info(f"获取到的成员姓名列表为 {names}")
assert searchkey in names
生成测试报告
pytest --alluredir=./results --clean-alluredir
allure serve ./results
allure generate --clean report/html report -o report/html
课堂练习
- 生成 Allure 测试报告
总结
- PO 模式测试框架搭建
- 自动化测试架构优化
- App 弹窗异常处理
- Allure 报告生成