评论

微信小程序UI自动化实践:python+minium+PO模式

微信小程序UI自动化实践:python+minium+PO模式

@TOC


前言

公司要求做小程序的自动化,最开始让我用jest+SDK做,但后面学了两天,困难重重,第一,这种方式只支持JS语言,我以前从来没学过JS语言,JS一窍不通;第二,网上资料太少太少了,本来对JS就不懂,网上资料还少,后面去网上百度了下,minium支持python语言,我原来有一点python基础,所以最后改用了python+minium框架。


一、minium介绍

minium提供一个基于unittest封装好的测试框架,MiniTest是minium中继承自unittest.TestCase的测试基类, 你可以在testcase中使用框架实例化好的Minium/App/Native实例,也可以使用unittest中的各种断言函数,并做了以下改动:
1、加载读取测试配置
2、在合适的时机初始化minium.Minium、minium.App和minium.Native
3、根据配置打开IDE,拉起小程序项目或自动打开真机调试
4、拦截assert调用,记录检验结果
5、记录运行时数据和截图,用于测试报告生成


二、安装环境

1. 安装minium doc

(这个主要是minium框架的一些介绍,可以略过不安装直接去官网查看文档:https://minitest.weixin.qq.com/#/minium/Python/readme)
(1)该文档使用 docsify 框架,需先安装 docsify

	npm i docsify-cli -g

(2)下载文档(会要求填写账号密码,指的是微信代码管理的账号和密码)

	git clone https://git.weixin.qq.com/minitest/minium-doc

(3)安装依赖

	cd minium-doc
	npm install

(4)本地部署(浏览器能浏览http://localhost:3000/说明文档安装好了)

	docsify serve .


2. 安装minium

(1)运行环境
Python 3.8及以上
微信开发者工具安全模式: 设置 -> 安全设置 -> 服务端口: 打开
确认微信公共库版本 >= 2.7.3
(2)安装
下载minium安装包, 解压后进入文件夹, 运行

	python3 setup.py install

3. 启动小程序

import minium


class TestMiniprogram(minium.MiniTest):

    def test_01(self):
        pass

命令行运行脚本

minitest -c config.json -m tests.igtest

三、准备知识

1. 启动

minium.MiniTest类里面已经封装好了小程序的启动、关闭、调用配置、执行测试用例等一系列的方法,所以我们编写测试用例脚本的时候,定义的类在继承minium.MiniTest类之后,可以直接写测试用例,无需关注怎么启动。

2. 配置

minium框架里面默认配置的项目路径以及CLI工具路径都为None,所以会加载默认配置,如果我们的项目路径以及CLI工具路径不是用的默认路径,执行会报错找不到路径,所以我们需要在项目路径下新建一个config.json文件,将里面的project_path改为你的小程序项目路径,dev_tool_path改为你的CLI工具路径

3. 命令行运行

相关字段的说明

minitest -c config.json -m tests.igtest -g

-c 指定配置文件
-m 指定要执行的用例文件名(注意不需要.py)
-g 生成测试报告

4. 元素定位


(1)单选择器定位:一般可以使用.class或者#id去定位到元素
(2)多选择器定位:如果元素class有重名,id也有相同的,可以使用.class+#id去定位
(3)组合定位:如果有多个元素的class相同,id又是变化的,可以使用page.get_element(’.main-menu-txt’, inner_text=’租赁合同’, text_contains=‘租赁合同’),或者使用page.get_elements(’.main-menu-txt’)[序号]

5. 断言

常用的断言主要有三种:
(1)assertEqual(first, second, msg)
first == second时,断言成功,用例结果符合预期
first != second时,断言失败,抛出错误信息及msg
(2)assertTrue(expr, msg)
expr为True,断言成功,用例结果符合预期
expr为False,断言失败,抛出错误信息及msg
(3)assertTexts(texts, selector, msg)
texts中每个元素的值都包含在selector选择器对应的元素文本集合中,则断言成功,否则,断言失败,抛出错误信息及msg

6. 第一个测试用例

import minium


class TestIg(minium.MiniTest):
  
    def test_01_login(self):
        self.mini.page.wait_for(2)					# 当前页面等待2s
        self.mini.page.get_element('.login-word-input').input(user)		# 输入用户名
        self.mini.page.get_element('.login-pass').input(password)		# 输入密码
        self.mini.page.get_element('.loginBtn').click()			# 点击登录按钮
        self.mini.page.wait_for(3)					# 当前页面等待3s
        self.assertEqual('/pages/main/index', self.mini.app.current_page.path, msg='登录失败')
        # 断言登录后是否跳转到首页,如果断言失败,则返回mgs"登录失败"


minitest -c config.json -tests.igtest

四、用例设计模式

上面第一个测试用例写到了登录,非常简单常见的一个场景,但像我在做的这个项目,在登录后,跳转到首页后有项目切换、项目信息、项目试算、租赁订单、租赁合同等等非常多模块的定位、元素点击、页面滑动、页面跳转以及断言等,把这么多代码全部写到一个类里面,代码多而繁杂,如果页面元素有更改,或者需要增加用例,查找起来非常不方便,更不要说整个小程序内有那么多页面,所以需要进行分层管理,我使用的是PO模式

1. 什么是PO模式

PO模式,即page object mode,页面对象模式,通过对界面元素和功能模块的封装减少冗余代码,同时在后期维护中,若元素定位或功能模块发生变化,只需要调整页面元素或功能模块封装的代码,提高测试用例的可维护性。

2. 层级关系

第一层:基础层BasePage,作用:封装一些minium的原生方法,如元素定位、框架跳转等
第二层:PO层,页面对象层,如元素定位、获得元素对象、页面操作
第三层:测试用例层,主要负责业务逻辑和数据驱动

三层之间的关系:PO层继承基础层的类,测试用例层调用PO层

3. 优点

(1)分层清晰,易读
(2)易维护,由于元素定位与业务逻辑的分层特性,元素定位变动只需要修改PO层的元素定位,不需要修改业务逻辑,容易维护
(3)可复用,由于在基础层已经对minium的原生方法进行过二次封装,所以方法具有一定的通用性,可复用(但这取决于二次封装方法的通用性有多高)

五、框架

1. 目录结构


(1)/cases/base/
/cases/base/basecase:测试用例基类,用于设置用例输出路径和清理工作,项目的测试用例都继承此类
/cases/base//basedef:页面基类,封装所有页面会用到的公用方法
/cases/base/router:小程序内各页面路径
(2)/cases/pages
页面对象模型:获得元素对象、页面操作等
(3)tests包
各页面的测试脚本
/tests/outputs
测试报告:记录各测试用例的执行情况
/tests/config.json
配置信息,包括小程序项目地址、cli工具地址等
/tests/suite.json
测试计划,配置要执行的用例及执行顺序

2. BaseCase基类

(1)复写minium.MiniTest类里面的setUpClass、tearDownClass、setUp、tearDown方法
setUpClass、tearDownClass前面一定要加@classmethod修饰器,这两个方法在整个测试计划执行期间只会执行一次,setUp、tearDown在每个测试用例case执行前后都会执行,minium.MiniTest内的setUp方法会将小程序恢复到默认页面,即小程序首页
(2)因为我写了很多页面,如果在每个页面里面的每条用例执行前都恢复到小程序首页,会大大增加用例执行时间,所以我复写的setUp、tearDown方法都直接pass了

from pathlib import Path
import minium


class BaseCase(minium.MiniTest):
    """测试用例基类"""
    @classmethod
    def setUpClass(cls):
        super(BaseCase, cls).setUpClass()
        output_dir = Path(cls.CONFIG.outputs)
        if not output_dir.is_dir():
            output_dir.mkdir()

    @classmethod
    def tearDownClass(cls):
        super(BaseCase, cls).tearDownClass()

    def setUp(self):
        pass

    def tearDown(self):
        pass

3. BaseDef公共方法类

定义一些公共方法

class BaseDef:
    def __init__(self, mini):
        self.mini = mini

    '''跳转到指定页面'''
    def navigate_to_open(self, route):
        self.mini.app.navigate_to(route)

    '''跳转到指定页面并关闭当前页面'''
    def redirect_to_open(self, route):
        self.mini.app.redirect_to(route)

    '''跳转到tabbar页面,关闭其他非tabbar页面'''
    def switch_to_tabbar(self, route):
        self.mini.app.switch_tab(route)

    '''跳转到非原生tabbar页面'''
    def switch_to_not_tabbar(self, selector, str=None):
        self.mini.page.get_element(selector, inner_text=str).click()

    '''无需加载的页面滑动到页面底部'''
    def scroll_to_buttom(self, selector):
        el = self.mini.page.get_element('scroll-view')
        rect = self.mini.page.get_element(selector).rect
        el.scroll_to(y=rect['top'])

    '''元素列表中找第几个元素'''
    def get_el_in_els(self, selector, index=0):
        els = self.mini.page.get_elements(selector)
        return els[index]

    '''断言每个元素文本中都包含某元素'''
    def assertIn(self, selector, text, msg=None):
        els1 = self.mini.page.get_elements(selector, text_contains=text)
        els2 = self.mini.page.get_elements(selector)
        if els1 != els2:
            raise AssertionError("selector:%s, inner_text=%s not Found")

    @property
    def current_path(self):
        return self.mini.page.path

4. /pages页面对象类

里面写了每个页面的元素定位,页面操作
(1)homepage:首页相关的元素定位、页面操作

from business_test.cases.base import router, basedef


class HomePage(basedef.BaseDef):
    locator = {
        'top_area': '.true-top-menu-item',      # 金刚位
        'main_area': '.main-menu-item',         # 功能位
        'project': '.content',                  # 项目切换
        'project_select': '.list-cell'          # 项目选择
    }

    # 校验首页页面路径
    def check_homepage_path(self):
        self.mini.page.wait_for(2)
        self.mini.assertEqual(self.current_path, router.homePage)

    # 根据index,点击金刚位的项目卡片、试算助手、联系人、流程中心
    def toparea_hop(self, index=0):
        els = self.mini.page.get_elements(self.locator['top_area'])
        els[index].click()

    # 根据序号,点击功能位的租赁订单、租赁合同……
    def mainarea_hop(self, index=0):
        els = self.mini.page.get_elements(self.locator['main_area'])
        els[index].click()

    # 当前项目名称
    def current_project_name(self):
        self.mini.page.wait_for(self.locator['project'], max_timeout=3)
        return self.mini.page.get_element(self.locator['project']).inner_text

    # 跳转到项目选择页面
    def project_select_page(self):
        self.mini.page.wait_for(self.locator['project'], max_timeout=3)
        self.mini.page.get_element(self.locator['project']).click()
        self.mini.page.wait_for(3)
        return self.mini.app.current_page

    # 选择项目
    def project_selector(self, project_name=''):
        page = self.mini.app.current_page
        page.wait_for(self.locator['project_select'], max_timeout=3)
        if project_name == '':
            return page.get_elements(self.locator['project_select'])
        else:
            return page.get_element(self.locator['project_select'], inner_text=project_name)

(2)customerpage:客户管理页面相关的元素定位、页面操作

略。。。

4. /tests测试用例类

(1)homepage_test:首页相关的测试用例

from business_test.cases.pages import loginpage
from business_test.cases.pages import homepage
from business_test.cases.base.basecase import BaseCase
from business_test.cases.base import router, accounts
from random import randint
import minium


# 小程序首页测试
@minium.ddt_class()
class HomePageTest(BaseCase):
    def __init__(self, methodName = 'runTest'):
        super(HomePageTest, self).__init__(methodName)
        self.homePage = homepage.HomePage(self)

    def test_00_login_sure(self):
        if self.app.current_page.path == router.loginPage:
            loginpage.LoginPage(self).login(user=accounts.user, password=accounts.password)
        else:
            pass

    def test_01_homepagePath(self):
        """验证首页路径正确"""
        self.homePage.check_homepage_path()

    def test_02_modulesExist(self):
        """验证首页相关模块存在"""
        self.assertTexts(['到期房源', '项目商机', '签约概览'], 'view', '到期房源、项目商机、签约概览模块存在')

    @minium.ddt_case(
        (0, '首页—>项目卡片'), (1, '首页—>试算助手'),
        (2, '首页—>联系人'), (3, '首页—>流程中心')
    )
    def test_03_topAreaPath(self, args):
        """验证首页金刚位入口正确跳转"""
        paths = [router.projectinfoPage, router.trialPage, router.contactPage, router.processPage]
        self.homePage.toparea_hop(index=args[0])
        self.page.wait_for(2)
        try:
            self.assertEqual(paths[args[0]], self.app.current_page.path, msg=args[1])
            self.app.navigate_back()
        except:
            self.app.navigate_back()

    @minium.ddt_case(
        (0, '首页—>租赁订单列表'), (1, '首页—>租赁合同列表'),
        (2, '首页—>认购单列表'), (3, '首页—>销售合同列表'),
        (4, '首页—>企业动态'), (5, '首页—>企业宝典'),
        (6, '首页—>租赁需求列表'), (7, '首页—>销售需求')
    )
    def test_04_mainAreaPath(self, args):
        """验证首页功能位入口正确跳转"""
        paths = [router.rentPage, router.rentPage, router.salePage, router.salePage, router.dynamicPage,
                 router.biblePage, router.demandPage, router.demandPage]
        self.homePage.mainarea_hop(index=args[0])
        self.page.wait_for(2)
        try:
            self.assertEqual(paths[args[0]], self.app.current_page.path, msg=args[1])
            self.app.navigate_back()
        except:
            self.app.navigate_back()

    def test_05_switchProjectPath(self):
        """验证项目选择页面路径正确"""
        self.homePage.project_select_page()
        try:
            self.assertEqual(router.selectProjectPage, self.app.current_page.path)
            self.app.navigate_back()
        except:
            self.app.navigate_back()

    def test_06_switchProject(self):
        """验证随机切换项目成功"""
        self.homePage.project_select_page()
        projects = self.homePage.project_selector()
        selection = projects[randint(0, len(projects) - 1)]
        project_name = selection.inner_text
        selection.click()
        self.assertEqual(project_name, self.homePage.current_project_name(), msg='当前项目为随机选择项目')

    def test_07_reset(self):
        """一个页面测试完成,重置到首页InfiPark项目"""
        self.homePage.redirect_to_open(router.homePage)
        self.homePage.project_select_page()
        self.homePage.project_selector(project_name='InfiPark').click()
        self.assertEqual(self.homePage.current_project_name(), 'InfiPark')

(2)customerpage_test:客户管理相关的测试用例

略。。。

5. config.json配置

{
  "project_path": "C:/Users/LX/Desktop/mini/cloud-merchant-mini-programs",		# 项目路径
  "dev_tool_path": "D:/微信开发者工具/微信web开发者工具/cli.bat",			# CLI工具路径
  "debug_mode": "debug",		# 日志打印级别
  "test_port": 9420,			# 端口号
  "platform": "ide",			# 测试平台,有ide、Android、IOS
  "app": "wx",
  "assert_capture": true,
  "request_timeout": 60,
  "remote_connect_timeout": 300,
  "auto_relaunch": false,
  "enable_app_log": true
}


6. suite.json测试计划配置

配置需要执行的用例以及用例执行顺序,根据pkg去匹配包名,找到测试类,然后再根据case_list里面的规则去查找测试类的测试用例。
在suite.json中,未明确指定用例的执行顺序时,用例是按照用例名称升序去执行的,所以定义用例名称时,最好在test后加上数字,保证用例按照我们书写的顺序执行

{
    "pkg_list": [
      {
        "case_list": [
          "test_*"					# 用例名称,*是通配符,意思是执行以test_开关的测试用例
        ],
        "pkg": "tests.homepage_test"		# 用例文件名,不需要加.py
      },
      {
        "case_list": [
          "test_*"
        ],
        "pkg": "tests.customerpage_test"
      }
    ]
  }

7. 执行测试计划

minitest -c config.json -s suite.json -g		# 按照suite.json测试计划执行,输出报告
minitest -c config.json -m tests.homepage_test --case test_03_moduleExist		# 执行homepage_test里面的test_03_moduleExist用例

8. 查看测试报告

用例执行完成后,会自动生成测试报告,相关数据存放在outputs目录下,目录下面有一个index.html文件,但是我们不能直接用浏览器打开这个文件,需要把这个目录放到一个静态服务器上

  运行命令
  python -m http.server 1234 -d outputs
  然后在浏览器上访问http://localhost:1234即可查看报告

9. 真机测试

真机上测试其实是通过开发者工具的真机调试来运行的,所以还是要用到开发者工具
(1)电脑上安装adb,之后命令行运行adb devices获取设备号

C:\windows\system32>adb devices
List of devices attached
9YS0220820019844 device

(2)修改config.json文件

(3)与模拟器一样命令行运行就可以了

10. 云测

基于minium框架的小程序UI自动化云测

六、ddt数据驱动

1. 什么是数据驱动

数据驱动,指在自动化测试中处理测试数据的方式。
通常测试数据与功能函数分离,存储在功能函数的外部位置。在自动化测试运行时,数据驱动框架会读取数据源中的数据,把数据作为参数传递到功能函数中,并会根据数据的条数多次运行同一个功能函数。

2. 数据驱动的优点

(1)减少重复代码
比如常见的登录场景,登录可以设计两条用例,登录失败与登录成功,区别只是输入(账号、密码)与输出(跳转路径)不同,常规写法就是写两条用例,test_login_success与test_login_fail,但其实他们有很多重复的代码,如果有的场景,设计的用例非常多,那重复代码也将会非常多,所以引入了ddt数据驱动。
(2)测试用例之间数据隔离
其中一条用例失败,不会影响其他用例的执行

3. 使用详解

minium框架中已经封装好了ddt,只需要在类前加上@minium.ddt_class,用例test_login前面加上@minium.ddt_case((arg1), (arg2), (arg3), ……),最后ddt_case里面有多少参数,用例就会被展开成多少条,在test_login(self, args)中,依次读取ddt_case中的参数,第一条用例中,args=arg1,第二条用例中,args=arg2,第三条用例中,args=arg3……,这样,我们就可以只写一个方法,达到完成两条甚至多条测试用例的目的

from business_test.cases.base.basecase import BaseCase
from business_test.cases.pages import loginpage
from business_test.cases.base import router, accounts
import minium


@minium.ddt_class()
class LoginPageTest(BaseCase):

    def __init__(self, methodName='runTest'):
        super(LoginPageTest, self).__init__(methodName)
        self.loginPage = loginpage.LoginPage(self)

    @minium.ddt_case(
        (0, '登录失败-账号/密码错误', 'aiyumei', '200'),
        (1, '登录成功-账号:aiyumei', accounts.user, accounts.password)
    )
    def test_login(self, args):
        if self.app.current_page.path == router.loginPage:
            paths = [router.loginPage, router.homePage]
            self.loginPage.login(user=args[2], password=args[3])
            self.page.wait_for(3)
            self.assertEqual(paths[args[0]], self.mini.app.current_page.path, msg=args[1])
        elif self.app.current_page.path == router.homePage:
            pass

七、遇到的问题

1. page.scroll_to滚动无效

页面滚动方法page.scroll_to无效,有时候开发在滚动的时候,会在上面加一层scroll-view组件,导致页面直接滚动无效,需要先定位到scroll-view,再以该元素进行滚动scroll_to操作

2. 前一条用例失败会影响后一条用例的执行

同一个页面,两条用例之间可能会有关联,比如下面的test_05跟test_06,涉及到两个页面,首页和项目选择页面,test_05跟test_06的初始页面都是首页,如果test_05断言失败的话,就不会执行后面的“self.app.navigate_back()”返回首页操作,导致test_06的初始页面不对,test_06也会执行失败,所以需要用到try…except

    def test_05_switchProjectPath(self):
        """验证项目选择页面路径正确"""
        self.homePage.project_select_page()
        try:
            self.assertEqual(router.selectProjectPage, self.app.current_page.path)
            self.app.navigate_back()
        except:
            self.app.navigate_back()

    def test_06_switchProject(self):
        """验证随机切换项目成功"""
        self.homePage.project_select_page()
        projects = self.homePage.project_selector()
        selection = projects[randint(0, len(projects) - 1)]
        project_name = selection.inner_text
        selection.click()
        self.assertEqual(project_name, self.homePage.current_project_name(), msg='当前项目为随机选择项目')

3. miniddt修改用例名称

参考该文章:修改miniddt生成用例的名称

4. 小程序右上角菜单转发

参考该文章:小程序自动化框架minium——右上角菜单转发分享

源码

附源码链接,有需要可以下载

https://download.csdn.net/download/baguenaudier/85819436

最后一次编辑于  2022-07-25  
点赞 3
收藏
评论

4 个评论

  • 树
    2022-09-30

    写的特别棒,有大框架有细节,帮助良多

    2022-09-30
    赞同 1
    回复
  • dousha322
    dousha322
    2023-10-11

    感谢分享,帮助良多,另外我看了下minium文档,执行每个用例前如果不需要跳转到首页,直接在config里面指定:"auto_relaunch": false 即可

    2023-10-11
    赞同
    回复
  • 电光幻影
    电光幻影
    2023-07-13

    BaseCase和BaseDef为啥要分开写的,不能同时都写到BasePage里么

    2023-07-13
    赞同
    回复
  • ꧁༺๑汤米๑༻꧂ 
    ꧁༺๑汤米๑༻꧂ 
    2023-02-09

    小程序的webview是怎么测试的,目前没有找到方法,虽然通过webview调试页找到了元素,但是程序无法识别

    2023-02-09
    赞同
    回复
登录 后发表内容