尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

TPshop频道搜索新闻UI自动化:从元素定位到稳定断言的全流程实战

TPshop频道搜索新闻UI自动化:从元素定位到稳定断言的全流程实战 做APP端UI自动化测试最难受的不是脚本写不出来而是那种看着毫无难度的用例真跑到你面前各种花式翻车。就比如TPshop项目实战里的根据频道搜索新闻从用户视角看就是点一个频道、输入关键词、看结果最多三步但从自动化实现的角度这一个用例把元素定位、动态等待、多页面联动、断言设计全串起来了稍微有一环不扎实脚本跑个十几次就开始闹脾气。这篇文章我会把整个场景从业务梳理到定位策略、页面对象设计、代码实现、稳定性处理完整过一遍中间穿插实测踩坑记录适合正在学APP自动化、或者刚接手TPshop这类实战项目的测试工程师参考。1. 先搞清楚频道搜索新闻在TPshop APP里的业务路径1.1 一次完整的用户操作是怎样的很多人拿到用例就急着写代码这是最容易翻车的起点。我先说说TPshop这个项目里根据频道搜索新闻到底是一条什么样的业务链路。TPshop的APP端除了商品交易主流程还有独立的资讯/新闻模块里面按频道划分了内容比如头条科技体育财经这类。用户的操作路径是进入资讯区域选择一个频道在频道页触发搜索输入关键词系统返回该频道下相关的新闻列表。注意关键词是该频道下。也就是说这不是全站搜索而是频道内搜索。用户选了科技那搜索结果里就不该出现属于体育频道的内容。这个差异对测试断言设计有直接影响后面我会重点讲。在动手写自动化代码之前最值得做的动作是用手工把这条路径完整走一遍每一步都记录下界面状态。包括进入资讯模块后默认展示什么频道、频道栏是横向滚动的还是tab式、搜索入口在页面哪个位置、搜索页是否有历史记录、提交搜索后结果是即时刷新还是要点按钮。这些细节会在定位策略里全部派上用场。1.2 为什么这类用例容易写废根据频道搜索新闻卡住大多数人的原因是它同时踩了几个APP测试的典型难点。首先是混合内容页面的动态加载。频道列表往往是后加载的首页先出来框架频道元素才慢慢填充脚本如果一进页面就去找科技这个频道经常撞上元素不存在的异常。其次是列表型页面的元素复用问题新闻列表里每条内容的布局结构基本一致XPath写宽了会匹配到一堆节点写窄了又选不中目标。第三是不同控件类型混搭频道栏可能是TextView也可能是带自定义样式的ViewGroup搜索框可能是EditText也可能是伪装成输入的控件。每换一个控件类型定位手段就要跟着调整。更麻烦的是一旦脚本运行环境变了分辨率、系统版本、模拟器还是真机控件的坐标和层级都可能变化。这也是为什么我强调必须先手工走一遍并观察控件属性而不是拿着目录结构猜。1.3 用例的验收标准实现之前先定好验收标准这样写断言时才知道往哪个方向使劲。我个人理解这个用例至少要覆盖三点搜索入口可以正常打开且默认焦点在搜索输入框选择了指定频道后搜索结果列表返回的内容确实属于该频道且包含搜索关键词无搜索结果时页面有符合预期的空态提示而不是白屏或一直转圈。至于要不要校验搜索历史的展示、是否记录本次关键词我觉得可以作为附加断言但不要塞进主流程里否则用例会被额外不稳定因素拖累。先把主路径跑稳再去扩展。2. 环境准备一个能稳定跑Appium的Android环境2.1 设备与驱动选型APP端UI自动化测试的技术栈目前比较主流的组合是Appium UiAutomator2驱动 Python。Appium本身做的事情是协议翻译真正在Android设备上执行操作的是它的驱动和底层框架。早期大家习惯装Appium 1.x然后单独依赖一堆插件到了Appium 2.x时代驱动管理变成了命令安装干净了不少。如果你还在用Appium 1.x我会建议尽快切到2.x因为社区持续迭代新功能基本都在2.x维护。安装驱动只需要一条命令appium driver install uiautomator2设备方面我倾向于用真机或Android官方模拟器跑尽量不要用国产第三方模拟器做标准回归同一套代码在第三方模拟器上的控件渲染偶发差异会消耗你大量排查时间。当然如果只是学习阶段手边有什么用什么但心里要清楚稳定压倒一切降低变量是测试运维的基本功。2.2 Desired Capabilities配置写启动配置的时候很多人的配置文件是直接从网上抄来的抄完报错就一头雾水。我这里给一份实际用过的配置逐项说明作用from appium import webdriver caps { platformName: Android, appium:platformVersion: 13, appium:deviceName: emulator-5554, appium:appPackage: com.tpshop.app, appium:appActivity: .MainActivity, appium:noReset: True, appium:unicodeKeyboard: True, appium:resetKeyboard: True, appium:automationName: UiAutomator2, appium:newCommandTimeout: 300, } driver webdriver.Remote(http://127.0.0.1:4723/wd/hub, caps)noReset设为True表示跑完用例不清理APP的数据状态。好处是启动快坏处是数据可能污染后面踩坑部分会细说。unicodeKeyboard和resetKeyboard配合使用是为了解决脚本向输入框发送中文文本时出现乱码或无法输入的问题。这里的内在逻辑是Appium默认使用系统输入法但很多设备不支持Unicode的直接注入所以要主动切换到专用输入法再在用例结束后重置。automationName必须写上UiAutomator2这是Android端默认高性能驱动的关键角色。还有一个容易被忽略的启动参数appActivity。如果填错了Appium在启动时可能提示找不到Activity或者直接拉起了一个错误页面。正确获取Activity的方法是用adb命令adb shell dumpsys package com.tpshop.app | grep -E Activity看输出的运行历史里最后一次触达的Activity值。这种细节查一次能省半天的排查时间。2.3 测试数据准备频道和新闻词条自动化用例跑起来能不能稳定复现很大程度取决于测试数据是否可控。TPshop的资讯频道数据通常由后台管理配置但UI自动化没法保证每次都连同一套后台数据。所以我建议在环境准备阶段先做两件事第一固定测试账号和默认频道配置。如果APP支持未登录浏览至少确认未登录状态下资讯模块的频道列表是固定的一组如果需要登录就把登录态相关的用例拆开不要和搜索用例耦合。第二准备独立的搜索词-预期结果映射表。这里的搜索词不能随意选要挑选在目标频道内有稳定结果的词。比如在科技频道搜索人工智能预期列表里一定存在至少一条内容如果搜了一个冷门词断言就能通过也可能因为数据下架导致偶发失败用例就变成了一颗定时炸弹。数据隔离的思路和接口测试用独立测试环境是一个道理数据干净断言才干净。3. 页面对象设计把选频道和搜新闻拆成可复用单元3.1 频道选择页的页面对象写UI自动化最忌讳的就是在所有用例里直接用一堆 locator 和 driver.find_element用例一多就变成到处重复的面条代码。页面对象模式Page Object ModelPOM解决的就是这个问题把某个页面独有的元素和操作封装成一个类测试用例只跟页面对象打交道不直接碰定位符。频道选择这块我通常写成这样class ChannelPage: def __init__(self, driver): self.driver driver self.channel_root (AppiumBy.ID, com.tpshop.app:id/channel_tabs) def select_channel(self, channel_name: str): root WebDriverWait(self.driver, 10).until( EC.visibility_of_element_located(self.channel_root) ) channel_xpath ( f//android.widget.FrameLayout[resource-idcom.tpshop.app:id/channel_tabs] f//android.widget.TextView[text{channel_name}] ) el root.find_element(AppiumBy.XPATH, channel_xpath) el.click()注意这里的一个细节我先找到了频道栏的根节点再在根节点内部去查具体的频道文本。这样做的核心用意是把查找范围缩小到频道栏容器里避免页面其他地方也存在一个同名TextView时发生误匹配。页面对象不应该只是一个定位符的集合它更应该是一个用户在这个页面能做的操作的抽象。select_channel方法的名字就直接表达了用户意图。3.2 搜索流程的页面对象设计搜索这个动作横跨两个页面一个是频道页上的搜索入口一个是搜索页本身。我习惯把它们分开封装因为复用性完全不同搜索入口可能首页也有搜索页则是全局唯一的。搜索入口封装成独立对象后商品搜索、新闻搜索可以直接复用同一个打开搜索页的方法。class SearchEntry: def __init__(self, driver): self.driver driver def open_search(self): entry WebDriverWait(self.driver, 10).until( EC.element_to_be_clickable((AppiumBy.ID, com.tpshop.app:id/iv_search)) ) entry.click() def input_keyword(self, keyword: str): input_box WebDriverWait(self.driver, 10).until( EC.visibility_of_element_located((AppiumBy.ID, com.tpshop.app:id/search_input)) ) input_box.send_keys(keyword) def submit_search(self): confirm_btn self.driver.find_element(AppiumBy.ID, com.tpshop.app:id/btn_search) confirm_btn.click()一个值得强调的点不要在页面对象初始化时执行任何元素查找只保存定位符。元素查找延迟到动作调用时才做是为了避免页面未加载完成时构造对象就把元素找了一遍导致后面真正点击时元素已经过期出现StaleElementReferenceException。3.3 划分粒度背后的思考单纯把代码拆成类不难难在边界划在哪里。我的经验是按操作语义而非页面切换来划。同样是输入关键词在频道页搜索框输入和在全站搜索框输入虽然控件ID可能相同但语义不同就应该拆成不同方法不要让调用方通过传参选择输入框。测试用例读起来应该像一句话操作流程channel_page.select_channel(科技) entry.open_search() entry.input_keyword(人工智能) entry.submit_search() results_page.assert_has_results()这样即使不懂代码的业务人员也能大致读懂用例在测什么。这也反过来倒逼我们把方法名取得足够直白这也是变相提高用例可维护性。4. 核心流程代码点击频道、输入关键词、卡断言4.1 完成一次搜索的主流程代码前面页面对象设计好了主流程用例反而最简单因为它只是把各个页面的动作按用户操作顺序编排起来。我第一次写的这个用例长这样def test_search_news_by_channel(driver_fixture): driver driver_fixture channel_page ChannelPage(driver) entry SearchEntry(driver) results_page SearchResultsPage(driver) channel_page.select_channel(科技) entry.open_search() entry.input_keyword(人工智能) entry.submit_search() result_titles results_page.get_result_titles() assert len(result_titles) 0, 搜索返回空列表 for title in result_titles: assert 人工智能 in title, f结果标题未包含关键词: {title}这个用例跑通很快但暴露的问题也很快。第一个问题出在频道定位和搜索结果之间少了一个当前频道确认步骤导致后续断言出现误伤时根本分不清是频道点错了还是搜索失败了。所以我在流程里加了一步actual_channel channel_page.get_selected_channel_name() assert actual_channel 科技, f当前选中频道不是科技: {actual_channel}这样可以保证前置状态正确不会因为频道栏点击失败而带着错误的状态一路跑到断言再报错。4.2 关于等待为什么是显式等待而不是sleep新人在处理页面加载时最常见的手段是time.sleep(3)我承认调试阶段我也会临时用但它绝不能出现在最终用例里原因很简单sleep时间固定快加载时浪费时间慢加载时照样失败。sleep并不能真正等元素出现它只是在赌某个时间点元素一定存在。真正可靠的是动态等待Appium生态里对应的是WebDriverWait配合expected_conditionsfrom selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC wait WebDriverWait(self.driver, 10) search_result wait.until( EC.presence_of_element_located((AppiumBy.ID, com.tpshop.app:id/news_item_title)) )这里有两类显式等待的差异要特别留意presence_of_element_located只代表元素出现在DOM/视图层级里并不代表元素可见visibility_of_element_located则要求元素可见且尺寸非零。对于弹窗覆盖、轮播图遮挡这类场景元素可能在视图中存在但被遮挡所以点击前更适合用element_to_be_clickable它会同时检查可见和可点击两个条件。页面自动化里80%的元素找不到问题本质都是存在的时机比查询的时机晚显式等待就是用来对齐这两个时机的。4.3 断言怎么写才能防止假绿最怕的不是测试失败而是测试明明运行成功了实际功能却是有问题的——这种情况叫假绿。我见过最典型的假绿写法是assert len(driver.find_elements(AppiumBy.ID, com.tpshop.app:id/news_item)) 0这段代码只能证明页面上存在新闻条目不能证明这些条目是用户预期的搜索结果。要想让断言有说服力必须往上叠加三层校验结果列表数量大于0每条结果的标题包含搜索关键词每条结果所属的频道标识与选中频道一致。结果条目列表在TPshop里通常包含标题、摘要、来源频道、发布时间这些字段。用UI自动化的方式去逐个提取这些字段是可行的但要注意不要贪多。我一般只校验标题和来源频道标签因为这两个字段能覆盖内容是否相关和频道归属是否正确的核心验收点。如果断言字段过多某次界面调整导致字段位置变化用例就会产生大量需要人工确认的失败维护成本立刻上来。class SearchResultsPage: def get_result_items(self): items self.driver.find_elements(AppiumBy.ID, com.tpshop.app:id/news_item) parsed [] for item in items: title item.find_element(AppiumBy.ID, com.tpshop.app:id/news_title).text channel_tag item.find_element(AppiumBy.ID, com.tpshop.app:id/news_channel).text parsed.append({title: title, channel: channel_tag}) return parsed断言侧我倾向于再加一组搜索结果为空的校验用例而不是只在有结果的用例里做正向验证。空结果的UI通常有专门的空态组件把空态断言写进测试套件里能提前发现搜索无结果时页面白屏这类回归隐患。5. 实测踩坑记录这个用例跑崩的四种方式5.1 浮层遮挡导致元素定位成功但点击无效这是最有迷惑性的一种失败。元素能被定位到element_to_be_clickable也通过了但click()执行后界面纹丝不动。我第一次遇到这个情况足足排查了半小时才意识到问题出在启动弹窗上。TPshop启动后会有用户协议授权弹窗、公告弹窗之类的浮层弹窗没有完全消失只是透明区域恰好不遮挡频道栏的中心点于是Appium点击坐标落在浮层上没有传给真实的频道元素。解决思路是在进入用例之前做一次全局扫弹窗处理查找已知的关闭按钮ID如果存在就点击不存在就跳过。在Android端还可以用UiAutomator2驱动自带的那个能力def dismiss_popups(driver): for close_btn_id in [com.tpshop.app:id/iv_close, com.tpshop.app:id/dialog_confirm]: close_btns driver.find_elements(AppiumBy.ID, close_btn_id) if close_btns: close_btns[0].click()这里有一个隐藏的坑多个弹窗可能同时存在比如协议弹窗之上还叠着一个公告弹窗。所以关闭一次弹窗后再扫描一次是必要的我通常会循环扫三遍确认浮层彻底清干净才进入正式操作步骤。5.2 XPath匹配到了假频道元素频道选择里我用的是//android.widget.TextView[text科技]。这句话看起来没问题但页面里可能同时存在一个底部标签栏上面也写着科技两个字比如资讯栏目本身就是底部Tab之一。这时候find_elements会返回两个节点find_element默认取第一个可能正好取到那个不该被点击的Tab。这次踩坑给我的直接教训是XPath定位永远要带路径约束不要只靠文本属性。优先在目标容器内查找能用resource-id限定父容器就绝不省略。这也是我在ChannelPage里先定位channel_root再在内部找文本的原因。如果频道栏本身没有resource-id退而求其次用层级路径约束//android.widget.HorizontalScrollView//android.widget.TextView[text科技]但要提醒一句这种依赖层级的XPath脆弱一旦界面布局调整就会失效。能用ID容器兜底就用ID容器这是稳定性优先级最高的方案。5.3 输入中文后软键盘挡住了搜索按钮用unicodeKeyboard配置解决了中文输入乱码后新的问题接踵而至输入内容后软键盘弹出恰好遮住了屏幕下方的搜索确认按钮click点击被软键盘拦截。这个问题在真机上尤其常见小屏手机会更明显。我的处理顺序是这样的先尝试driver.hide_keyboard()它是通过驱动发送隐藏键盘的指令大多数情况下有效如果无效再按返回键收起键盘try: driver.hide_keyboard() except Exception: driver.press_keycode(4) # KEYCODE_BACK还有一种绕法是不点搜索按钮直接改输入法内的搜索动作这在UiAutomator2里可以用press_keycode(84)KEYCODE_SEARCH实现但不同UI实现的响应行为不一致。稳妥起见我倾向于显式点击搜索按钮并确保键盘收起。所以只要用例中包含中文输入就一定在提交搜索前加一个键盘收起动作不要偷懒。5.4 测试数据污染让断言结果不稳定noResetTrue让用例启动速度变快但也带来了脏数据问题。搜索人工智能后第二次跑用例时搜索页出现了历史记录列表历史记录里的手机电脑等词也会出现在页面上导致我用全部标题做关键词断言时把历史记录也当成了搜索结果断言必然失败。这类问题的根子是测试数据和用例期望混在了一起。处理方案有两个方向用例启动前清理历史记录让搜索页回到无历史状态能保证断言干净但要注意清理入口的稳定性断言只统计搜索结果区域内的数据不统计整个页面可见文本这需要给结果列表限定独立的resource-id容器。我实际做的是双管齐下断言限定在结果列表容器内同时测试数据选用互相区分度高的关键词不让上一个关键词出现在下一个用例的期望里。不要小看数据隔离90%的用例跑久了自己就开始随机失败都和它有关。6. 从单条用例到可回归的测试套件6.1 数据驱动频道和关键词解耦前面写的用例是科技频道搜人工智能跑通之后立刻面临的问题是如何扩展到体育频道搜篮球财经频道搜汇率等多条场景。最笨的做法是复制粘贴五个用例但这会让维护成本翻五倍。稍微好一点的做法是用pytest的参数化把数据和用例逻辑拆开import pytest pytest.mark.parametrize( channel, keyword, [ (科技, 人工智能), (体育, 篮球), (财经, 汇率), ], ) def test_search_news_by_channel(driver_fixture, channel, keyword): ... channel_page.select_channel(channel) entry.open_search() entry.input_keyword(keyword) entry.submit_search() ...这种数据驱动方式的优点不只是少写代码更在于测试报告的可读性。每个参数组合会生成一条独立的测试记录哪条频道数据有问题可以直接定位不用在一堆大用例里翻日志。另外把参数列表放到单独的JSON/YAML文件里还能让非开发人员参与维护测试数据这在团队协作里非常实用。6.2 失败自动截图与日志归档UI自动化测试跑在无人值守环境里时失败现场是不可复现的宝贵信息。我现在跑用例的习惯是任何断言失败都自动截图并把当前页面的XML层级结构保存下来。截图用于看视觉现场XML用于看控件状态两者配合几乎能还原90%的失败原因。pytest里用钩子函数实现特别方便pytest.hookimpl(tryfirstTrue) def pytest_runtest_makereport(item, call): if call.when call and call.excinfo is not None: driver item.funcargs.get(driver_fixture) if driver: timestamp time.strftime(%Y%m%d-%H%M%S) driver.save_screenshot(fartifacts/{item.name}-{timestamp}.png) page_source driver.page_source with open(fartifacts/{item.name}-{timestamp}.xml, w, encodingutf-8) as f: f.write(page_source)这里有个经验之谈截图必须和页面源码文件名关联上最好包含时间戳和用例名否则几十条失败记录混在一起时想找对应关系会非常痛苦。另外截图不要放在项目根目录否则跑上一周就能把磁盘塞满固定归档目录并定期清理是必须做的事。6.3 还可以继续扩展的方向根据频道搜索新闻这一条用例跑稳之后它会自然演变成一个可复用的搜索能力块。同一条搜索流程可以横向扩展到TPshop的站内商品搜索、用户搜索等不同业务场景只要搜索页控件ID一致SearchEntry这个页面对象可以直接复用。再进一步如果能接触接口测试层的搜索接口可以把UI断言和接口数据结合起来做前后端一致性校验比如UI返回的结果数量应该与接口返回的数量一致。如果团队有持续集成平台还可以考虑把这条用例挂到夜间回归任务里配合短信或企业微信告警每天早上一封测试结果邮件比人肉回归靠谱得多。但前提是先解决用例自身的不稳定性否则挂在CI上只会每天制造噪音最后谁也懒得看。说回频道搜索新闻本身。我后来在这个用例身上反复打磨了将近两周最大的感受是UI自动化的难点从来不在某个独门技术上而在把每一个细节的确定性和可维护性做扎实。你愿意在一个看起来简单的用例上花多少心力决定了它能陪你在回归测试里走多远。
返回列表