微信小程序自动化测试做了一段时间后你会发现官方文档给的Demo只是冰山一角。真正投入实际项目跑起一个像样的自动化测试体系需要的不是你照着文档敲两行代码而是把底层机制吃透然后根据自己项目的实际场景去自定义整个测试能力。这也就是Minium这款框架最值得深挖的地方——它给你搭好了地基但上面的房子想盖成什么样完全取决于你怎么去定义和扩展。这篇文章不打算复述官方文档而是围绕自定义测试这条主线把Minium的核心机制、踩坑经验、以及怎么把它改造成符合自家项目需求的测试框架完整地梳理一遍。适合的人群是这样几类刚接手小程序自动化测试想快速搭建一套稳定可跑的用例体系的测试工程师被官方Demo迷惑以为Minium只能调用几个简单API的入门者以及在pytest和Minium之间反复横跳想知道怎么把两者结合出最佳实践的深度玩家。1. 为什么选择Minium而不是另外两条路小程序自动化测试其实有几条路可以走先说结论Minium是目前小程序原生测试的不二之选前提是你不想折腾太多底层协议。1.1 三种主流方案的真实对比市面上做小程序自动化测试无非三条路Appium/WebdriverAgent这类通用移动端方案、微信官方基于Chrome DevTools Protocol的调试方案也就是旧的miniprogram-automator、以及微信团队后来推出的Minium。先把对比摆在这里都是我自己实测过后的结论方案稳定性小程序兼容性学习成本底层机制适用场景Appium中等依赖原生控件识别差小程序是Canvas渲染原生控件树拿不全高走系统WebView调试协议混合App中嵌WebViewminiprogram-automator低官方多年前已停止维护一般基础库版本升级就容易挂低直接走CDP协议操作WebView简单demo验证、一次性调试Minium高微信官方持续维护好专门针对小程序定制中小程序内部注入测试代码异步回传正规项目持续集成、业务回归这里有个容易忽略的点Appium走的是WebView调试协议但小程序页面大量使用自定义组件渲染在原生Canvas层时Appium拿到的那套节点树根本不完整很多业务控件根本定位不到就算定位到了点击、滑动也经常失灵。Minium则完全不一样它是官方专门为小程序打造的通过在小程序端注入测试运行时代码再和Python侧的Driver通信能拿到完整的组件树和页面栈信息稳定性根本不是一个级别。1.2 Minium架构的简单拆解Minium官方架构图看起来很复杂但剥开来看其实就三层第一层是Python侧的客户端也就是你写测试用例时面对的那套Minium类。这一层负责解析你的测试脚本把操作指令封装成协议消息发给小程序端。第二层是通信协议层Minium通过WebSocket和微信开发者工具或真机调试通道建立连接所有指令和回执都走这个通道。第三层是注入在小程序运行时里的执行器它负责接收指令在小程序内部执行对应的原生操作再把执行结果异步推回给Python侧。明白这个机制你就理解为什么Minium的很多操作是异步的。比如page.get_elements()返回的是一组匹配的元素列表但页面加载、数据请求、渲染都是异步的如果你在元素还没出现时就调用方法必然拿到空列表。这也是后面所有踩坑事件的总根源——时序问题永远排在第一位。2. 环境搭建里那些第一次跑不通的环节Minium的环境配置不算复杂但它有个官方文档没有正面强调的点依赖版本组合非常敏感。我前前后后在新电脑上搭建过不下五次环境几乎每次都有人卡在某个固定的坑上。2.1 版本组合清单先给出一份实测稳定、可以抄作业的版本组合Python版本3.7到3.9不建议3.10以上部分依赖的二进制包在3.10上编译困难微信开发者工具版本使用当前稳定版但要注意开发者工具必须开启服务端口Minium建议直接安装最新版但锁定依赖时注意minium的版本号变化挺频繁pytest6.x或7.x都行建议7.x安装命令# 创建虚拟环境这是必须步骤最好别省 python3.7 -m virtualenv venv source venv/bin/activate # 安装Minium pip install minium # 安装pytest和相关插件 pip install pytest pytest-html提示如果你用的是Linux服务器需要先单独安装libminidevice.so的运行依赖不然minium导入就会报错。具体依赖列表在requirements.txt里都有说明装缺失的系统库就行。Windows和macOS则基本是一路next。2.2 开发者工具服务端口的坑Minium要连上小程序靠的是微信开发者工具给你开的那个自动化测试端口。开启路径在微信开发者工具 - 设置 - 安全设置 - 服务端口把开关打开。最容易踩的坑如果你项目里配了多个project.config.json或者开发者工具开了多个窗口Minium连接时可能出现端口冲突。它的默认端口是9420如果被占用了连接就会一直卡在waiting for connection。解决方法是手动指定端口在config.json里设置# 运行测试时手动指定端口 minium --port 9421如果你是在代码里调Minium(conf)则配置项里加一行connection_options: {port: 9421}即可。2.3 真机调试图省心还是省钱有两种调试模式开发者工具模式和真机模式。开发者工具模式好处是快改完代码立刻能看到效果但有个隐患——开发者工具的基础库版本和真机上的基础库版本有可能不一致导致某些API行为在工具里正常、真机上异常。如果你要跑的是支付、订阅消息这类强依赖真实环境的用例必须在真机上跑。真机模式配置稍微多一点{ project_path: /path/to/your/wechat-miniprogram, desired_caps: { platformName: android, platformVersion: 11, deviceName: your-device-id, miniprogram: { appId: wx1234567890abcdef, path: pages/index/index } } }手机需要在开发者工具里开启调试模式然后扫码连接。这里有个细节真机模式的执行效率比开发者工具模式慢很多因为每步操作都要走USB通信和真机渲染。所以在日常开发迭代阶段用工具模式跑到了发版前的全量回归阶段再用真机模式这是效率最高的组合。3. 自定义测试的骨架页面对象模型不是可选项聊到自定义测试第一个要说的就是页面对象模型Page Object Model。这一节内容来自实际项目的血泪教训——最早用Minium写用例时图省事直接在测试用例里写一堆page.get_element、click、input的操作。结果用例跑到三百条的时候业务UI一改版整个测试套件报废了一半改起来痛不欲生。3.1 为什么测试用例不能直接写页面操作原因是改动成本问题。你的用例本质上是验证业务逻辑而不是验证UI长什么样。如果用例代码和页面的DOM结构耦合在一起UI稍微调一下层级、改个class名、换了个文案按钮位置所有引用到这个地方的用例全部要跟着改。这违背了自动化测试的初衷——你期望测试脚本能在产品迭代中活下来而不是跟着UI一起重写。页面对象模型就是解决这一层的把页面的元素定位、操作行为、状态校验都封装到Page类里用例只关心Page提供的业务语义操作比如LoginPage.login(username, password)至于这个操作底层是点击id为login_btn的按钮还是点击class为.login-button的按钮用例完全不用关心。3.2 自定义的Page基类设计Minium官方没有强制你绑定它的Page类这个设计给了很大的自由度。我这里分享一个在实际项目中自定义的基类from minium import MiniProgram class BasePage: 自定义页面基类封装一些通用操作 def __init__(self, mini: MiniProgram, page_pathNone): self.mini mini if page_path: self.page_path page_path def _get_page(self): 获取当前页面实例 try: return self.mini.get_current_page() except Exception: # 适配不同基础库版本 return self.mini.app.get_current_page() def get_displayed_elements(self, selector, timeout10): 等待元素出现并返回 page self._get_page() elements page.get_elements(selector) # 加上显式等待 deadline time.time() timeout while not elements and time.time() deadline: time.sleep(0.5) elements page.get_elements(selector) return elements def click_by_text(self, text): 按文本点击 page self._get_page() elements page.get_elements(ftext~{text}) if not elements: raise AssertionError(f未找到文本为{text}的元素) elements[0].click() def go_back(self): 返回上一页 # 优先用原生导航栏返回 try: self.mini.navigate_back() except Exception: # 部分页面用自定义导航栏需要模拟系统返回 self.mini.shake()这个基类看起来简单但实际价值很大。比如get_displayed_elements这个封装就是把Minium的异步时序问题提前拦截了不至于让用例在执行到一半时因为元素还没渲染出来而莫名失败。3.3 具体业务Page类的实现方式以一个典型的商品列表页为例class ProductListPage(BasePage): 商品列表页 def __init__(self, mini): super().__init__(mini, pages/product/list/list) def switch_to_category(self, category_name): 切换到指定分类 # 分类标签是横向滚动需要先滑动到一个可见位置 self.click_by_text(category_name) def scroll_to_bottom(self): 滚动到底部触发加载更多 page self._get_page() # 小程序页面的滚动容器不一定是window page.scroll_to_bottom() def get_product_count(self): 获取当前列表中的商品数量 elements self._get_page().get_elements(.product-item) return len(elements) def open_product_detail(self, index0): 打开第index个商品的详情页 page self._get_page() products page.get_elements(.product-item) if len(products) index: raise AssertionError(f商品数量不足当前只有{len(products)}个) products[index].click() return ProductDetailPage(self.mini)注意最后那个open_product_detail方法它的返回值是ProductDetailPage。这就形成了Page对象之间的跳转链测试用例写起来会非常顺滑def test_browser_product_flow(): 浏览商品的完整流程用例 login_page LoginPage(mini) login_page.login(test_user1, password) home_page HomePage(mini) home_page.enter_product_list() list_page ProductListPage(mini) list_page.switch_to_category(手机) detail_page list_page.open_product_detail(0) assert iPhone in detail_page.get_product_title()这个用例的可读性非常高业务视角完全从UI细节中解放出来。回归阶段UI改了定位方式你只需要改对应的Page类里的一个方法所有引用它的用例全部自动修复。4. 自定义断言与业务状态的精准校验Minium自带的一套断言是基于元素层面的assert_element、assert_visible、assert_hidden之类。但实际项目中真正的断言往往需要跨元素、跨数据、甚至跨接口响应来综合判断。这时候你必须自定义断言体系。4.1 从元素存在到业务正确的断言升级举个例子一个典型的加载更多场景——用户滑动到列表底部触发分页加载加载完成后列表新增了十项内容。如果你只断言存在加载完成的toast提示或者某个loading元素消失了根本证明不了分页逻辑正确。你需要断言的是加载前的列表项数量N触发加载后列表项数量变成了N10且新增项的数据不是重复的这才是业务层面的正确断言。Minium给你提供的是基础能力你需要在上面组合出自己的业务断言方法def assert_list_loaded_more(before_count, after_count, expected_increment): 断言列表是否成功加载了更多项 assert after_count before_count expected_increment, \ f加载更多失败加载前{before_count}项加载后{after_count}项期望增加{expected_increment}项这种自定义断言函数可以从页面对象或接口Mocker中获取数据做各种维度的组合判断。4.2 内置断言与mock的配合使用更高级一点的做法是小程序的前端数据往往来自后端接口。我们在做自动化测试时可以使用Minium的mock_request能力把接口返回的数据固定住让断言变得可控def test_list_load_more_with_mock(): 用mock数据控制分页接口返回 with mini.mock_request({ /api/product/list: { page: 1, pageSize: 10, total: 35, list: [{id: i, name: f商品{i}} for i in range(10)] } }): list_page ProductListPage(mini) list_page.enter_page() list_page.scroll_to_bottom() new_items list_page.get_displayed_elements(.product-item) assert len(new_items) 10比单纯的等待真实接口快很多而且完全不依赖后端环境跑起来极其稳定。4.3 自定义断言库的沉淀做多了以后你会积累一个属于自己项目的断言库。这个库的目录结构一般是这样tests/ ├── assertions/ │ ├── common_assertions.py # 通用断言如列表加载、数值比较 │ ├── order_assertions.py # 订单业务相关断言 │ └── pay_assertions.py # 支付流程断言 ├── pages/ │ ├── base_page.py │ ├── login_page.py │ ├── product_list_page.py │ └── ... ├── testcases/ │ ├── test_login.py │ ├── test_product.py │ └── ... └── config.json每次把新发现的断言模式沉淀下来这套库就是你项目的专属测试资产。换一批测试人员接手时新人不需要从零开始理解业务直接基于断言库写用例就行效率提升是立竿见影的。5. 处理小程序特有的异步时序与页面交互难题前面提到小程序的操作是异步的这是所有自动化测试难点的大头。这一节单独来讲因为它值得单独讲。5.1 顶部导航栏高度与自定义导航栏适配小程序页面的导航栏处理是自动化测试里一个很不起眼但特别容易踩坑的点。如果你用的是自定义导航栏navigationStyle: custom那么你点击页面顶部元素时如果坐标计算不对很容易点到状态栏区域导致点击失效。Minium的tap支持坐标和元素两种模式但坐标模式下你需要知道导航栏的真实高度。这个高度不是固定的因为不同手机状态栏高度不同。好在可以直接在小程序页面的JS中执行代码来获取def get_nav_bar_height(mini): 获取页面实际导航栏高度 res mini.evaluate(wx.getSystemInfoSync()) status_bar_height res.get(statusBarHeight, 20) # 自定义导航栏高度一般在44~48之间取当前页面配置 nav_height 44 if not res.get(isCustomNav) else 48 return status_bar_height nav_height注意wx.getSystemInfoSync()在较新版本的基础库中标记为废弃但它在自动化测试场景中还是能用的主要是拿系统信息。如果你用的是最新基础库也可以直接用wx.getWindowInfo()配合wx.getMenuButtonBoundingClientRect()来计算胶囊按钮位置和导航栏高度后者更精确。然后在点击顶部元素时把导航栏高度加上def tap_top_area(self, x, y): 点击页面顶部区域需要避开导航栏 nav_height get_nav_bar_height(self.mini) actual_y y nav_height self.mini.tap(x, actual_y)5.2 列表加载更多的可靠性封装页面列表加载更多是电商、社区类小程序最常见的功能之一。自动化测试要实现这个场景难点在于触发滑动的距离不够导致不加载、加载动画出现太慢导致断言时数据尚未刷新、或者因小程序滚动容器的嵌套导致scroll_to_bottom不生效。我的经验是scroll_to_bottom不一定靠谱因为小程序页面的滚动容器不一定是window。更稳定的是找到实际的滚动容器执行滚动指令def scroll_container_to_bottom(container_selector): 针对指定滚动容器滑动到底部 page mini.get_current_page() elements page.get_element(container_selector) # 获取容器高度 container_info elements[0].bounding_client_rect() # 直接调用小程序内部的scroll-view的scrollTop赋值 page.evaluate(fwx.pageScrollTo({{scrollTop: {container_info[height] * 3}}}))这个办法在scroll-view容器和页面级滚动容器上都验证过稳定度比scroll_to_bottom高不少。5.3 监听用户离开小程序的业务场景有段时间有个业务需求是用户在小程序切到后台或直接退出时需要上报某个埋点事件。这个场景用Minium实现自动化测试时需要模拟小程序切后台的操作。好消息是基础库提供了wx.onAppHide回调接口Minium也提供了对应的模拟能力def test_app_hide_event_report(): 验证用户隐藏小程序时上报埋点 # 使用mock拦截上报接口记录是否被调用 report_called [] with mini.mock_request(/api/report/session_end): # 模拟小程序切后台 mini.app_hide() # 等埋点上报完成 report_called mini.get_mock_called_urls() assert /api/report/session_end in report_called这个用例的价值在于切后台这种动作如果依赖手工测试很难稳定复现且耗费时间但自动化里一个app_hide()就搞定了。6. 结合pytest构建可配置的测试体系Minium自己提供了一套测试执行器但要应对复杂项目的回归需求把它嵌入到pytest体系中才是更硬核的做法。这一节重点讲怎么把两者结合出最佳实践。6.1 通过fixture管理Minium实例的创建与销毁在pytest里跑Minium核心是管理好小程序连接的生命周期每个测试模块或测试用例运行前初始化Minium运行结束后正确释放资源。这里用pytest的fixture是最干净的做法import pytest import minium pytest.fixture(scopemodule) def mini(): 模块级别的Minium实例 conf { project_path: /path/to/wechat-mini-program, debug_connect: False, device: tool } mini minium.Minium(conf) yield mini mini.shutdown()提示Mini的实例不应该每个测试用例都创建一次因为连接建立和微信开发者工具的启动过程很耗时模块级共享即可。如果你要跑真机也可以把这个fixture改成session级。6.2 自定义pytest命令行参数实现目标页面选择实际回归测试中经常只针对某个模块跑。可以在conftest.py里增加自定义参数def pytest_addoption(parser): parser.addoption( --test-page, actionstore, defaultall, help指定要测试的页面如product/order/user ) pytest.fixture def test_page(request): return request.config.getoption(--test-page)然后在测试用例中加跳过逻辑def test_product_detail(test_page): if test_page ! all and test_page ! product: pytest.skip(不在本次回归范围) # 测试逻辑这样一来命令行pytest --test-pageproduct就能只跑商品模块的相关用例省去大量无用执行时间。6.3 独立测试报告与持续集成pytest-html插件可以生成HTML格式的测试报告但默认报告比较简陋。结合Minium测试中经常需要保存失败现场截图的需求可以自定义一个pytest插件来收集截图和页面栈信息import os import pytest from minium import MiniProgram def pytest_runtest_makereport(item, call): 用例失败时自动截图 if call.when call and call.exited: # 获取当前minium实例 mini item.funcargs.get(mini, None) if mini: screenshot_dir os.path.join( item.config.rootdir, screenshots ) os.makedirs(screenshot_dir, exist_okTrue) # 用时间和用例名命名截图文件 screenshot_name f{item.name}_{int(time.time())}.png screenshot_path os.path.join(screenshot_dir, screenshot_name) mini.screenshot(screenshot_path) # 把截图信息附加到报告里 ...这套机制实际跑下来非常香回归出错了不用翻日志直接看截图和页面栈信息定位效率翻倍。6.4 UDP、上报等自定义工具的集成Minium的mock_request能拦截小程序内的HTTP请求和上报请求比如UDP、WebSocket。在测试体系里沉淀一套上报断言工具可以让数据埋点的回归变得非常可靠class ReportAssertionHelper: 上报事件断言工具 def __init__(self, mini): self.mini mini self.reported_events [] def __enter__(self): self.mini.record_report(/api/event/report) return self def __exit__(self, *args): self.mini.stop_record_report() def assert_report_event(self, event_name, times1): 断言某个上报事件被触发了几次 events self.mini.get_reported_data(/api/event/report) match_events [ e for e in events if e.get(eventName) event_name ] assert len(match_events) times, \ f上报事件{event_name}期望触发{times}次实际{len(match_events)}次这个工具配合Mock server能很轻松地把数据上报类需求的回归做好这在纯手工测试中几乎是不可完成的任务——手工点记不清上报了几次、上报参数是什么自动化几秒钟就能给出结论。7. 跑通整个链路后必须避开的几个经典坑这个章节相当于我的个人避坑台账每一个都是真金白银换来的经验。7.1MiniProgram实例连接不稳定、偶发初始化超时这个坑的表现是第一次运行用例没问题第二次或第三次运行时Minium(conf)初始化时卡在连接微信开发者工具这一步超时后报错。根因微信开发者工具的自动化端口在断开连接后没有及时释放或者多个Python进程占用同一个项目。排查链路先用命令查看当前系统是否有多个Python进程在跑minium如果有逐个杀掉残留进程看微信开发者工具的服务端口开关是否被重置有的版本会在工具崩溃后自动关闭如果以上都没有问题就考虑是端口2888被占住了手动换一个端口解决方法是写一个环境准备脚本运行用例前自动检测端口占用和残留进程#!/bin/bash # 清理残留的minium进程和端口 pkill -f minium || true sleep 2 $PYTHON -c import socket; ssocket.socket(); s.settimeout(1); s.connect((127.0.0.1, 9420)); print(端口被占用需等待) || echo 端口空闲7.2 自定义组件的事件穿透导致点击失效小程序自定义组件和原生组件的交互层级不同cover-view这类原生组件会盖在WebView渲染层之上。Minium执行click时如果目标元素被cover-view遮住点击就会透传到底层元素。排查链路先用page.get_elements确认目标元素存在再用element.bounding_client_rect()获取元素位置人工点击该坐标确认UI上有没有被遮挡如果确认被cover-view遮挡改用mini.tap配合坐标但坐标要加偏转量点击cover-view的可见区域这种坑在地图页面上有悬浮按钮视频播放页叠加控件这类场景中尤其常见。我的习惯是遇到这类特殊页面一律在业务Page类里预埋一个点击兜底方法自动判断点击是否生效def click_cover_aware(self, selector, expected_result_selector): 点击一个可能被cover-view遮挡的元素 page self._get_page() ele page.get_element(selector) if not ele: raise AssertionError(f元素不存在: {selector}) # 先尝试元素点击 ele.click() # 短暂等待判断预期结果是否出现 time.sleep(0.5) if not page.get_element(expected_result_selector): # 元素点击失效改用坐标点击 rect ele.bounding_client_rect() center_x rect[left] rect[width] / 2 center_y rect[top] rect[height] / 2 self.mini.tap(center_x, center_y)这个方法是笨办法但胜在稳定可靠。7.3 分页列表下拉刷新/上拉加载状态互相干扰这个问题在测试加载更多和下拉刷新同时存在的页面时经常遇到你为了触发加载更多执行了滚动但滚动过程中误触发了下拉刷新手势导致页面重新加载断言数据全乱。排查链路先检查滚动距离是不是滚过头直接滚到页面顶部触发下拉刷新检查Minium的scroll_to_bottom实现是否触发了橡皮筋效果用page.evaluate(wx.pageScrollTo({scrollTop: 100}))手动控制滚动位置避免直接触发橡皮筋我的建议是测试加载更多用例时先通过page.evaluate设置一个较大的scrollTop值再调用scroll_container_to_bottom精确滚动这样能把误触发的概率降到最低。7.4 回调事件大量积压导致的内存泄漏这是高级玩家才会遇到的问题长时间跑大量用例后Minium的执行速度越来越慢甚至操作响应从几百毫秒飙升到几秒。根因小程序端注入的执行器和Python侧的连接之间有大量未消费的事件消息在积压。比如你mock了一堆请求但用例中没有消费这些回调数据它们就一直占用内存。解决方法每个测试用例结束后手动清理Mock和回调注册在fixture的teardown环节调用mini.clear_mock()和mini.clear_report_data()如果仍然积压考虑强制断开重连每跑完一定数量的用例就重置一次连接pytest.fixture(scopemodule, autouseTrue) def clean_resources(mini): yield # 模块结束后清理所有mock和上报数据 mini.clear_mock() mini.clear_report_data()7.5 混合应用小程序 WebView的自动化困境现在很多小程序内嵌了WebView页面比如用WebView打开一个活动页。Minium本身不直接支持操作WebView内部的元素。这种情况的一般做法是对WebView内的内容做接口级断言用mock_request拦截WebView的接口数据验证返回如果必须操作WebView内部元素需要切换上下文用CDP协议直接操作或用Appium叠加实现但目前我实测下来不用去碰WebView内部是最好的选择尽可能把逻辑断言放在小程序原生部分完成。小程序页面跳转到WebView时用例里最多断言一下当前页面确实是WebView页面然后立即返回上一页继续跑后续逻辑。8. Math壳之下的正确认知自定义测试的边界最后多唠叨几句边界问题。Minium很强大但它并不是万能的自定义测试也不是万能的。最典型的边界就是小程序端依赖真实网络环境的能力不可控。比如某些短信验证码登录、第三方SDK授权这类强依赖外部系统的流程自动化测试根本没法绕过去。对于这种情况最务实的做法是用Mock把接口阶段拦截掉只验证页面交互和前端逻辑外部系统的正确性交给联调阶段去验证。另一个边界是小程序的各种入口形态比如从App内分享卡片进入小程序很难在测试中完整复现。这类场景只能靠手工测试配合真机来补充自动化测试的目标不是覆盖所有场景而是把高价值、高频回归的路径守住。这些认知是跑了一段时间自动化测试之后逐渐建立的。刚开始做自定义测试时我也试图把用例覆盖到所有业务角落一度陷入什么都想自动化的焦虑中。后来明白了一个道理自动化测试是测试体系的一部分它的核心价值是稳定地守住核心逻辑的回归而不是替代所有手工探索。把Minium跑稳、把自定义断言体系沉淀好、把pytest集成摸透你就已经建立了一套可以陪伴产品长期迭代的自动化防线。剩下的那些边边角角交给手工测试本身也是一种合理的选择。