企业微信App自动化测试实战
用户端 App 自动化测试实战
预习准备
- 提前先预习完以下相关的知识,再开始本章节的学习。
专题课 | 阶段 | 教程地址 | 视频地址 |
---|---|---|---|
用户端 APP 自动化测试 | L3 | 自动化关键数据记录 | 自动化关键数据记录 15:13 |
用户端 APP 自动化测试 | L3 | app 弹窗异常处理 | app 弹窗异常处理 31:29 |
用户端 APP 自动化测试 | L3 | 自动化测试架构优化 | 自动化测试架构优化 20:08 |
用户端 APP 自动化测试 | L3 | 【实战】基于 page object 模式的测试框架优化实战 | 【实战】基于 page object 模式的测试框架优化实战 1:00:55 |
课程目标
- 掌握 App 自动化测试框架封装能力。
- 掌握 App 自动化测试框架优化能力。
- 掌握 App 多设备自动化测试的原理与方案。
需求说明
被测对象
- 企业微信
- 腾讯微信团队打造的企业通讯与办公工具,具有与微信一致的沟通体验,丰富的 OA 应用,和连接微信生态的能力。
- 可帮助企业连接内部、连接生态伙伴、连接消费者。专业协作、安全管理、人即服务。
- 前提条件:
- 手机端安装好企业微信 App。
- 企业微信注册用户。
测试需求
- 完成 App 自动化测试框架搭建。
- 在自动化测试框架中编写自动化测试用例。
- 优化测试框架。
- 输出测试报告。
- 实现多设备自动化测试。
实战思路
使用 PO 模式封装测试框架
- PO 设计模式 回顾。
- 构造页面相关类和方法,实现暂时留空。
- 根据业务逻辑编写,编写测试用例。
分层 | 作用 | 示例 |
---|---|---|
BasePage | 封装和业务无关的公共方法(操作) | 查找元素 滑动行为 |
业务 App | 和具体 App 相关的操作 | 初始化 App 回到首页 |
业务 Page | 具体的业务页面 | ContactPage(通讯录页) MainPage(首页) |
测试用例层 | 测试步骤,相关的页面以及断言 | 添加成员用例 查找成员用例 |
目录结构
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 封装。
- 封装元素为私有属性。
- 封装测试数据记录方法。
app 启动
# 代码详见仓库
BasePage 封装
基类中封装常用方法
# 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_text(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()
封装测试数据记录方法
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)
业务页面封装
# 代码详见仓库
业务页面中切换为基类中的方法
# 代码详见仓库
封装元素为私有属性
把元素定位表达式也拆分出来,定义为私有属性。满足 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)
其余页面做类似的改造。完成后运行一下看效果。可以正常运行,那说明上面的改造是没有问题的。
优化测试框架
数据驱动
准备测试数据
# 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/members_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 TestContactByParams:
def setup_method(self):
'''
实例化 app
'''
self.app = WeworkApp()
# 启动 app 进入首页
self.main = self.app.start().goto_main()
def teardown_method(self):
'''
关闭 app
'''
self.app.stop()
@pytest.mark.parametrize(
"name, phonenum", get_member_datas()
)
def test_add_member(self, name, phonenum):
'''
添加成员测试用例
:return:
'''
toast_tips = self.main.goto_address_list_page().\
goto_add_member_page().goto_menual_input_page().\
quick_input_member(name, phonenum).get_toast_tips()
assert "添加成功" == toast_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')
黑名单处理
装饰器相关概念
通过闭包来实现装饰器,函数作为外层函数的传入参数,然后在内层函数中运行、附加功能,随后把内层函数作为结果返回。
- 闭包定义:
- 在函数嵌套的前提下,
- 内部函数使用了外部函数的变量,并且外部函数返回了内部函数
- 我们把这个使用外部函数变量的内部函数称为闭包
- 闭包的构成条件:
- 在函数嵌套(函数里面在定义函数)的前提下
- 内部函数使用了外部函数的变量(还包括外部函数的参数)
- 外部函数返回了内部函数
装饰器:外部函数传入被装饰函数名,内部函数返回装饰函数名。
特点:
- 不修改被装饰函数的调用方式
- 不修改被装饰函数的源代码
代码实现
先在工具类中定义文件保存目录的创建和获取。
# 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}")
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
测试报告
添加描述信息
添加报告步骤描述。
# main_page.py
class MainPage(WeworkApp):
@allure.step("点击通讯录按钮")
def goto_address_list_page(self):
'''
跳转通讯录页面
:return:
'''
# address_list_page.py
class AddressListPage(WeworkApp):
@allure.step("点击添加成员按钮")
def goto_add_member_page(self):
'''
跳转到添加成员页面
:return:
'''
# add_member_page.py
class AddMemberPage(WeworkApp):
@allure.step("点击手动输入添加按钮")
def goto_menual_input_page(self):
'''
跳转到手动添加成员页面
:return:
'''
# menual_input_page.py
class MenualInputPage(WeworkApp):
@allure.step("快捷输入成员信息")
def quick_input_member(self, name, phonenum):
'''
快捷输入成员信息
:return:
测试用例添加描述。
@allure.feature("企业微信联系人操作")
class TestContact:
...
@allure.story("添加成员")
@allure.title("添加成员冒烟用例")
def test_add_member(self):
'''
添加成员测试用例
:return:
'''
生成测试报告
pytest --alluredir=./results --clean-alluredir
allure serve ./results
allure generate --clean report/html report -o report/html
多设备自动化测试
课堂练习
- 完成企业微信 App 测试框架中基类的封装
- 完成企业微信添加成员测试脚本
- 在框架中完成查询成员的自动化测试
- 为测试框架添加弹窗处理逻辑
- 生成 Allure 测试报告
总结
- PO 设计模式优化脚本。
- 多设备自动化测试的多种方案与原理。