企业微信
App 自动化测试实战

霍格沃兹测试开发学社

ceshiren.com

目录

  • Appium 知识点梳理
  • 自动化测试用例设计
  • PO 讲解
  • 实战演练

课程准备

  • 录播:
    • App 自动化测试 L1-L3
  • 环境准备
    • Python 环境、Pytest、Allure
    • MacOS 系统
    • Android 环境
    • Android 模拟器(网易 mumu)
    • Appium Server、Appium Client
    • 注册企业微信帐号

App 自动化测试的时代背景

  • 按月发布 -> 按周发布 -> 按小时发布
  • 多端发布: Android、iOS、微信小程序、h5
  • 多环境发布: 联调环境、测试环境、预发布环境、线上环境
  • 多机型发布: 众多设备型号、众多系统版本
  • 多版本共存: 用户群体中存在多个不同的版本
  • 历史回归测试任务: 成百上千条业务用例如何回归

总结:加班 + 背锅

UI 自动化测试需要哪些技术

  • App 自动化测试: Appium、Airtest、ATX 等

Appium 介绍

  • 官网: appium.io
  • 跨语言 Java、Python、nodejs 等
  • 跨平台
    • Andoid、iOS
    • Windows、Mac
  • 底层多引擎可切换
  • 生态丰富,社区强大

App 自动化测试成果展示

  • 工程结构
  • 运行效果
Hogwarts $ tree
.
├── __init__.py
├── base
│ ├── __init__.py
│ ├── app.py
│ └── base_page.py
├── cases
│ ├── __init__.py
│ └── test_schedule.py
├── log
│ ├── test.log
├── datas
│ └── people.yml
├── page
│ ├── __init__.py
│ ├── choose_page.py
│ ├── edit_schedule_page.py
│ ├── main_page.py
│ ├── schedule_page.py
│ └── workbench_page.py
├── pytest.ini
└── utils
    ├── __init__.py
    └── generate_info.py

App 自动化测试学习路线

Appium 学习路线梳理

用例设计:手工测试与自动化测试对比

企业微信产品介绍

  • 企业通讯与办公工具
  • 有很丰富的 OA 应用

自动化测试用例设计

  • 参照手工用例怎么设计思路
  • 自动化场景:企业微信添加成员(app端)
  • 前提条件:
    1. 提前注册企业微信管理员帐号
    2. 手机端安装企业微信
    3. 企业微信 app 处于登录状态
  • 通讯录添加成员用例步骤
    1. 打开【企业微信】应用
    2. 进入【通讯录】页面
    3. 点击【添加成员】
    4. 点击【手动输入添加】
    5. 输入【姓名】【手机号】并点击【保存】
  • 验证点:登录成功提示信息

Appium 实现添加联系人用例

# test_add_member.py
# pip install Appium-Python-Client

from appium import webdriver
from appium.webdriver.common.appiumby import AppiumBy
from faker import Faker
from selenium.common import NoSuchElementException

class TestContact:

    IMPLICITLY_WAIT = 10

    def setup_class(self):
        # mock随机数据,姓名 ,地址,手机号。。。
        self.faker = Faker("zh_CN")

    def setup(self):
        # 准备资源
        caps = {}
        # 被测手机的平台名
        caps["platformName"] = "Android"
        caps['platformVersion'] = '6'
        caps["deviceName"] = "hogwarts"
        caps["appPackage"] = "com.tencent.wework"
        caps["appActivity"] = ".launch.LaunchSplashActivity"
        # 防止清缓存 "true"  True
        caps["noReset"] = True
        # 关键的步骤!!!调用Remote() 建立连接 ,返回 一个session对象
        self.driver = webdriver.Remote("http://127.0.0.1:4723/wd/hub", caps)
        # 隐式等待, 动态的等到元素出现
        self.driver.implicitly_wait(self.IMPLICITLY_WAIT)

    def teardown(self):
        # 销毁资源
        self.driver.quit()

    def test_addcontact(self):
        # mock name 和phone number
        name = self.faker.name()
        phonenum = self.faker.phone_number()
        # 进入【通讯录】页面
        self.driver.find_element(AppiumBy.XPATH, "//*[@text='通讯录']").click()
        # 点击【添加成员】
        self.driver.find_element("添加成员").click()
        # 点击【手动输入添加】
        self.driver.find_element(AppiumBy.XPATH, "//*[@text='手动输入添加']").click()
        # 输入【姓名】
        self.driver.find_element(AppiumBy.XPATH, "//*[contains(@text, '姓名')]/../*[@text='必填']").send_keys(name)
        # 输入【手机号】
        self.driver.find_element(AppiumBy.XPATH, "//*[contains(@text, '手机')]/..//*[@text='必填']").send_keys(phonenum)
        # 点击【保存】
        self.driver.find_element(AppiumBy.XPATH, "//*[@text='保存']").click()
        # 验证结果
        # class="android.widget.Toast" 隐式等待 找到toast
        toast_tips = self.driver.find_element(AppiumBy.XPATH, "//*[@class='android.widget.Toast']").text
        assert toast_tips == "添加成功"

封装滑动方法

self.driver.find_element(MobileBy.ANDROID_UIAUTOMATOR,\
  'new UiScrollable(new UiSelector().scrollable(true).instance(0))\
  .scrollIntoView(new UiSelector().text("添加成员").instance(0));')
def swipe_find(self, text, max_num = 3):   
    """滑动查找一个文本 text
    如果没有找元素,完成滑动操作
    如果找到了,则返回元素
    """
    self.driver.implicitly_wait(1)
    for num in range(max_num):
        try:
            # find_element() 每次调用这个方法的时候,都会激活隐式等待,也就是在隐式等待的时长之内,动态的找元素
            element = self.driver.find_element(AppiumBy.XPATH, f"//*[@text='{text}']")
            # 找到了元素之后,再设置回全局的隐式等待时长 10秒
            self.driver.implicitly_wait(self.IMPLICITLY_WAIT)
            return element
        except NoSuchElementException as e:
            print("未找到元素")
            # 滑动 从下向上
            size = self.driver.get_window_size()
            # 'width', 'height'
            width = size.get("width")
            height = size.get("height")

            startx = width/2
            starty = height*0.8

            endx = startx
            endy = height*0.2

            duration= 2000
            self.driver.swipe(startx, starty, endx, endy, duration)

        if num == max_num-1:
            # 没有找到的情况。在抛出异常之前,把这个隐式等待改回全局的等待时长 10 秒
            self.driver.implicitly_wait(self.IMPLICITLY_WAIT)
            # 执行到最大次数,仍然没有找到这个文本,则抛出异常
            raise NoSuchElementException(f"找了{num} 次,未找到{text}")

打造测试框架的需求与价值

  • 领域模型适配:封装业务实现,实现业务管理
  • 提高效率:降低用例维护成本,提高执行效率
  • 增强功能:解决已有框架不满足的情况

Page Object 模式六大原则

PO 原则解读

  • 属性意义

    • 不要暴露页面内部的元素给外部
    • 不需要建模 UI 内的所有元素
  • 方法意义

    • 用公共方法代表 UI 所提供的功能
    • 方法应该返回其他的 PageObject 或者返回用于断言的数据
    • 同样的行为不同的结果可以建模为不同的方法
    • 不要在方法内加断言

企业微信PO建模

  • 方块代表一个类
  • 每条线代表这个页面提供的方法
  • 箭头的始端为开始页面
  • 箭头的末端为跳转页面或需要断言的数据

企业微信 PO 封装过程

企业微信实战(PO 练习)

  • 使用 PO 模式实现企业微信添加联系人用例
  • 实战在企业微信中添加多个联系人

企业微信 PO 封装

  • PO与链式调用
    • PageObject:编写PO
    • 测试用例:实现链式调用
  • 定位与断言
    • PageObject:融入元素定位
    • 测试用例:实现断言
  • 封装 BasePage
    • driver 对象的实例化
  • 封装元素定位
    • 常用的 UI 操作封装在 base_page 中

企业微信 PO 封装实战总结

测试框架优化

  • 复用 driver
  • 添加失败用例日志
  • 查找元素异常处理(装饰器实现)
    • 保存截图
    • 同时保存页面源码
    • 处理弹窗黑名单元素
  • 添加测试报告

复用 driver

class WeworkApp(BasePage):
    IMPLICITLY_WAIT = 10

    def start(self):

        if self.driver == None:
            # 准备资源
            caps = {}
            # 被测手机的平台名
            caps["platformName"] = "Android"
            caps["deviceName"] = "hogwarts"
            caps["appPackage"] = "com.tencent.wework"
            caps["appActivity"] = ".launch.LaunchSplashActivity"
            # 防止清缓存 "true"  True
            caps["noReset"] = "true"
            caps['settings[waitForIdleTimeout]'] = 1
            caps['dontStopAppOnReset'] = True
            # 关键的步骤!!!调用Remote() 建立连接 ,返回 一个session对象
            self.driver = webdriver.Remote("http://127.0.0.1:4723/wd/hub", caps)
            # 隐式等待, 动态的等到元素出现
            self.driver.implicitly_wait(self.IMPLICITLY_WAIT)
        else:
            # 直接启动desire里设置的package及activity的相关设置参数,只启动当前测试的应用
            self.driver.launch_app()
        return self

添加失败用例日志

  • 知识点:pytest 直播课-hook 部分
  • 知识点:web 自动化测试直播课-添加日志部分
# conftest.py
import os
import pytest
import yaml
import sys
from wework_po.utils.log_util import logger

# 添加前项目路径到环境变量
root_path = os.path.dirname(os.path.abspath(__file__))
sys.path.append(root_path)

# 解决用例描述中中文乱码的问题
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')

@pytest.hookimpl(hookwrapper=True, tryfirst=True)
def pytest_runtest_makereport(item, call):
    # 获取钩子方法的调用结果
    out = yield
    # 从钩子方法的调用结果中获取测试报告
    report = out.get_result()
    fail_case_info = []
    # 错误类型
    error_type = None
    # 错误信息
    error_msg = None
    # 错误完整信息
    error_longrepr = None
    # 如果用例执行不通过
    if report.outcome != "passed":
        # 如果运行时有相应的错误日志则捕获日志,赋值到一个变量中
        logger.info(call.excinfo)
        if call.excinfo:
            error_type = call.excinfo.typename
            # error_msg = call.excinfo.value.msg
            error_msg = call.excinfo.value
            error_longrepr = str(out._result.longrepr)

        case_info = {
            "nodeid": report.nodeid,
            "result": report.outcome,
            "type": error_type,
            "msg": error_msg,
            "longrepr": error_longrepr
        }
        fail_case_info.append(case_info)

        error_path = root_path + '/fail_record'

        if not os.path.isdir(error_path):
            os.mkdir(error_path)

        # 用例信息写入 yaml 文件
        with open(error_path + '/fail_cases_info.yaml', 'a', encoding='utf-8') as f:
            yaml.dump(fail_case_info, f, allow_unicode=True)

        logger.error(f'错误类型 =>> {error_type},\n'
                     f'错误信息 =>> {error_msg},\n'
                     f'错误详情 =>> {error_longrepr} \n')

拓展 - 查找元素异常处理

  • 知识点:
    • app 自动化测试 L3-自动化关键数据记录
    • app 自动化测试 L3-app 弹窗异常处理
  • 查找元素异常处理(装饰器实现)
    • 保存截图
    • 保存页面源码
    • 处理弹窗黑名单元素
# base_page.py
class BasePage:

    def screenshot(self, root_path):
        '''
        截图
        :param path: 截图保存路径
        '''
        logger.info(f"没有找到元素,截图")
        # 以当前时间命名的截图
        image_name = Utils.get_current_time() + ".png"
        # 拼接当前要输出图片的路径
        image_dir_path = os.sep.join([root_path, '..', f'images/'])
        if not os.path.isdir(image_dir_path):
            os.mkdir(image_dir_path)
        image_path = image_dir_path + image_name
        logger.info(f"截图路径为:{image_path}")
        self.driver.save_screenshot(image_path)
        return image_path

    def save_page_source(self, root_path):
        '''
        保存页面源码
        :return: 返回源码文件路径
        '''
        logger.info(f"没有找到元素,保存页面源码")
        pagesource_name = Utils.get_current_time() + "_page_source.xml"
        pagesource_dir_path = os.sep.join([root_path, '..', f'page_source/'])
        if not os.path.isdir(pagesource_dir_path):
            os.mkdir(pagesource_dir_path)
        pagesource_path = pagesource_dir_path + pagesource_name
        logger.info(f"page source 路径为:{pagesource_path}")
        with open(pagesource_path, "w", encoding="u8") as f:
            f.write(self.driver.page_source)
        return pagesource_path

# error_handler.py

black_list = [
    (AppiumBy.XPATH, "//*[@text='确定']"),
    (AppiumBy.XPATH, "//*[@text='取消']")
]

# 传入的 fun 相当于 find 方法
def black_wrapper(fun):
    def run(*args, **kwargs):
        # 相当于传入的第一个参数 self
        basepage = args[0]
        try:
            logger.info(f"开始查找元素:{args[2]}")
            return fun(*args, **kwargs)
        except Exception as e:
            logger.warning("未找到元素,处理异常")
            # 遇到异常截图
            # 获取当前工具文件所在的路径
            root_path = os.path.dirname(os.path.abspath(__file__))
            image_path = basepage.screenshot(root_path)
            allure.attach.file(image_path, name="查找元素异常截图", attachment_type=allure.attachment_type.PNG)
            # 保存页面源码
            pagesource_path = basepage.save_page_source(root_path)
            allure.attach.file(pagesource_path, name="page_source", attachment_type=allure.attachment_type.TEXT)

            for b in black_list:
                #  查找黑名单中的每一个元素
                eles = basepage.driver.find_elements(*b)
                if len(eles) > 0:
                    # 点击弹框
                    eles[0].click()
                    # 继续查找元素
                    return fun(*args, **kwargs)
            logger.error(f"遍历黑名单,仍未找到元素,异常信息为 ====> {e}")
            logger.error(f"traceback.format_exc() 信息为 ====> {traceback.format_exc()}")
            raise e
    return run