Skip to content

企业微信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 应用,和连接微信生态的能力。
    • 可帮助企业连接内部、连接生态伙伴、连接消费者。专业协作、安全管理、人即服务。
  • 前提条件:
    1. 手机端安装好企业微信 App。
    2. 企业微信注册用户。

测试需求

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

实战思路

uml diagram

使用 PO 模式封装测试框架

  1. PO 设计模式 回顾。
  2. 构造页面相关类和方法,实现暂时留空。
  3. 根据业务逻辑编写,编写测试用例。

uml diagram

分层 作用 示例
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 测试框架搭建

填充测试框架

  1. app 启动。
  2. BasePage 封装。
  3. 封装元素为私有属性。
  4. 封装测试数据记录方法。
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')
黑名单处理
装饰器相关概念

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

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

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

特点:

  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}")
            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 测试报告

总结

  1. PO 设计模式优化脚本。
  2. 多设备自动化测试的多种方案与原理。