Appium Appium UI 自动化实践总结

哔哩哔哩温 · 2021年09月29日 · 最后由 direction 回复于 2023年11月24日 · 7914 次阅读

UI 自动化实践总结

为什么要做 UI 自动化?

说到 UI 自动化,有些人肯定会想,我们项目已有在接口自动化,为什么还要做 UI 自动化?

  • 我们先看看 UI 自动化中 UI 的含义:UI 即 User Interface(用户界面)的简称, UI 自动化测试做的事情就是模拟用户行为进行操作,还原用户使用场景,UI 自动化能够帮助我们确保线上不出现 P0 级别的问题,比如登录不成功,页面打不开等等。这是接口自动化无法比拟的, 也体现了 UI 自动化的价值。😍
  • 每更新一个迭代版本,在有接口自动化的基础上,我们还是需要手工对历史功能进行回归,随着功能的迭代,手工回归成本逐渐增高;此时如果可以使用UI 自动化去代替人工进行回归,就可以降低人力回归成本;回归的次数越多,UI 自动化的价值就越高。☀ ☀
  • 当客户端开发升级一个公共组件时,我们要进行全量回归及兼容性测试,接口自动化就显得有些 “爱莫能助”,此时就体现了 UI 自动化的重要性,在业务没有改动的情况下,我们可以通过 UI 自动化进行回归测试和兼容性测试😊

做 UI 自动化面临哪些问题?

提到 UI 自动化,大家就会提到:

  • UI 自动化维护成本高,ROI 低
    • 做过 UI 自动化的同学肯定都会遇到这个问题,辛辛苦苦写好的测试用例,跑了还没几天,新的需求之后,开发把页面改了,原来定位的控件失效了,N 条测试用例跑不通了。每条用例改完之后,UI 又变了,又得改,很奔溃有没有。回想在货运项目中,一个新增车辆的 UI 就改了 4、5 个版本;😂 😂 😂 😂
  • UI 自动化稳定性差,这次执行成功了,下次就失败了,排查问题还耗费时间
    • 测试用例本地调试的时候是通的,怎么批量执行的时候就失败了。。。
    • 这个用例上次跑通了,怎么这次又失败了。。。
    • app 界面上明明有这个元素,怎么又定位不到了 😠 😠 😠

下面我们通过讲如何做 UI 自动化来解决这 2 个问题。

怎么做 UI 自动化?

前面提到的 2 个问题相比接口自动化来说确实是存在的,我们需要做的是尽可能提高我们的 ROI 和稳定性

如何提高 ROI?

我们先看下 UI 自动化收益及成本的计算公式:

自动化收益 = 有效迭代次数 x 手工测试成本

自动化成本 = 脚本创建成本 + 维护次数 x 维护调试成本 + 脚本失败次数 x 脚本排错成本

  • 开发 UI 用例之前,我们根据上诉公式评估 ROI,评估自动化成本是否小于手工测试成本;当我们知道产品已经有了对该 UI 改动的计划,那我们暂时可以不需要对此功能进行开发,在项目初期,这种情况经常发生,所以选取相对稳定的功能进行 UI 自动化用例开发是提高 ROI 的最有效手段;
  • 针对 APP 来说,我们可以先选取验证码登录、密码登录、修改密码、修改个人信息这种基础功能,进行 UI 自动化覆盖。其次,在核心业务中,选取相对稳定的功能进行 UI 自动化覆盖;
  • 在 APP 的迭代过程中我们无法避免 UI 上的改动造成的自动化维护成本的提高,我们需要做的是优化 UI 自动化框架,在 UI 发生变动后,可以快速的在历史用例的基础上修改成新的用例;(后文会介绍用例设计技巧)
  • 在 UI 自动化进行一段时间后,进行成本分析,分析出最耗时的方面,再针对业务特性进行优化。

如何提高自动化稳定性?

在项目的迭代过程中,我对用例执行失败的具体原因进行了分析,总结出以下几点原因:

  • 页面切换/控件加载场景下,元素定位太快,导致点击失效;
    • 大家都以为 UI 自动化只会因为执行慢找不到元素,没想到有一天我竟然遇到了,因为页面切换太快,元素还没加载到指定位置 appium 就发现了它并且点击,结果当然会导致用例失败了 😭
  • 不同网络情况下,接口返回时间不一致,客户端一直 loading,导致元素存在但无法点击;看到这个 loading 我就感到很懊恼,想把开发打一顿!!😤
  • 系统通知影响元素定位,导致元素无法点击;
    • 直接关闭系统通知是最简单的办法,关闭后再没有因为系统通知导致用例失败
  • 测试数据、前置数据存在问题,导致用例失败;
    • 突然有一天登录用例失败了,吓得我赶紧去看下怎么登录还失败了,最后发现我的测试账号竟然被开发用了,打他们 😤
    • 自动化的账号一般要单独维护,并且最好备注下,以免被其他人用了。
  • 上一条用例失败了,停留在当前页面,导致下一条用例也执行失败了;
  • 图像识别不稳定,这台设备识别成功了,那台设备又失败了(在货运项目中,我并没有采用图像识别,因为在同频项目实践过程中发现,图像识别并不稳定,我们应该尽量减少图像识别的使用

针对以上几点问题,我们测试框架和用例设计上进行合理优化后自动化的稳定性自然而然会提高,重点是我们需要在 UI 自动化前期阶段,对具体失败的原因进行分析,并且做出相应调整的动作,这样稳定性就会持续提高。

UI 自动化框架设计要点

不管我们使用的是 Airtest 还是 Appium,在框架设计理念上是无区别的,当前框架包含以下几个特点:

  • 对 appium/webdriver 底层操作进行封装;提高用例执行稳定性;
    • 元素获取失败 or 断言失败后,需要进行 app 重启操作,避免影响其他用例执行;
  • 采用 Page Object 设计模式,对页面元素、通用元素、通用操作进行封装,减少代码冗余、将业务和实现分离、 降低代码维护成本;
    • 对通用元素的封装,在用例的维护过程中起着很关键的作用;试想当开发修改了一个返回元素后,你需要在 N 个用例中一个一个修改、调试是多个痛苦,所以元素的封装是很有必要的,当一个元素修改后,我们不需要修改用例,只需要更改此元素的封装方法即可。
  • 对测试数据进行单独维护,降低后续维护成本;
    • 有一天你发现你的 UI 用例失败了,原因是你登录的账号信息,被其他人修改了,你发现你的这个账号,在 N 个用例中都有使用,瞬间崩溃;如果在最开始设计的时候你的测试数据是单独维护的就不存在此问题,你只需修改测试数据里面的账号即可。
  • 结合接口,实现部分功能,简化 UI 自动化流程;
    • 在一些情况下可以利用接口,来简化测试流程;比如我们想设计装货用例, 如果从接单开始设计用例,提高用例执行时长不说,还极有可能因为接单失败,导致未验证到装货功能;此时我们可以直接通过接口实现,创建运单,直接进行装货功能的验证,简化 UI 执行流程。
  • 完整的测试报告,包含用例步骤、日志、失败截图、操作录屏,快速排查定位失败原因;
    • UI 用例的失败原因有很多,最开始 UI 自动化报告只包含失败截图,无法判断具体失败原因,增加录屏后,我们可以快速定位失败原因。 测试报告
  • 增加元素自动解析模块;
    • 通过成本分析得出 UI 自动化最耗费时间的是一个一个元素进行定位、所以增加元素自动解析模块,可以提高我们的开发效率 ; 自动解析元素
  • 直接通过 pycharm 进行用例调试;快速的脚本调试 ,也是减少自动化维护成本的一方面。

appium 封装的部分代码

import traceback

import allure
from appium.webdriver.extensions.applications import Applications
from common_utils.new_log import NewLog
from selenium.webdriver.support.wait import WebDriverWait

from config.project_common_config import ProjectConfig
from airtest.core.api import *

from ui_common_utils.wrapper_utils import wrapper_logger


class AppiumElementUtils:

    log = NewLog(__name__)
    logger = log.get_log()


    @classmethod
    @wrapper_logger
    def select_locate_method(cls, driver, method_type, element_info):
        """目前元素的几种定位方式"""
        if method_type == "id":
            return driver.find_element_by_id(element_info)
        elif method_type == "accessibility_id":
            return driver.find_element_by_accessibility_id(element_info)
        elif method_type == "xpath":
            return driver.find_element_by_xpath(element_info)
        elif method_type == "ios_predicate":
            return driver.find_element_by_ios_predicate(element_info)
        elif method_type == "ios_class_chain":
            return driver.find_element_by_ios_class_chain(element_info)
        elif method_type == "android_uiautomator":
            return driver.find_elements_by_android_uiautomator(element_info)
        elif method_type == "class_name":
            return driver.find_element_by_class_name(element_info)

    @classmethod
    @wrapper_logger
    def text(cls, text_concent):
        """
        由于flutter-app安卓设备上,无法通过appium-send_keys进行文本输入
        采用airtest中的text,输入文本
        """
        text(text_concent)

    @classmethod
    @wrapper_logger
    def check_element_status(cls, driver, element_info, method_type, wait_time):
        """判断元素状态,存在则返回元素,不存在则返回False"""
        try:
            element = WebDriverWait(driver, wait_time, 0.5).until(
                lambda x: cls.select_locate_method(x, method_type, element_info))
            cls.logger.info("找到元素【%s】" % element_info)
            return element
        except Exception:
            err_msg = "未找到%s元素" % element_info
            cls.save_screenshot(err_msg)
            cls.logger.error("未找到%s元素", element_info, exc_info=1)
            cls.logger.error("错误信息:\n%s" % traceback.format_exc())
            return False

    @classmethod
    @wrapper_logger
    def get_element(cls, driver, element_info, method_type="xpath", wait_time=4, is_raise=True):
        """
        eg:
        get_element(driver, "密码登录", "accessibility_id")
        get_element(driver, "//android.widget.ImageView[5], "xpath")
        """
        # 元素与元素点击之间间隔300ms,解决页面切换,元素点击失败的场景
        time.sleep(0.3)
        result = cls.check_element_status(driver, element_info, method_type, wait_time)
        if result:
            return result
        else:
            if is_raise:
                cls.logger.error("未获取到元素,重启app")
                cls.restart_and_check_up_app(driver)
                raise Exception(("获取元素异常, element_info: %s" % element_info))
            else:
                cls.logger.info("获取元素异常,不重启app, element_info: %s" % element_info)
                return None

    @classmethod
    @wrapper_logger
    @allure.step("断言元素存在")
    def assert_element_exists(cls, driver, element, wait_time=3, is_restart=True):
        for i in range(wait_time):
            page_source = driver.page_source
            if element in page_source:
                cls.logger.info("断言成功,找到[%s]元素" % element)
                return True
            else:
                time.sleep(1)
        cls.save_screenshot("断言失败,未找到[%s]元素" % element)
        if is_restart:
            cls.logger.info("断言失败,未找到[%s]元素, 重启app" % element)
            cls.restart_and_check_up_app(driver)
        return False

    @classmethod
    @wrapper_logger
    def restart_and_check_up_app(cls, driver):
        """重启app, 根据项目判断进行重启后的操作"""
        cls.restart_app(driver)
        if ProjectConfig.project_name == "ytt":
            from project_pages.ytt_actions.common_action import check_up_ytt_app
            check_up_ytt_app(driver)
        else:
            pass

    @classmethod
    @wrapper_logger
    def restart_app(cls, driver):
        """重启app, 根据项目判断进行重启后的操作"""
        Applications.close_app(driver)
        Applications.launch_app(driver)

UI 自动化用例设计要点

  • 元素与用例分离;
  • 尽可能封装通用操作;
    • 如:登录、退出、返回、拍照、相册选择这些基础功能,这些高频操作均可进行封装;
  • 保证用例的独立性,用例与用例之间不要有关联关系,这样不会因为 A 用例失败,引起 B 用例也失败,这样能提升用例的稳定性;
    • 如:所有用例执行后都返回首页,失败用例自动重启也返回首页;用例都从首页开始设计
  • 前置操作 or 后置操作能不用 UI 操作尽量不用 UI 操作;可以通过接口 or 操作数据库实现来简化执行流程;
  • 脚本中尽量不使用坐标和图像识别;
    • 除非实在没有办法的情况下,我建议用坐标比图像识别好一些,根据不同设备标记不同坐标,往往只是一次性的开发,但是图像识别,总是存在失败的现象,排查失败原因,也同样增加了 UI 的成本;
  • UI 自动化是为了回归测试,而不是发现 bug,不要过多的去验证 UI 的正确性,这样会降低自动化的稳定性,也就降低了 UI 自动化的 ROI。
    • 登录功能,我们只要验证点击登录进入首页,就说明登录成功了。
    • 新增车辆功能,在新增车辆后,验证列表已存在新增的车牌号及车辆信息即可;

登录用例参考

元素封装模块
class LoginPage:
    """登录页面"""
    @classmethod
    @wrapper_logger
    @allure.step("勾选用户协议")
    def click_user_agreement(cls, driver):
        if driver.capabilities["platformName"] == "Android":
            xpath = '//android.view.View[@content-desc="欢迎登录"]/following-sibling::android.view.View[1]'
        else:
            xpath = '//XCUIElementTypeStaticText[@name="欢迎登录"]/following-sibling::XCUIElementTypeOther[1]'
        aeu.get_element(driver, xpath, "xpath").click()

    @classmethod
    @wrapper_logger
    @allure.step("点击输入手机号")
    def click_and_input_phone(cls, driver, android_phone, ios_phone, is_raise=True):
        if driver.capabilities["platformName"] == "Android":
            xpath = '//*[@text="请输入手机号"]'
            aeu.get_element(driver, xpath, "xpath", is_raise=is_raise).click()
            aeu.text(android_phone)
        else:
            element = 'label == "请输入手机号"'
            aeu.get_element(driver, element, "ios_predicate", is_raise=is_raise).send_keys(ios_phone)

    @classmethod
    @wrapper_logger
    @allure.step("点击获取验证码按钮")
    def click_get_verification_code(cls, driver, is_raise=True):
        aeu.get_element(driver, "获取手机验证码", "accessibility_id", is_raise=is_raise).click()
登录操作封装
@wrapper_logger
def login(driver, android_phone, ios_phone, first=False):
    """
    存在一键登录,则点击切换成验证码登录
    点击安卓同意按钮
    点击手机号输入框
    输入手机号
    点击获取验证码
    输入验证码
    """
    if first:
        lp.click_agree_btn(driver)
    exists_once_login(driver)
    # 点击升级按钮
    lp.click_upgrade_remind_btn(driver)

    assert aeu.assert_element_exists(driver, "获取手机验证码", wait_time=3)
    lp.click_user_agreement(driver)
    lp.click_and_input_phone(driver, android_phone, ios_phone)
    lp.click_get_verification_code(driver)
    assert aeu.assert_element_exists(driver, "输入手机验证码")
    lp.input_verification_code()
    assert aeu.assert_not_element_exists(driver, "输入手机验证码")
用例模块
@allure.feature("验证码登录")
class TestLogin:
    log = NewLog(__name__)
    logger = log.get_log()

    @allure.story('已注册司机账号登录')
    @allure.description("已设置密码,已注册司机账号登录")
    @allure.severity(CommonConfig.Blocker)
    @pytest.mark.run(order=ProjectConfig.case_order.get("test_login_1"))
    @pytest.mark.v110
    @pytest.mark.new
    # 通过pytest进行单个用例调试时,打开下面注释
    # @pytest.mark.parametrize("devices", aiu.get_devices_info(devices_type="android"))
    def test_login_1(self, devices, driver):
        # 验证码登录账号
        android_phone = login_params["test"]["android_phone"]
        ios_phone = login_params["test"]["ios_phone"]
        # 登录权限处理
        CommonElements.click_allow_locate(driver)
        LoginPage.click_agree_btn(driver)
        # app环境切换
        switch_app_env_rc(driver)
        # 登陆方法封装
        login(driver, android_phone=android_phone, ios_phone=ios_phone)
        # 登录成功校验
        assert PasswordLoginPage.assert_login_success(driver)

下一篇介绍当前项目中使用的 UI 自动化框架

共收到 34 条回复 时间 点赞

讲的不错,其中关于 UI 和接口的结合使用,我最近也在思考。感觉用好了应该很有作用。
关于图像识别的部分,我提个建议,跟元素封装类似,把图片也封装。然后跟项目组使用的图片同名,通过 git 同步更新。意思就是说美术上传了图片后,执行机更新 git 时,顺便把 UI 自动化使用的图片也同步维护了。

水山 回复

图像识别主要用在无法定位的元素上面吗?现在项目中 99% 的元素都可以定位,所以就没在用图像识别,之前在原生 +h5 混合 app 中有使用过 airtest 的图像识别,感觉很不稳定

游戏这种非 android 原生控件,需要用到图像识别

水山 回复

原来如此,还没接触过游戏测试,很厉害的样子!!

代码规范有待改进!

Jay_ 回复

具体指哪一方面呢😀 还请不吝赐教

此处所言的图像识别, 是基于 opencv 做的对吧. 严谨点说这应该叫图像匹配. 缺点就是分辨率会影响匹配的准确性. 尤其是在复杂页面并且兼容多手机的需求下.

chend 回复

是的,图像匹配,分辨率对匹配的准确性影响还是挺大的

“上一条用例失败了,停留在当前页面,导致下一条用例也执行失败了”,深有感触

Mango 回复

😂 看来都是踩过坑的苦命人

好奇你的 wrapper_logger 装饰器做了啥,看能不能借鉴下

Mango 回复

这种情况建议写一个异常处理返回应用主界面,所有用例都从应用主界面开始就会好很多,把步骤解耦出来

无迹 回复
def wrapper_logger(func):
    @wraps(func)
    def wrapper(*args, **kw):
        start_time = time.time()
        logger.info("== method [%s] input args: [%s]====" % (func.__name__, args[1:]))
        result = func(*args, **kw)
        logger.info("== [%s] run time is %.3f ms =====" % (func.__name__, (time.time() - start_time)*1000))
        logger.info("== method [%s] response: [%s] ====" % (func.__name__, result))
        return result
    return wrapper

😂
比较简单,只是记录函数名和函数执行时间和函数返回值😂 日志有点乱,还可以优化

看了楼主的关于 appUI 自动化文章受益匪浅,能否加个 wx 深入交流呢

Morii 回复

我加你吧,你发一下微信号

理论思考不错

Morii_re

请问下 App 的录屏是怎么做的呢?能否提供一下录屏的源码

元素自动解析模块?是指自动解析页面元素定位吗?

报告很好看,请问是什么插件?能分享下代码吗?

洒洒 回复

allure 插件

🔥🔥🔥 回复

appium 自带功能

无人机 回复

是的

请问失败用例自动重启也返回首页是怎么实现的呢?

正在请问是如何元素自动解析的呢?看到楼主的文章感觉很多都是目前的困惑点,可否加个 wx 交流下呢?

Liushasha 回复

再用例里面做断言

封装的断言函数里面增加 is_restart 字段,默认重启

重启使用 appium 自带的函数重启 app

重启之后,需要对 app 状态做一些判断,比如是否处于登录页面,一些权限弹窗,等

Jinyan 回复

简单的说就是,获取到 appium 的页面树,之后通过你手动定位元素过程中的总结的规律,写成通用的函数,去自动解析元素树,最后生成元素代码。跟 app 有强相关不通用。有一些局限性,主要能解决一些简单元素的封装,能节省点时间,完全自动有些苦难

自动获取元素的,我通过 yaml 文件实现了 但是现在遇到了新问题,就是用 xpath 定位不准,经常定位不到元素 请问有什么解决方案不

Jinyan 回复

xpath 是自动解析的吗?我一般 xpath 都使用相对定位,自动解析 xpath 的时候也是用相对定位的方式,想保证稳定性,一些元素还是需要手动定位,通过一些不变的元素,找到相对的 xpath,才能提高稳定性。

31楼 已删除

xpath 是通过手动获取的和写的相对路径 第一次可以跑通过第二次可能就不行了 不通过的时候又看了 xpath 发现原来那个 但是不知道为什么就找不到了 不知道是不是和很多页面都是 flutter 写的关系

Jinyan 回复

你可以在执行的时候增加录屏,还可以在元素定位失败的时候增加截图,这样好排查问题。
appium 里面的 start_recording_screen 和 stop_recording_screen ;每次执行用例前录屏,用例结束后结束录屏,再将录屏文件插入到 allure 里面

@pytest.fixture
def driver(cmdopt, devices):
global base_driver
global env
# 执行 runner 脚本,多条用例执行时,无需每条用例都初始化 driver,如果 driver 存在则直接使用,并且初始化录屏
if base_driver and not devices:
base_driver.start_recording_screen()
yield base_driver
time.sleep(1)
record_base64 = base_driver.stop_recording_screen()
save_record_file(record_base64, base_driver)

def save_record_file(base64_data, driver):
# 将 mp4 录屏文件存储在 static 目录,已当前时间命名
file_path, file, zip_file, file_name, output_name = get_mp4_file_info(driver)
# base64 转视频文件
logger.info("======保存录屏文件=======")
base64_to_mp4(base64_data, file)
# 压缩视频文件
ZipPictureOrVideo(file_path, file_name, output_name).compress_video()
# 视频文件添加至测试报告
logger.info("======添加视频至 allure 报告=======")
with open(zip_file, mode='rb') as f:
file = f.read()
allure.attach(file, "录屏文件", allure.attachment_type.MP4)

因为是 flutter 编码的 所以很多元素就找不到的😭 现在改为文字或者图片识别获取到坐标再点击了

最近也是在搞自动化,看到博主实现的方式好像和我这边的差不多,不过我的对异常处理和断言方面还不知道怎么做才是最好的,希望和你探讨一下,能否加个 wx 交流一下

需要 登录 後方可回應,如果你還沒有帳號按這裡 注册