Skip to content

【周末】用户端App自动化测试实战

用户端 App 自动化测试实战

预习准备

  • 提前先预习完以下相关的知识,再开始本章节的学习。
知识模块 等级 知识点 Python 班级
用户端 APP 自动化测试 L3 自动化关键数据记录 Python 版录播
用户端 APP 自动化测试 L3 app 弹窗异常处理
用户端 APP 自动化测试 L3 自动化测试架构优化
用户端 APP 自动化测试 L3 【实战】基于 page object 模式的测试框架优化实战

课程目标

  • 掌握 App 自动化测试框架封装能力
  • 掌握 App 自动化测试框架优化能力

知识点总览

点击查看:App 自动化测试知识点梳理.xmind

需求说明

被测对象

  • 企业微信
  • 腾讯微信团队打造的企业通讯与办公工具,具有与微信一致的沟通体验,丰富的 OA 应用,和连接微信生态的能力。
  • 可帮助企业连接内部、连接生态伙伴、连接消费者。专业协作、安全管理、人即服务。
  • 前提条件:
  • 手机端安装好企业微信 App。
  • 企业微信注册用户。

测试需求

  • 完成 App 自动化测试框架搭建
  • 在自动化测试框架中编写自动化测试用例
  • 优化测试框架
  • 输出测试报告

实战思路

uml diagram

使用 PO 模式封装测试框架

  • 马丁福勒个人博客:https://martinfowler.com/bliki/PageObject.html

PO 模式六大原则

  • 属性意义
  • 不要暴露页面内部的元素给外部
  • 不需要建模 UI 内的所有元素
  • 方法意义
  • 用公共方法代表 UI 所提供的功能
  • 方法应该返回其他的 PageObject 或者返回用于断言的数据
  • 同样的行为不同的结果可以建模为不同的方法
  • 不要在方法内加断言
构造页面相关类和方法
  • 基础层:对底层工具进行二次封装,例如元素查找方法。
  • 公共业务层:app 的启动配置。
  • 页面层:每个页面或功能模块作为一个类,类中包含该页面的元素和操作方法。
  • 测试用例层:使用 pytest 编写测试用例,调用业务逻辑层的方法进行测试。
  • 公共方法层:一些通用的工具函数,例如日志记录、数据读取等。

uml diagram

目录结构
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 界面提示,警告的作用)
  • 装饰器优势
  • 对原有函数的功能增强
  • 不改变原有函数的逻辑
  • 使代码更简洁、易维护

装饰器相关概念

通过闭包来实现装饰器,函数作为外层函数的传入参数,然后在内层函数中运行、附加功能,随后把内层函数作为结果返回。

  • 闭包定义:
  • 在函数嵌套的前提下,
  • 内部函数使用了外部函数的变量,并且外部函数返回了内部函数
  • 我们把这个使用外部函数变量的内部函数称为闭包
  • 闭包的构成条件:
  • 在函数嵌套(函数里面在定义函数)的前提下
  • 内部函数使用了外部函数的变量(还包括外部函数的参数)
  • 外部函数返回了内部函数

装饰器:外部函数传入被装饰函数名,内部函数返回装饰函数名。

特点:

  1. 不修改被装饰函数的调用方式
  2. 不修改被装饰函数的源代码
代码实现

先在工具类中定义文件保存目录的创建和获取。

# 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 报告生成