ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

电商UI自动化测试从0到1:Selenium+Pytest+Page Object实战

电商UI自动化测试从0到1:Selenium+Pytest+Page Object实战

1. 动手之前把账算清楚:UI自动化的适用边界与方案选型

做UI自动化测试这件事,很多人上来就开干,先装个Selenium,再顺手写几十个脚本,结果跑了俩星期,发现每天都在修脚本,比手工点点点还累。我自己刚入行时也干过这种蠢事,在模拟项目X上踩了不少坑之后才琢磨明白:UI自动化的本质不是找一批用例去“自动化执行”,而是先想清楚哪些场景值得被自动化,以及这套东西在你的测试体系里,到底承担什么角色。

这个标题虽然叫“从0到1”,但千万别理解成“装个环境写个脚本跑通”就完事。真正从0到1,是你要把框架选型、工程结构、脚本设计、数据准备、结果反馈、后续维护这一整条链路都走通。拿电商平台来说,典型的核心流程无非是登录、搜索、看商品详情、加购、下单、支付、查订单,这一圈走下来,每一步都有页面跳转、异步请求、弹窗、状态更新,而且电商页面通常改版频繁,元素说变就变。如果你没有一套足够规范的框架在背后支撑,自动化很快就变成“自动生成失败报告”的机器。

先谈适用边界。UI自动化最适合的是回归测试和冒烟测试,尤其是那种核心主流程每轮迭代都要回归、手工反复点了几百遍的场景。比如购物车和结算流程,每次版本更新都怕影响老功能,人工回归一遍大几分钟,遇到Web端、H5、多浏览器还要翻倍,这时候UI自动化就非常值得投入。反过来,如果是偏接口的校验、异常参数组合、性能压测这类任务,UI自动化是下下策,又慢又不稳定,根本兜不住海量数据验证。

其次是方案选型。现在主流的技术栈其实相当统一而成熟:Python + Selenium + Pytest + Page Object模式,再配Allure报告和Jenkins调度。为什么是这个组合?不是因为它最酷,而是因为它最稳。

Selenium几十年生态沉淀,踩坑资料遍地都是,你遇到90%的定位问题都能搜到解决方案。Python比Java写起来轻便,尤其UI脚本里充满等待、定位、断言这些样板代码,Python的简洁能明显降低维护成本。Pytest的断言、fixture、参数化、插件体系都很好用,和Selenium搭配可以说是天作之合。

至于Page Object,这个不是可选项,而是必需品。UI脚本最怕的是一堆定位器散落在用例代码里,页面一改就得全局搜索全部替换,然后把用例逻辑改得面目全非。Page Object的核心思路是把页面元素和操作行为封装成一个类,测试用例只跟页面对象打交道,不直接碰定位器。等于是给你每个页面建了一本“说明书”,元素改了只改说明书,用例代码自然不跟着遭殃。

另外提一句,这两年很多人开始用Playwright替代Selenium,它自带等待机制和多浏览器支持确实省心,尤其在处理iframe、弹窗、下载这些场景时,体验明显比Selenium顺畅。但如果你所在团队已经沉淀了一批Selenium脚本,或者你刚入门、想找最稳妥的生态,Selenium依然是更稳妥的起点。别陷入工具之争,重点是先把这整条链路跑通。

2. 框架搭建核心:从目录设计到Page Object落地

2.1 工程结构怎么搭才不糟心

很多新手写自动化,脚本写得很随性,全堆在一个test目录里,今天心情好就多建一个文件夹,明天又懒得挪。一旦用例数量上了100,这种乱摊子基本上就废了。我一般会建这样一个目录结构:

project_root/ ├── config/ │ ├── __init__.py │ ├── settings.py # 环境地址、账号、超时时间等全局配置 │ └── data/ │ ├── user.yaml # 多环境测试账号数据 │ └── case_xxx.json # 数据驱动用例数据 ├── common/ │ ├── __init__.py │ ├── base_page.py # 封装公共方法:等待、点击、输入、截图 │ ├── log_util.py # 日志封装 │ └── shell.py # 其他通用工具 ├── page/ │ ├── __init__.py │ ├── login_page.py # 每个业务页面一个类 │ ├── search_page.py │ ├── cart_page.py │ └── order_page.py ├── testcase/ │ ├── __init__.py │ ├── conftest.py # fixture:driver初始化和销毁 │ ├── test_login.py │ ├── test_search_and_cart.py │ └── test_order.py ├── report/ │ ├── screenshot/ # 失败截图 │ └── allure-results/ └── requirements.txt

这个结构看起来很普通,但每条分目录逻辑都有讲究。config放着所有环境相关的可变因素,比如测试环境的Base URL、超时时间、账号信息,这样换环境的时候只改配置,不进脚本。common放所有复用组件,核心是BasePage,它等于把Selenium原生的find_element、click、send_keys这些API又包了一层,统一加了显式等待和失败截图。page每个类对应一个业务页面,与具体页面结构绑定。testcase放业务用例,用例只写业务步骤和断言,不写定位逻辑。

这个分层的意义很直白:当某电商平台登录页的“验证码输入框”从一个input换成自定义组件时,你只需要改login_page.py里那一个定位器;而如果登录流程新增一步“勾选用户协议”,用例层要改也很少。页面做页面的事,用例做用例的事,谁也不跨界。

2.2 BasePage公共封装:等待、点击、截图一个都不能少

我见过很多人写脚本,每个方法里反复写driver.find_element(By.ID, "xxx").click(),一旦某个元素加载慢了就随手加time.sleep(2)。这是最典型的反面教材。正确的做法是在BasePage里封装带显式等待的基础操作,我通常会写成这样:

import logging from selenium.webdriver.remote.webdriver import WebDriver from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By from datetime import datetime import os import inspect class BasePage: def __init__(self, driver: WebDriver): self.driver = driver self.timeout = 20 self.logger = logging.getLogger(__name__) def find_element(self, by: By, locator: str): try: element = WebDriverWait(self.driver, self.timeout).until( EC.presence_of_element_located((by, locator)) ) return element except Exception as e: self.screenshot(inspect.stack()[1].function) self.logger.error(f"定位失败: {by}={locator}, 错误: {e}") raise def click(self, by: By, locator: str): element = self.find_element(by, locator) try: WebDriverWait(self.driver, 5).until(EC.element_to_be_clickable((by, locator))) element.click() except Exception as e: self.screenshot(inspect.stack()[1].function) self.logger.error(f"点击失败: {by}={locator}, 错误: {e}") raise def input_text(self, by: By, locator: str, text: str): element = self.find_element(by, locator) element.clear() element.send_keys(text) def screenshot(self, name): """失败时自动截图,存到report/screenshot目录,命名带上页面方法和时间戳""" os.makedirs("report/screenshot", exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") path = f"report/screenshot/{name}_{timestamp}.png" self.driver.save_screenshot(path) self.logger.info(f"截图已保存: {path}")

为什么find_element里一定用presence_of_element_located而不是visibility_of_element_located?这是很多人的认知盲区。在电商页面里,很多元素是“已经渲染在DOM里但还没可视”的,比如商品列表页的“加入购物车”按钮,可能一开始在屏幕外,需要滚动到可视区域才能被点击,但它的定位器已经可用了。presence只要求元素在DOM中存在,后续如果你要做点击操作,再结合element_to_be_clickable来确保它可交互。如果你一上来就要求visibility,很多场景反而会把明明可以下一步的元素给卡死。

而且你注意我用了inspect.stack()来定位当前调用页面对象的方法名,这样截图命名会带上具体调用者,排查失败时一眼就能看到是哪一步出了岔子。这种细节看着不起眼,真正跑起用例来体验天差地别。

2.3 Pytest fixture管理浏览器生命周期

光有BasePage还不够,你还需要一个干净利落的driver管理方式。用Pytest的fixture来做是最省心的,它在每个用例前启动浏览器、用例结束后自动回收,失败时还能额外做点处理:

import pytest import os from selenium import webdriver from selenium.webdriver.chrome.options import Options @pytest.fixture(scope="function") def driver(): options = Options() options.add_argument("--window-size=1440,900") options.add_argument("--ignore-ssl-errors=yes") options.add_argument("--ignore-certificate-errors") # 无头模式视情况开,排查脚本时强烈建议开有头模式 if os.getenv("HEADLESS", "0") == "1": options.add_argument("--headless=new") driver = webdriver.Chrome(options=options) driver.set_page_load_timeout(30) driver.implicitly_wait(5) yield driver driver.quit()

scope="function"是默认行为,每条用例一个全新浏览器,互不干扰。有人为了提高速度,改成scope="module"甚至scope="session",让所有用例共用一个浏览器,听起来省事,实际会在用例之间留下严重状态污染:上一条用例的登录态、弹窗、搜索历史都可能让下一条用例莫名失败,定位成本极高。我的经验是,UI自动化的稳定性远比执行速度更重要,宁可每个用例多花几秒启动,也不要为了省那几秒把自己埋在排查地狱里。

implicitly_wait(5)和BasePage里的显式等待同时存在,很多人怕冲突。实际上Selenium的官方建议就是两者可以共存:隐式等待设一个保底下限,相当于“全局粗糙兜底”;显式等待在关键元素上精确定制超时时间。但要注意,Selenium明确说了隐式等待在find_element时生效,而显式等待作用于具体的WebDriverWait轮询,两者机制不同,不会产生互相吞并的情况。当然,如果实在担心,隐式等待也可以直接设成0,核心等待全交给显式来做,这样行为最可控。

pytest的fixture还天然支持teardown阶段收集测试结果。比如用例失败时,可以顺便把当前页面HTML源码存下来,这对排查那种“截图看起来没什么异常但断言就是不通过”的诡异问题极其有用:

@pytest.hookimpl(hookwrapper=True) 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: driver.save_screenshot(f"report/screenshot/failed_{item.name}.png")

加上这段,你每次失败至少能看到两条线索:失败截图和页面状态快照。比拿到一串AssertionError再去脑补现场要强太多了。

3. 电商核心链路脚本实操:从登录到下单全流程拆开揉碎

3.1 登录与登录态保持:别让每条用例都被验证码卡住

登录是所有电商流程的入口,也是最容易被卡脖子的环节。很多平台在登录环节加了滑块验证码或图形验证码,自动化脚本遇到这种基本就卡死了,想靠算法去破解验证码,既不现实也不稳妥。这时候的正解是登录态复用——把已经通过验证码登录的浏览器会话的cookie保存下来,其他用例直接注入,跳过登录这个步骤。

操作思路大概是这样的:先手动跑一次真实登录,拿到登录成功后的cookies,然后序列化保存到本地文件。在自动化脚本里,启动浏览器后先把这些cookie灌进去,再刷新页面,就能直接进入已登录状态:

import pickle from selenium import webdriver # 保存cookie:登录成功后执行 def save_cookies(driver, path="config/data/cookies.pkl"): with open(path, "wb") as f: pickle.dump(driver.get_cookies(), f) # 注入cookie:启动浏览器后,先访问一次域名,再添加cookie def load_cookies(driver, path="config/data/cookies.pkl"): driver.get("https://pre.xxx.com/") # 先进入目标域名,否则cookie不生效 with open(path, "rb") as f: cookies = pickle.load(f) for cookie in cookies: driver.add_cookie(cookie) driver.refresh()

这个方案有几点要特别提醒。cookie是有时效的,通常一两天就会失效,所以你要在conftest里对cookie过期做检测,比如加载后跳转到首页,发现仍然跳到登录页,就标记这条用例为“登录状态失效,需要更新cookie”,而不是让它继续往下跑白白失败。第二个注意事项是不同环境的cookie不通用,预发布、测试环境、生产环境之间的域名和加密方式完全不同,cookie要按环境分开保存。

至于用例本身,我建议只保留少量几条真正的登录用例,覆盖账号密码正确、密码错误、账号不存在、必填校验等。其余业务用例全都走cookie注入的通道,这样既绕过了验证码无谓地消耗时间,又能保持业务链路的独立性。

3.2 搜索与商品列表:异步加载和动态内容怎么处理

搜索是电商最常见的入口动作。拿一个简单的“关键词搜索-点击商品-进入详情”流程来说,代码写起来最核心的考验就是异步加载。

很多电商搜索页并不是一次性把商品列表全部渲染出来的,而是先返回骨架屏,再通过接口分批加载数据。如果你在输入搜索词之后马上点击“搜索”按钮,表面上按钮似乎没变,但实际页面可能已经发生了局部刷新。这时候用固定time.sleep(3)去等,极不稳定,网络快的时候3秒浪费,网络慢的时候3秒又不够。

显式等待在这里才是正解。搜索结果的断言条件,应该从“某个商品卡片出现”推到“搜索结果的关键内容发生变化”。我习惯先定位第一个搜索结果卡片,然后等卡片里的商品标题文本包含搜索关键词:

def search(self, keyword): self.input_text(By.ID, "search-input", keyword) self.click(By.ID, "search-button") # 等待搜索结果的第一个卡片加载完成 WebDriverWait(self.driver, 10).until( EC.presence_of_element_located((By.XPATH, f"//div[contains(@class, 'product-card')]//span[contains(text(), '{keyword}')]")) )

这里有个容易搞错的地方:presence_of_element_located只判断元素在不在,不判断是不是覆盖了旧结果。如果页面做了异步替换,旧结果先不动,网络返回后才替换DOM,你定位到的可能是旧元素。所以更严格的做法是等旧结果消失、新结果出现,或者干脆在发起搜索之前,先记录当前页面的URL或某个状态标志。

搜索页还有一个典型难点,就是“搜索结果为空”的用例。这时候根本没有商品卡片,你等的那个XPath永远等不到。我的做法是先点击搜索,再判断页面上是否存在“没有找到相关商品”这类文案,有则断言为空结果集,没有再去等商品卡片。这就是把“异常路径”和“正常路径”分开处理,避免一个用例写了两种互相冲突的等待条件。

3.3 商品详情、SKU选择与加购:电商独有的那些交互坑

商品详情页是整个流程里交互元素最多的地方,也是UI自动化最容易留下“脚本脆弱”隐患的重灾区。

先说SKU选择。电商的SKU通常是“颜色、尺寸、版本”这种多级联动,点击某个颜色后,可选的尺寸组合会随之变化,不可选的甚至置灰。写脚本时,如果直接按固定索引去点“红色、M码”,页面改版后极容易找不到元素。我的建议是,把SKU选项的定位尽量写到业务可理解的层度,比如“颜色-红色”用它的可点击文案去定位,而且点击之后要断言选中状态,因为有些选框点击会触发异步校验,点下去并不等于选中成功。

这里要特别提醒一个坑:有些SKU选项需要页面滚动到可视区域才能点击。Selenium的element.click()在元素不在视口内时,通常会自动滚动到元素位置,听起来没问题。但电商详情页往往有吸顶导航、悬浮客服按钮、优惠券浮层等遮挡层,自动滚动后的元素可能仍然被这些悬浮层遮住。这样element_to_be_clickable条件都通过了,真正执行click时却报ElementClickInterceptedException。

我试过最有效的解决方式是:先滚动到元素位置,再判断是否有遮挡,有遮挡就先关掉或避开:

from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.by import By def click_after_scroll(self, by, locator): element = self.find_element(by, locator) # 滚动到元素中间位置,减少被顶部吸顶导航挡住的概率 self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", element) try: self.click(by, locator) except Exception: # 如果被遮挡,先尝试按下ESC或点击关闭按钮 self.driver.find_element(By.TAG_NAME, "body").send_keys(Keys.ESCAPE) element.click()

加购之后一般会弹出“已加入购物车”的悬浮层或侧边栏,有些还会在购物车数量角标上有动画更新。在脚本里断言加购成功的依据,我建议用购物车图标上的角标数字变化,而不是等弹层文案,因为弹层出现的时机很不稳定,而角标数字、购物车列表条数这种变化,更好作为稳定的断言信号。

拿购物车示例:

def get_cart_count(self) -> int: # 假设购物车角标是一个class为cart-badge的元素 text = self.find_element(By.CLASS_NAME, "cart-badge").text return int(text) if text.isdigit() else 0 def add_to_cart(self): cart_count_before = self.get_cart_count() self.click(By.ID, "add-to-cart-btn") # 等待角标变化 WebDriverWait(self.driver, 10).until( lambda d: self.get_cart_count() == cart_count_before + 1 )

注意这个lambda d配合WebDriverWait的写法,WebDriverWait每500毫秒轮询一次,会不断调用这个函数去读取角标数字,直到它等于预期值才返回。这种轮询等待比死等一个固定元素要靠谱得多,因为它把“业务是否完成”的校验直接挂到了等待条件上。

3.4 购物车与结算:页面跳转、金额计算和订单提交断言

购物车页面相对简单,主要验证的是:加购过的商品出现在列表里、商品数量和金额正确。但到了结算页就复杂了,因为结算页通常会叠加优惠券、满减、运费、积分抵扣这些逻辑,最终的“应付金额”是一个动态计算结果。

UI自动化在这里要克制。你做不到也不应该把整个价格计算引擎重新验证一遍,那应该属于接口测试或单元测试的范畴。UI层面的关注点是页面金额展示与用户操作之间的变化关系。比如你选了一张满100减10的优惠券,断言应付金额是否变成了原价减10,这是合理的UI验证。但你要是试图在UI脚本里覆盖所有满减、叠加、运费模板的组合矩阵,那脚本数量直接爆炸,执行时间也完全失控。

此外,结算页往下走,经常会弹各种二选一弹窗,比如“是否使用积分抵扣?”“确认放弃优惠吗?”。处理这类弹窗的统一策略是:先识别弹窗类型,再按业务预期点击确定或取消。如果业务预期明确,就直接等待出现按钮文本再点击:

WebDriverWait(self.driver, 10).until( EC.element_to_be_clickable((By.XPATH, "//div[contains(@class, 'modal')]//button[text()='确定']")) ).click()

下单成功之后,电商平台一般跳到“支付结果”页或“订单详情”页。我通常以订单号是否出现、支付成功文案是否显示作为最终断言条件。为了避免用例在支付环节被外部支付网关卡住,很多项目会把下单行为截止到“去支付”按钮为止,支付行为的验证交给支付模块的专项用例来做。这样分而治之,整条链路更容易稳定运行。

4. 稳定性是一切:元素定位、等待策略与问题排查

4.1 元素定位策略:id优先不是随便说说的

写UI脚本,最多的时间其实都花在“找元素”和“等元素”上。先聊聊定位优先级,这也是从实际教训里总结出来的顺序:

第一优先用id,id在整个页面里是唯一的。第二用的是data-testid或data-qa这类专门为自动化埋点的属性,电商大厂一般都约定核心功能元素必须带这些标记。这两类属性稳定又唯一,比什么都好用。第三优先级是CSS选择器和相对XPath,适合那种结构性很强的列表、卡片。最后才考虑用文本内容定位,因为文本最容易随业务改版变化,比如按钮文案从“立即购买”改成“马上抢购”,你的脚本全挂。

我给你看一个实际例子。某电商平台商品详情页的“加入购物车”按钮,早期文案一直叫“加入购物车”,定位可以用//button[contains(text(),'加入购物车')]。后来运营做活动,把按钮文案改成了“立即加购”,结果脚本大面积失败。后面我给这个按钮加了>

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进