评论

Minium+云测,实现小程序UI自动化

本文将介绍,如何简单快速使用Minium+云测搭建一套小程序自动化流程,并将测试报告发送到企业微信,不需要深厚的代码功力也能快速上手。

一、需求背景:

[群接龙]小程序有各种针对社群开发的活动,轻量应用,使用简单。以及有针对各类社群开发的社群主页,帮助用户轻松经营好社群。特别在社区/社群团购领域,[群接龙]是最有影响力的产品之一。

目前,群接龙小程序用户数量已经达1.8亿+,并且小程序的功能每周都在迭代更新,为保证用户的体验质量,我们需要在每次发版前,对上传的体验版进行回归,依赖手动回归并不现实,因此我们选择使用Minium+云测,来实现小程序自动化,提高回归效率,保障产品质量。

二、技术方案

使用Python+Minium+云测编写测试用例,并将测试报告发送到企微。

三、环境搭建

环境搭建本文中不做展开,参考 官方文档 即可

四、使用Minium编写测试用例

1.框架介绍

  • base:封装页面公共方法和测试基类的封装
  • common:存放公共方法,如读取脚本
  • data:存放一些测试数据
  • page:生成的页面路径,维护各页面的元素和操作方法
  • script:通过开发者工具录制导出的自动化脚本
  • testcase:测试用例
  • tools:存放工具方法


  • 在base/def中,我们封装了一些minium框架的方法,如页面跳转,点击元素,文本输入方法等,可以根据自己的需求进行封装。
import base64
import os
from pathlib import Path
from time import sleep
import minium
import requests
from minium import Callback



class BaseDef(minium.MiniTest):

    def __init__(self, mini):
        super().__init__()
        self.mini = mini

    def navigate_to_open(self, route):
        """以导航的方式跳转到指定页面,不允许跳转到 tabbar 页面,支持相对路径和绝对路径, 小程序中页面栈最多十层"""
        self.mini.app.navigate_to(route)

    def redirect_to_open(self, route):
        """关闭当前页面,重定向到应用内的某个页面,不允许跳转到 tabbar 页面"""
        self.mini.app.redirect_to(route)

    def relaunch_to_open(self, route):
        """关闭所有页面,打开到应用内的某个页面"""
        self.mini.app.relaunch(route)


    # 判断某个元素是否存在
    def element_is_exists(self, element):
        self.mini.logger.info(f"目前在断言元素{element}")
        bool = self.mini.page.element_is_exists(element)
        try:
            assert bool == True
        except AssertionError:
            self.mini.logger.error(f"断言失败,错误元素{element}")
            raise AssertionError(f"断言失败,错误元素{element}")

    # 文本框输入
    def send_key(self, element, text: str):
        sleep(1)

        try:
            for i in range(10):
                ele = self.mini.page.wait_for(element, max_timeout=3)
                if ele:
                    self.mini.logger.info(f"目前在输入元素{element}")
                    ele_text = self.page.get_element(element)
                    ele_text.input(text)
                    return
                else:
                    self.mini.logger.error(f"找不到该元素{element},无法点击!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
            raise RuntimeError(f"找不到该元素{element},超过等待时间,用例执行失败")
        except AttributeError as e:
            self.mini.logger.error(f"找不到该元素{element},无法输入!!!,,报错原因{e}")
            raise

    def tap(self, element):
        """
        :param element: 要点击的元素
        :return:
        """
        sleep(1)
        try:
            for i in range(10):
                ele = self.mini.page.wait_for(element, max_timeout=3)
                if ele:
                    self.mini.logger.info(f"目前在点击元素{element},点击方式Tap")
                    ele_tap = self.mini.page.get_element(element)
                    ele_tap.tap()
                    sleep(0.7)
                    return
                else:
                    self.mini.logger.error(f"找不到该元素{element},无法点击!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
            raise RuntimeError(f"找不到该元素{element},超过等待时间,用例执行失败")

        except AttributeError as e:
            self.mini.logger.error(f"找不到该元素{element},无法输入!!!,,报错原因{e}")
            raise



  • base/basepage,是测试用例的基类,这里可以封装一些用例执行之前的操作
import minium
from common.del_outputs import del_outputs
from base.basedef import *

class BasePage(minium.MiniTest):
    """测试用例基类"""
    @classmethod
    def tearDownClass(cls):
        pass

    @classmethod
    def setUpClass(cls):
        pass

    def setUp(self):
        pass

    def tearDown(self):
        pass



  • common/read_script,此函数的作用是读取自动化脚本中的元素和页面路径,打印元素和创建页面
import json
import os

#上传到云测的时候,需要把下面两行注释,和注释函数调用
object_path = os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))))
file = os.path.join(object_path,"script/script.json").replace("\\","/")


def get_path(file):
    #用于获取当前目录的根目录路径
    def find_project_root():
        current_dir = os.getcwd()
        while not os.path.exists(os.path.join(current_dir, 'README.md')):
            if current_dir == os.path.dirname(current_dir):
                return None  # 达到文件系统根目录但未找到项目根目录
            current_dir = os.path.dirname(current_dir)
        return current_dir.replace('\\', '/')

    root = find_project_root()


    with open(file,encoding="UTF-8") as f:
        data = json.load(f)

    #读取脚本文件中的commands字段
    for path in data['commands']:
        if 'path' in path:
            #获取path字段的值,用于生成目录
            path_parts = path["path"].split('/')
            current_path = os.path.join(root,"pages")
            existing_directories = set()

            for part in path_parts:
                if part not in ['pro', 'page' , 'homepage']:
                    #更改文件名,python文件名不能用-起名
                    part = str(part).replace("-","_")
                    current_path = os.path.join(current_path, part)
                    #判断文件夹是否存在
                    if part not in existing_directories:
                        os.makedirs(current_path, exist_ok=True)
                        py_file = os.path.join(current_path, f"{part}.py")
                        #判断文件是否存在
                        if os.path.exists(py_file):
                            pass
                        else:
                            open(py_file, 'w').close()
                    existing_directories.add(part)

#获取target元素和文字
def get(file):
    with open(file,encoding="UTF-8") as f:
        data = json.load(f)
    for command in data['commands']:
        if 'target' in command and command["command"] == "tap":
            target = command['target']
            text = command["text"]
            path = command["path"]
            print("按钮名称:"+text,"\n")
            print("所属页面"+path,"\n")
            print("xpath表达式:"+target,"\n")
        elif 'target' in command and command["command"] == "input":
            input = command["target"]
            print("input输入框按钮:"+input,"\n")


get(file)
get_path(file)


  • tools/cloud_test_output.py,用于创建云测测试任务,并将测试报告发送到企微

云测第三方接口文档:云测文档 ,需要将一下代码中的token、group_en_id、test_plan_id、企微机器人路径替换成自己的

import json
import time
import requests
from urllib.parse import urlparse, parse_qs


class MiniTestApi:

    def __init__(self, user_token, group_en_id):
        self.token = user_token  # 需要填写自己的token
        self.group_en_id = group_en_id  # 项目的英文ID
        self.minitest_api = 'https://minitest.weixin.qq.com/thirdapi'

    def third_auto_task(self):
        """
        创建测试任务
        :return:
        """
        config = {
            "assert_capture": True,
            "auto_relaunch": False,
            "auto_authorize": False,
            "audits": False,
            "compile_mode": ""
        }

        data = {
            'token': self.token,
            'group_en_id': self.group_en_id,
            'test_type': 2,
            'platforms': 'ios',
            'wx_id': '',
            # 跑体验版
            'wx_version': 2,
            'desc': 'Minium测试',
#换成云测上的测试计划ID
            'test_plan_id': xx,
            'dev_account_no': 1,
            'minium_config': config,
            #换成测试的账号名称
            "virtual_accounts": "xx"
        }
        resp = requests.post(
            self.minitest_api + '/plan',
            json=data
        )
        resp = resp.json()
        print(resp)
        return resp["data"]["plan_id"]


    def share_url(self, planId):
        """
        分享测试报告
        :param planId:
        :return:
        """
        data = {
            'token': self.token,
            'group_en_id': self.group_en_id,
            'plan_id': planId,
        }
        resp = requests.get(
            self.minitest_api + '/share_url',
            params=data
        )
        resp = resp.json()
        return resp["data"]["share_url"]

    def add_case_plan(self):
        """
        新增测试计划
        :return {'msg': "添加测试计划成功", 'rtn': 0}
        """
        plan_config = {
            "pkg_list": [
                {
                    "case_list": [
                        "test_*"
                    ],
                    "pkg": "testcase.*"
                }
            ]
        }  # 参照文档 https://minitest.weixin.qq.com/#/minium/Python/framework/suite 进行编写

        data = {
            'token': self.token,
            'group_en_id': self.group_en_id,
            'test_plan_name': 'api自定义Minium',  # 测试计划名称
            'test_plan_config': json.dumps(plan_config)
        }

        resp = requests.post(url=self.minitest_api + '/case_plan', json=data)
        print(resp.json())

        return resp.json()

    
    def get_task(self, plan_id):
        """
        获取云测任务的执行结果
        :param plan_id:
        :return:
        """
        data = {
            'token': self.token,
            'group_en_id': self.group_en_id,
            'plan_id': plan_id,
        }
    
        max_retries = 10
        retry_count = 0
    
        while retry_count < max_retries:
            try:
                resp = requests.get(
                    self.minitest_api + '/plan',
                    params=data
                )
                res = resp.json()
    
                status_text = res["data"]["status_text"]
                if status_text == "测试结束":
                    report_url = minitest_client.share_url(plan_id)
                    create_time = res["data"]["create_time"]
                    finish_time = res["data"]["finish_time"]
                    total_case_num = res["data"]["total_case_num"]
                    success_case_num = res["data"]["success_case_num"]
                    minitest_client.sendMessge(report_url, create_time, finish_time, total_case_num, success_case_num)
                    print("测试完成")
                    break
                else:
                    print("还在测试中,等待...")
                    retry_count += 1
                    if retry_count < max_retries:
                        time.sleep(300)
            except Exception as e:
                print("发生异常:", str(e))
                retry_count += 1
                if retry_count < max_retries:
                    time.sleep(300)
                else:
                    raise e
    
        return None
    
        def sendMessge(self, report_url, create_time, finish_time, total_case_num, success_case_num):
            """
            :param passed: 通过的用例数
            :param failed: 失败的用例数
            :param broken: 报错的用例数
            :return:
            """
    
            #替换自己成企微机器人的地址
            Webhook = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=XX"
            headers = {'Content-Type': 'application/json'}
            data = {
                "msgtype": "markdown",
                "markdown": {
                    "content":
                        '''提醒!前端UI自动化测试反馈\n请相关同事注意,及时跟进!\n
                            > 云测报告链接:[minium 测试报告,请点击后进入查看]({})
                            > 用例开始时间:{}\n
                            > 用例结束时间:{}\n
                            > 用例总数:{}\n
                            > 通过用例数:{}\n
                            > 失败用例数:{}\n
                     '''.format(report_url, create_time, finish_time, total_case_num, success_case_num,
                                (total_case_num - success_case_num))
                }
            }
    
            requests.post(url=Webhook, headers=headers, json=data)
    
    
    if __name__ == '__main__':
    
        #填写自己的账号token
        minitest_client = MiniTestApi('XX', 'XX')
        print("开始创建测试任务")
        plan_id = minitest_client.third_auto_task()
        print("查询任务状态")
        minitest_client.get_task(plan_id)


2.通过一个例子学会如何编写自动化用例

我们通过群接龙小程序中,从主页进入商品库中添加商品的用例,来介绍整个用例编写流程。

点击商品库 → 添加商品 → 确认添加

  • 首先通过IDE自带的自动化测试功能,完成小程序用例的录制并导出用例

  • 导出脚本后,获得一个json文件,将json文件替换掉目录script/script.json的文件,执行common/read_script.py,生成目录和获取元素

运行完后,我们可以看到控制台打印了我们脚本中的元素XPATH定位和生成了页面路径和py文件,接下来我们只需要去生成的py中,创建一个页面的类,并在类中维护页面元素和操作方法即可。

这里我们将使用xpath作为元素的定位方式:

优点:脚本直接导出生成,定位精准,免去调试元素的时间,不懂定位方法也可以编写用例,只要懂复制粘贴就行。

缺点:只要页面元素改动,就需要重新进行元素维护

因为编写自动化的页面一般是不常改动的页面,所以我们选择用xpath,就算改动了,重新再录一遍导出就行,也可以配合其他选择器定位灵活使用,根据自己的需求来。

编写页面类,每个页面只做当前页面的操作

主页:

from base.basedef import BaseDef
from data import page_data

class GroupHomepage(BaseDef):

    produnct_warehouse = "//*[text()='商品库']"

   def click_produnct_warehouse(self):
     self.tap(self.produnct_warehouse)


商品库页:

from base.basedef import BaseDef

class ProductWarehouse(BaseDef):

    addGoods = "text.little-font.flexShrink.ml12"

    def click_add_goods(self):
        self.tap(self.addGoods)


商品创建页:

from base.basedef import BaseDef

class ProductSetting(BaseDef):
    goods_name = "/page-wrapper/view/view/view/view[1]/info-form/view/view[1]/view/view/view/input"
    goods_multi = "/page-wrapper/view/view/view/view[3]/info-form/view/view[1]/view/view[2]/view/input"
    goods_price = "/page-wrapper/view/view/view/view[3]/info-form/view/view[2]/view/view[2]/view/input "
    confirm = "/page-wrapper/view/view/view/footer-button/view[2]/view/view"

    def add_goods(self):
        self.send_key(self.goods_name,"我是单规格商品")
        self.send_key(self.goods_multi ,"我是规格")
        self.send_key(self.goods_price,"10")
        self.tap(self.confirm)
        


编写测试用例,测试用例需要test_xx开头,不然读取不到用例:

from base.basepage import BasePage
from pages.pages.group_homepage.group_homepage import GroupHomepage
from pages.pages.seq_manage_options.product_setting.product_setting import ProductSetting
from pages.pages.seq_manage_options.product_warehouse.product_warehouse import ProductWarehouse


class TestAddGoods(BasePage):
    def __init__(self, methodName='runTest'):
        super().__init__(methodName)
        self.GroupHomepage = GroupHomepage(self)
        self.ProductWarehouse = ProductWarehouse(self)
        self.ProductSetting = ProductSetting(self)
    
    """添加货源单规格商品"""
    def test_add_goods_single(self):
        self.GroupHomepage.click_produnct_warehouse()
        self.ProductWarehouse.click_add_goods()
        self.ProductSetting.add_goods()


到此,我们就完成了一个自动化用例的编写,是不是很简单!


五、上传Minium用例到云测,创建云测测试计划,并发生测试报告到企微

云测使用我们不做过多展开,可以参考官方文档使用

上传用例完成后,我们可以通过手动在云测上执行测试任务,或者创建云测定时任务来执行minium用例

我们小程序是在打包完成后调用tools/cloud_test_output.py,来创建测试任务并发生测试报告,也可以将cloud_test_output.py放入Jenkins中进行持续集成,这个根据自己的场景来使用,展示一下最终效果:


最后感谢@kennyyan和@冯永鹏两位微信小伙伴的耐心指导,完结撒花❀



最后一次编辑于  2023-09-13  
点赞 4
收藏
评论

3 个评论

登录 后发表内容