Skip to content

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


课程目标

  • 熟悉 Appium 框架与常用操作
  • 掌握 App 自动化测试用例编写能力
  • 掌握 App 自动化测试实战能力

知识点总览


需求说明

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

测试需求

  • 企业微信 App 添加成员功能自动化测试。
  • 完成 App 自动化测试框架搭建。
  • 在自动化测试框架中编写自动化测试用例。
  • 优化测试框架。
  • 输出测试报告。

实战思路

uml diagram


环境安装与配置

  • 安装 JDK
  • 搭建 Android 环境
  • 安装 Appium Server
  • 安装 Appium Inspector
  • 安装 Appium 的 python 客户端
  • 准备 Android 移动设备(模拟器或者真机)

梳理业务流程

uml diagram


测试用例设计

测试模块 用例标题 前置条件 用例步骤 预期结果 实际结果
成员模块 添加成员-成功 登录成功 1. 点击通讯录
2.点击添加成员按钮
3. 点击手动输入添加按钮
4. 输入姓名,手机号,点击保存按钮
1. 进入通讯录页面
2. 进入添加成员页面
3. 进入输入成员信息页面
4. 成功添加学员,并给出【添加成功】的提示信息。
成员模块 添加成员-手机号重复,添加失败 登录成功 1. 点击通讯录
2.点击添加成员按钮
3. 点击手动输入添加按钮
4. 输入已经存在的手机号,点击保存
1. 进入通讯录页面
2. 进入添加成员页面
3. 进入输入成员信息页面
4. 提示【手机已存在于通讯录,无法添加】,添加成员失败。

编写添加联系人自动化测试用例

# test_wework_contact.py

class TestWeworkContact:

    def setup_class(self):
        faker = Faker("zh_CN")
        self.name = faker.name()
        self.phonenum = faker.phone_number()

    def setup_method(self):
        # Capability 设置定义为字典
        caps = {}
        # 设置 app 安装的平台(Android、iOS)
        caps["platformName"] = "Android"
        # 设置 appium 驱动
        caps["appium:automationName"] = "uiautomator2"
        # 设备的名字,执行 adb devices 列表中的名称
        caps["appium:deviceName"] = "emulator-5554"
        # 设置 app 的包名
        caps["appium:appPackage"] = "com.tencent.wework"
        # 设置 app 启动页
        caps["appium:appActivity"] = ".launch.LaunchSplashActivity"
        # 不清空缓存
        caps["appium:noReset"] = True
        # 强制app重启
        caps["appium:forceAppLaunch"] = True
        # 加载 capability
        options = AppiumOptions().load_capabilities(caps)
        # 初始化 driver
        self.driver = webdriver.Remote(
            "http://127.0.0.1:4723",
            options=options
        )
        # 设置全局的隐式等待
        self.driver.implicitly_wait(10)

    def teardown_method(self):
        # 关闭 driver
        self.driver.quit()

    def test_add_contact(self):
        '''
        添加联系人
        1。 点击通讯录,进入通讯录页面
        2。 点击添加成员按钮,进入添加成员页面
        3。 点击手动输入添加按钮,输入成员信息页面
        5。 输入姓名,手机号,点击保存按钮
        6。 返回添加成员页面
        '''
        # 点击通讯录按钮
        self.driver.find_element(
            AppiumBy.XPATH,
            "//*[@text='通讯录']").click()
        # 滑动点击添加成员按钮
        self.swipe_find("添加成员").click()
        # 点击手动输入添加按钮
        self.driver.find_element(
            AppiumBy.XPATH,
            "//*[@text='手动输入添加']").click()
        # 定位姓名输入框
        self.driver.find_element(
            AppiumBy.XPATH,
            "//*[contains(@text, '姓名')]/../*[@text='必填']"
        ).send_keys(self.name)
        # 定位企业邮箱输入框
        self.driver.find_element(
            AppiumBy.XPATH,
            "//*[@text='企业邮箱']/..//*[@text='必填']"
        ).send_keys(self.phonenum)
        # 定位手机号输入框
        self.driver.find_element(
            AppiumBy.XPATH, "//*[@text='手机']/..//*[@text='选填']"
        ).send_keys(self.phonenum)
        # 滑动屏幕
        self.swipe_find("保存")
        # 点击保存按钮
        self.driver.find_element(
            AppiumBy.XPATH,
            "//*[@text='保存']").click()
        # 获取 toast 文本
        tips = self.driver.find_element(
            AppiumBy.XPATH,
            "//*[@class='android.widget.Toast']").text
        assert tips == "添加成功"

    def swipe_window(self):
        '''
        滑动界面
        '''
        # 滑动操作
        # 获取设备的尺寸
        size = self.driver.get_window_size()
        # {"width": xx, "height": xx}
        print(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
        # 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.driver.implicitly_wait(1)
        for num in range(max_num):
            try:
                # 正常通过文本查找元素
                ele = self.driver.find_element(AppiumBy.XPATH, f"//*[@text='{text}']")
                print("找到元素")
                # 能找到则把隐式等待恢复原来的时间
                self.driver.implicitly_wait(15)
                # 返回找到的元素对象
                return ele
            except Exception:
                # 当查找元素发生异常时
                print(f"没有找到元素,开始滑动")
                print(f"滑动第{num + 1}次")
                # 滑动操作
                self.swipe_window()
        # 把隐式等待恢复原来的时间
        self.driver.implicitly_wait(15)
        # 抛出找不到元素的异常
        raise NoSuchElementException(f"滑动之后,未找到 {text} 元素")

使用 PO 模式封装测试框架

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

搭建空架子

# 代码详见仓库

填充测试框架

  1. app 启动。
  2. BasePage 封装。
  3. 封装元素为私有属性。
  4. 封装测试数据记录方法。

app 启动

class WeworkApp:

    def start(self):
        '''
        启动 app
        :return:
        '''
        # Capability 设置定义为字典
        caps = {}
        # 设置 app 安装的平台(Android、iOS)
        caps["platformName"] = "Android"
        # 设置 appium 驱动
        caps["appium:automationName"] = "uiautomator2"
        # 设备的名字,执行 adb devices 列表中的名称
        caps["appium:deviceName"] = "emulator-5554"
        # 设置 app 的包名
        caps["appium:appPackage"] = "com.tencent.wework"
        # 设置 app 启动页
        caps["appium:appActivity"] = ".launch.LaunchSplashActivity"
        # 不清空缓存
        caps["appium:noReset"] = True
        # 强制app重启
        caps["appium:forceAppLaunch"] = True
        # 加载 capability
        options = AppiumOptions().load_capabilities(caps)
        # 初始化 driver
        self.driver = webdriver.Remote(
            "http://127.0.0.1:4723",
            options=options
        )
        # 设置全局的隐式等待
        self.driver.implicitly_wait(10)
        return self

    def stop(self):
        '''
        停止 app
        :return:
        '''
        self.driver.quit()

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()

封装元素为私有属性

把元素定位表达式也拆分出来,定义为私有属性。满足 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)

封装测试数据记录方法

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)

优化测试框架 - 数据驱动

准备测试数据

# datas/members_info.yaml
member_info:
  - - 陈俊
    - "15691203895"
  - - 胡桂芳
    - "14590228232"
  - - 陈玉
    - "13984932909"

tests 下创建 conftest.py 获取项目路径,并完成项目路径添加到环境变量中的操作。

import os
import sys
from frame.utils.log_util import logger

# 添加前项目路径到环境变量
root_path = os.path.dirname(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/utils.py

class Utils:

    ...

    @classmethod
    def get_current_time(cls):
        """
        获取当前的日期与时间
        :return:
        """
        return time.strftime("%Y-%m-%d-%H-%M-%S")

    @classmethod
    def save_source_datas(cls, 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
# base/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

# base/error_handle.py

import allure
from appium.webdriver.common.appiumby import AppiumBy
from frame.utils.log_util import logger

# 弹窗黑名单
black_list = [
    (AppiumBy.XPATH, "//*[@text='确定']"),
    (AppiumBy.XPATH, "//*[@text='取消']")
]


# 传入的 fun 相当于 find(self, by, value): 方法
def black_wrapper(fun):
    def run(*args, **kwargs):
        # basepage 相当于传入的第一个参数 self,可以调用 base_page 中的方法
        basepage = args[0]
        try:
            # 打印传入方法的第 3 个参数
            logger.info(f"开始查找元素:{args[2]}")
            # 调用被装饰的方法
            result = fun(*args, **kwargs)
            # 返回被装饰的方法可能的返回值
            return result
        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:
                    # 点击弹框
                    basepage.driver.find_elements(*b)[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

总结

  • Appium 环境搭建
  • App 自动化测试用例编写
  • PO 设计模式搭建 App 自动化测试框架