ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Appium实战:TPshop APP频道搜索新闻自动化测试全解析

Appium实战:TPshop APP频道搜索新闻自动化测试全解析 这篇是这个系列的第八篇。前面七篇把Appium环境、POM框架、模拟器连接这些地基活儿干完了今天开始进入真正的业务实战以TPshop这个开源商城项目为被测对象在APP端完成“根据频道搜索新闻”这条场景。这条场景看起来简单实际上把元素定位、显式等待、数据准备、结果断言、异常弹窗处理全部串起来了非常适合作为UI自动化测试的进阶练手项目。文章会从页面逻辑拆解开始一步步给出可落地的代码最后把我在实战中踩过的坑一并交代清楚。1. 项目背景与场景拆解先搞清楚这条用例在测什么1.1 TPshop这个开源项目为什么测试圈总拿它练手很多做UI自动化的同学都会有同一个困惑框架学了一堆但找不到一个合适的项目来练手。拿淘宝、京东这种公网产品去测数据不可控网络不稳定推荐算法还会随时改变页面内容脚本今天能跑、明天就红。而自己写个Demo页又太假学不到真东西。TPshop恰好解决了这个问题。它是一个开源的B2C商城系统基于PHP开发商品、购物车、订单、会员、文章资讯这些电商业务里常见的模块全都有。更重要的是它是可以本地部署的把代码下下来配好PHP环境和数据库跑起来就是一个完整的商城。测试时你可以随意改数据、清缓存、重置环境不需要担心影响真实用户也不需要等产品经理给你造数。这次用到的资讯模块在TPshop后台对应的是“文章分类”和“文章管理”。你可以在后台建若干个分类把它们理解为APP端的“频道”然后在每个分类下面发布测试文章也就是我们说的“新闻”。前台APP里用户点击频道Tab看到的就是对应分类下的文章列表再配合搜索入口进行关键词检索。这样一个“频道搜索新闻”的测试场景就完整闭环了。1.2 “根据频道搜索新闻”到底是什么页面逻辑这条场景的操作路径可以拆成四步进入指定频道、触发搜索入口、输入关键词、查看搜索结果。我实际部署后的页面结构是这样的APP首页顶部有一条频道栏比如“推荐”“行业动态”“促销活动”“新闻资讯”每个频道对应后台的一个文章分类。点击“行业动态”频道后页面顶部会出现一个搜索框输入“活动”并点击搜索下方列表展示该频道下标题包含“活动”的新闻。如果这个频道下没有匹配数据则显示空结果提示。从测试角度看真正的价值点在于“频道”和“关键词”这两个条件的组合。普通搜索只验证关键词是否生效而“根据频道搜索新闻”还要额外验证频道维度有没有生效我在“行业动态”里搜“活动”返回的结果必须是“行业动态”分类下包含“活动”的文章不能把“促销活动”频道里的文章混进来。这比单独的搜索用例多了一层业务约束也是这条用例容易写错、容易踩坑的地方。顺带说明一下这套页面逻辑不止适用于TPshop。如果你们公司做的APP是信息流类产品比如资讯客户端、短视频平台、社区产品几乎都有频道的概念也都有搜索入口。把我在下文里写的这套用例结构和脚本逻辑吃透换到任何一个频道类产品上都能直接平移只需要调整元素定位和页面路径。2. 测试方案设计工具、用例矩阵与可控数据2.1 不是跟风Playwright为什么还是选了Appium Python POM先说结论这轮实战我用的是Appium 2.x Python 3.10 appium-python-client 3.x脚本结构按照Page Object Model来组织。我知道现在一聊Web自动化大家都会提Playwright它确实在Web端非常好用选择器丰富、自动等待做得优秀。但这次被测对象是APP而且TPshop的APP端混合了原生页面和WebView页面玩真的还得看Appium在真机、模拟器、混合应用上的成熟生态。Airtest我也考虑过它在游戏测试和图像识别上有优势上手门槛低但做元素级断言和复杂业务校验时不够灵活。Appium的优势是跨平台、支持Native和WebView切换、社区资料多出了问题几乎都能搜到解决方案。在写脚本之前我把测试方案定成了三层最底层是BasePage负责封装启动、点击、输入、等待这些基础动作中间层是按页面划分的Page Object比如频道页、搜索页、搜索结果页最上层是测试用例只描述业务步骤和断言不关心元素是怎么定位的。这么设计的原因很简单UI自动化最怕页面一变脚本全废POM能让页面元素变化时只改一个类而不是去几十条用例里捞代码。Appium连接设备的Capabilities配置这里也顺手给出方便没有搭过环境的人直接参考。需要注意“platformVersion”和“deviceName”要和你自己的模拟器一致不要盲目复制。{ platformName: Android, appium:platformVersion: 12.0, appium:deviceName: emulator-5554, appium:app: /Users/tester/tpshop/app-debug.apk, appium:automationName: UiAutomator2, appium:noReset: True, appium:unicodeKeyboard: True, appium:resetKeyboard: True, appium:newCommandTimeout: 300 }2.2 用例矩阵把“搜索新闻”拆成六条有效用例很多人写自动化用例喜欢“一条流”从一个搜索词一路点到详情页点完就结束。这样能覆盖主流程但覆盖不了边界情况。“根据频道搜索新闻”这条场景我把它拆成了一个六条用例的矩阵每条用例对应一个明确的业务规则。用例编号用例描述操作步骤预期结果TC01频道内搜索有结果关键词进入指定频道输入存在的关键词点击搜索列表返回多条新闻所有标题都包含关键词TC02频道内搜索无结果关键词进入指定频道输入不存在的关键词页面展示“暂无相关新闻”提示不崩溃TC03搜索词为空进入搜索页不输入任何内容直接点击搜索提示“请输入关键词”不发起无效请求TC04搜索结果详情跳转点击搜索结果第一条成功进入新闻详情页详情页标题与列表标题一致TC05关键词与频道的组合过滤在频道A搜索“测试”切换到频道B再次搜索“测试”两次搜索结果互不串数据只展示当前频道下内容TC06连续搜索搜索完成后清空关键词再输入新词搜索页面能正常刷新新结果覆盖旧结果列表不叠加拆成这么多条看起来执行时间变长了但自动化测试的价值恰恰在于把人工容易漏掉的边界场景固化下来。做过测试的朋友应该都懂空搜索和跨频道串数据这类问题是发布前最容易漏、上线后最容易出事故的。2.3 测试数据准备让频道里的新闻“可控”UI自动化的稳定性有一个常被忽略的前提测试数据要可控。用公网产品练习时你永远不知道这个关键词到底有没有数据导致断言写也不是、不写也不是。而自己部署的TPshop数据完全掌握在手里。我的数据准备步骤是先在TPshop后台建三个频道分别是“行业动态”“促销活动”“新闻资讯”。然后往“行业动态”频道下发布若干条测试新闻标题里统一带上“活动”字样比如“三月行业交流活动报名开启”“渠道合作伙伴活动纪实”等。再往“促销活动”频道下发一批标题同样包含“活动”的新闻。这样TC05就能有效验证频道过滤两个频道都有“活动”这个词的数据但各自搜索时只能出现各自频道的内容。数据准备过程中最容易翻车的点有两个。一个是后台发布文章后APP端没有立即刷新导致脚本去搜索时列表为空这时候要先做一次下拉刷新或杀掉APP重启必要的话在测试前置步骤里调用一个数据同步操作。另一个是文章状态不是“发布”而是“草稿”或“下线”前台自然看不到。这些页面逻辑层面的问题不提前处理好脚本跑挂了排查半天往往发现根本不是代码的问题。3. 核心脚本实现从Page Object到测试用例落地3.1 项目目录POM怎么铺才不越写越乱POM最忌讳的是把所有的find_element全堆在测试文件里。我推荐一个比较通用的目录结构它并不庞大但足够把分层理清楚。tpshop_app_auto/ ├── config/ │ └── config.py # 存放Capabilities、基础URL、等待时间 ├── utils/ │ ├── driver.py # 初始化driver的fixture/工具方法 │ └── wait_utils.py # 显式等待的公共封装 ├── pages/ │ ├── base_page.py # 点击、输入、等待、滑动等基础动作 │ ├── channel_page.py # 频道页 │ ├── search_page.py # 搜索页 │ ├── search_result_page.py # 搜索结果页 │ └── detail_page.py # 新闻详情页 ├── testcases/ │ ├── conftest.py # pytest钩子、driver fixture、失败截图 │ └── test_search_news.py # 搜索相关用例 ├── reports/ # Allure报告输出目录 └── requirements.txt这个结构的好处是每个文件职责单一页面元素变了去pages目录改对应的类业务步骤变了去testcases目录改用例设备变了去config改配置。不同层之间不互相污染新人接手也能很快定位问题。3.2 频道页与搜索页的Page Object封装BasePage是所有页面对象的父类它把最常用到的基础动作封装在一起。我在项目里写了一个很简洁的版本重点是把“等待”和“操作”绑定尽可能避免直接使用time.sleep。from appium.webdriver.common.appiumby import AppiumBy from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC class BasePage: def __init__(self, driver, timeout10): self.driver driver self.timeout timeout def find(self, locator): return WebDriverWait(self.driver, self.timeout).until( EC.visibility_of_element_located(locator) ) def finds(self, locator): return WebDriverWait(self.driver, self.timeout).until( EC.visibility_of_all_elements_located(locator) ) def click(self, locator): self.find(locator).click() def input_text(self, locator, text): element self.find(locator) element.clear() element.send_keys(text)真正的页面对象要面向业务。频道页关注两件事进入某个频道以及进入频道内的搜索入口。注意频道Tab的文本是动态的不能用死定位我用XPath做参数化拼接。from appium.webdriver.common.appiumby import AppiumBy from pages.base_page import BasePage class ChannelPage(BasePage): channel_tab (AppiumBy.XPATH, //android.widget.TextView[text{}]) search_entry (AppiumBy.ID, com.tpshop.app:id/search_btn) def enter_channel(self, channel_name: str): locator (self.channel_tab[0], self.channel_tab[1].format(channel_name)) self.click(locator) def tap_search_entry(self): self.click(self.search_entry)搜索页则负责输入关键词并触发搜索这属于页面内的动作和频道页解耦。from appium.webdriver.common.appiumby import AppiumBy from pages.base_page import BasePage class SearchPage(BasePage): search_input (AppiumBy.ID, com.tpshop.app:id/search_input) search_btn (AppiumBy.ID, com.tpshop.app:id/search_btn) def input_keyword(self, keyword: str): self.input_text(self.search_input, keyword) def tap_search(self): self.click(self.search_btn)这里有人可能会问为什么频道页和搜索页都有search_entry/search_btnID也一样会不会冲突不会。频道页的搜索入口是“进入搜索页的按钮”搜索页的search_btn是“触发搜索的按钮”它们虽然ID相同但处于不同页面用不同类区分后语义非常清楚。如果只图省事把全部定位写在一个类里后面改动时反而容易混淆。3.3 核心用例test_search_news_by_channel实现用例层只做一件事把业务步骤翻译成代码调用。以TC01为例正常搜索场景的核心步骤包括进入频道、打开搜索、输入关键词、点击搜索、等待结果、断言结果。import pytest from pages.channel_page import ChannelPage from pages.search_page import SearchPage from pages.search_result_page import SearchResultPage from pages.detail_page import DetailPage pytest.mark.parametrize(channel,keyword, [(行业动态, 活动)]) def test_search_news_by_channel(driver, channel, keyword): channel_page ChannelPage(driver) channel_page.enter_channel(channel) channel_page.tap_search_entry() search_page SearchPage(driver) search_page.input_keyword(keyword) search_page.tap_search() result_page SearchResultPage(driver) assert result_page.get_result_count() 0, 频道【{}】下关键词【{}】应有搜索结果.format(channel, keyword) titles result_page.get_news_titles() assert all(keyword in title for title in titles), 存在标题不包含关键词的新闻{}.format(titles) first_title titles[0] result_page.click_first_result() detail_page DetailPage(driver) assert detail_page.get_title() first_title, 详情页标题与列表标题不一致SearchResultPage里需要实现get_result_count、get_news_titles、click_first_result这几个方法它们都围绕结果列表的定位来做。这里有一个值得注意的小点get_result_count如果等待不到结果会直接抛超时异常但有些产品在无结果时会显示“暂无数据”而不是隐藏列表所以这个方法内部要使用一种更宽容的等待方式比如先等待“搜索完成标记”出现再统计列表数量不能一上来就死等元素。from appium.webdriver.common.appiumby import AppiumBy from pages.base_page import BasePage class SearchResultPage(BasePage): result_list (AppiumBy.ID, com.tpshop.app:id/news_list) result_title (AppiumBy.ID, com.tpshop.app:id/tv_news_title) empty_tip (AppiumBy.ID, com.tpshop.app:id/tv_empty) def get_result_count(self) - int: if self.driver.find_elements(*self.empty_tip): return 0 return len(self.driver.find_elements(*self.result_title)) def get_news_titles(self) - list: elements self.finds(self.result_title) return [element.text for element in elements] def click_first_result(self): self.finds(self.result_title)[0].click()3.4 断言设计与Allure失败截图这条用例里的断言我写了三个层级每一层都有它存在的意义。第一层是“结果数量大于0”破的是数据侧问题比如频道没数据、关键词输错。第二层是“所有标题都包含关键词”破的是搜索逻辑和渲染逻辑问题。第三层是“详情页标题与列表标题一致”破的是跳转链路和详情页取值问题。如果只断言第一个脚本会一直绿但它根本没有守住“搜索结果匹配关键词”这条业务规则。失败截图建议接到pytest的钩子里不用每条用例手动处理。我习惯的做法是写一个conftest.py在测试失败时自动截图并附着到Allure报告。import allure import pytest from allure_commons.types import AttachmentType pytest.hookimpl(hookwrapperTrue) def pytest_runtest_makereport(item, call): outcome yield report outcome.get_result() if report.when call and report.failed: driver item.funcargs.get(driver) if driver: allure.attach(driver.get_screenshot_as_png(), namefailure_screenshot, attachment_typeAttachmentType.PNG)加上这个钩子以后红了的用例在Allure报告里能直接看到失败瞬间的界面截图省去了一边看日志一边脑补界面的痛苦。UI自动化里报告的可读性和脚本本身一样重要。4. 稳定性复盘这些坑我跑了两三天才绕过去4.1 元素找不到弹窗、等待、动态控件一锅端这条用例第一次跑的时候我挂在第一步进入频道页永远报“element not found”。后来手动点了一遍APP发现问题出在启动后的弹窗上。TPshop APP首次启动会弹“用户隐私协议”有时候还会弹“发现新版本”的更新提示。自动化脚本启动APP后直接去点频道Tab隐私协议弹窗把整个页面盖住了元素自然定位不到。解决办法是两条腿走路。一条是显式等待把sleep换成WebDriverWait等目标元素可见再操作。另一条是弹窗处理在BasePage里加一个close_popup_if_exists方法在进入具体页面之前先尝试关闭已知的弹窗。重点在于这里的“尝试”二字弹窗不一定每次都有所以要用find_elements判断存在才去点击不能直接find_element然后等着报错。def close_popup_if_exists(self, popup_id: str): elements self.driver.find_elements(AppiumBy.ID, popup_id) if elements: elements[0].click()这个经验背后的通用逻辑是UI自动化的不稳定大多不是框架的问题而是没有把“页面初始状态”控制好。业务逻辑复杂不是问题状态不确定才是问题。4.2 混合APP的WebView上下文切换TPshop的资讯类频道页有一部分内容是通过WebView渲染的。Appium默认只操作Native视图对WebView里的元素是感知不到的。我第一次执行搜索时列表明明已经加载出来了但通过Appium Inspector去看页面结构只能看到外层框架里面的新闻标题全部来自WebView。这一步要用到Appium的上下文切换能力。先通过driver.contexts拿到当前可用的上下文列表一般会有一个“NATIVE_APP”和一到多个“WEBVIEW_com.tpshop.app”。切换到WebView之后才能像Web自动化一样用CSS或XPath去定位新闻标题。def switch_to_webview(self, context_nameNone): contexts self.driver.contexts webview_context [c for c in contexts if c.startswith(WEBVIEW)] if not webview_context: raise RuntimeError(未找到WebView上下文) self.driver.switch_to.context(webview_context[0] if context_name is None else context_name) def switch_to_native(self): self.driver.switch_to.context(NATIVE_APP)这里面有两个很容易踩的坑。一个是环境里的WebView版本和本机ChromeDriver版本不匹配会导致切换后无法操作元素这种报错信息里通常会提示“ChromeDriver version mismatch”解决方法是下载对应版本的ChromeDriver放到Appium配置目录下。另一个是切换上下文之后原来的Native元素定位全部失效所以用完一定要切回NATIVE_APP不然后面用例的第一步就会报错。4.3 中文输入丢失与软键盘干扰第一次执行TC01我眼睁睁看着脚本把“活动”两个字输入进去点击搜索后搜索结果却是空的。手动去APP里看搜索框里面根本没有字。原因是Appium默认的输入方式在某些Android设备上对中文支持不好字符通过adb输入时可能被系统输入法拦截或丢弃。我当时采用的方案是在Capabilities里同时打开unicodeKeyboard和resetKeyboard两个开关让Appium使用自带的Unicode输入法输入完成后自动重置。这个配置在上文第2.1小节的Capabilities里已经写到了这里再次强调一下它的作用。还有一个小技巧是输入文本前先让输入框获取焦点即先点击输入框再调用send_keys避免焦点不在输入框时输入内容被吞掉。如果你遇到比较顽固的输入问题可以在输入后加一个回读校验把输入框里的实际文本读出来和预期对比不一致就重试一次。UI自动化的中文输入历来是玄学这条防线值得加。4.4 真机与模拟器行为不一致怎么办在模拟器上跑得好好的脚本换到一台小米真机上一开机就报错。打开日志发现是系统弹窗变了MIUI会在启动APP时弹“允许显示在其他应用上层”等权限请求这个弹窗在模拟器上根本不会出现。还有部分真机存在虚拟按键、刘海屏适配导致页面元素的坐标位置偏移。这问题没有一步到位的解法我的习惯是固定一套测试设备组合不要今天模拟器、明天真机地来回换不然脚本稳定性永远在追着环境跑。如果团队只有真机资源那就以真机为准把所有特有弹窗写进初始化处理里。如果日常以模拟器为主就固定某一种模拟器镜像比如Android 12的x86镜像避免频繁切换系统版本。5. 让用例长期稳定执行顺序与CI接入5.1 前置状态恢复避免用例之间互相“埋雷”六条用例如果放在同一个测试类里顺序执行很可能会出现用例间状态污染。比如TC04点击了详情页下一条TC01再次执行“进入频道”时页面还停留在详情页根本看不到频道Tab自然找不到元素。解决思路是每个用例执行之前都保证APP回到一个确定的状态。我通常会在driver的fixture里做处理启动时如果noReset配了True那么上一次的页面状态会被保留这时候必须手动做一次“回到首页”的动作。如果APP支持返回键就通过driver.back()连续返回到首页如果不确定当前在哪一层更保险的做法是杀掉APP再重新启动。pytest.fixture(scopefunction) def driver(): appium_driver webdriver.Remote(http://127.0.0.1:4723/wd/hub, desired_caps) yield appium_driver appium_driver.quit()用例之间还要注意数据隔离。比如TC02搜索了不存在的关键词如果这个关键词真的在后台被其他用例创建了数据结果就会不可预期。所以我在设计测试数据时会让每类测试数据都有清晰的前缀比如“UI_TEST_KEYWORD”方便识别和清理。5.2 接进Jenkins做每日回归脚本写好了只放在本地偶尔跑一次意义不大。自动化测试的长期价值在于持续回归。我在项目里用Jenkins配置了一个定时任务每天凌晨跑一次TPshop APP的关键用例集。接入方式不复杂Jenkins服务器上安装Appium Server配置一台连接了模拟器的构建节点然后在构建任务里执行两条命令一条跑pytest生成Allure结果一条把结果渲染成报告。python -m pytest testcases/ -s -q --alluredir./reports/allure-results allure generate ./reports/allure-results -o ./reports/allure-report --clean跑完如果红了Jenkins会通过邮件或钉钉通知。这里我想强调一个观点定时跑不是目的定时跑之后有人看才是目的。如果脚本挂在那边红了半个月没人管那它和没写没有任何区别。建议第一周每天盯一下报告逐步把稳定性提到95%以上后面才能真正解放双手。6. 最后聊几句个人体会做UI自动化这几年我最大的体会有两个。第一个是搜索场景值得重点做因为搜索几乎是所有业务系统的高频入口而且改动频繁、最容易出现回归问题。把“根据频道搜索新闻”这类场景固化下来不是指望它发现多复杂的隐藏Bug而是守住“搜索基本能力没有坏”这条底线。第二个体会上文其实也提过稳定性的核心不是脚本技巧而是数据、状态和设备这几件事的确定性。数据可控、页面初始状态可控、设备环境固定脚本自然就稳了。最后分享一个小技巧搜索类用例的断言不要只做“有没有结果”尽量做“结果是否符合预期条件”。能用“标题包含关键词”就不要用“列表数量大于0”能校验详情页跳转就不要停在列表页。断言向前推一层能挡住的问题多得多。这个场景做完之后后续可以顺着同一条路径继续扩展比如把频道切换、分页加载、搜索历史这些功能点逐个补成独立用例。UI自动化的路就是一条一条用例堆出来的但每一条都要想清楚它到底在守什么。
返回列表