1. 脚本跑到一半就崩:两个崩溃现场还原
先讲件真事。我自学Python自动化测试那会儿,写过一套跑电商后台的回归脚本,用例覆盖商品上架、库存修改、订单发货几个核心流程。第一次全量跑的时候,我泡了杯咖啡坐等结果,回来一看屏幕——脚本在第二个用例就停了,控制台刷了一大片红色Traceback,后面的用例一个没跑。那会儿我还以为是自己代码写得不够“健壮”,后来才想明白,这压根不是健壮性的问题,是我从一开始就没搞懂自动化脚本为什么会崩、崩在哪、以及怎么让它在出错时不至于“全军覆没”。
当时弹出来的两个报错,几乎是每个自学自动化测试的人都会撞上的:
selenium.common.exceptions.NoSuchElementException: Message: no such element: Unable to locate element: {"method":"xpath","selector":"//div[@class='goods-item']//span[text()='审核通过']"}和
requests.exceptions.ConnectionError: HTTPSConnectionPool(host='api.example.com', port=443): Max retries exceeded with url: /order/update (Caused by NewConnectionError('<urllib3.connection.HTTPSConnection object at 0x...>: Failed to establish a new connection: [Errno 110] Connection timed out'))一个是元素定位失败,一个是接口请求连接超时。这俩问题表面看风马牛不相及,但本质上都有一个共同的病灶:脚本里大量代码是“裸奔”状态——没有超时控制、没有异常处理、没有失败兜底。页面一旦加载慢了、接口一旦抖动,整个执行链就断了。
这篇文章我就围绕这两个崩溃点,完整复盘我当时是怎么排查的、后来怎么改的,以及最终我把异常处理做成了什么样。
2. 元素定位失败,根子多半不在定位语句本身
2.1 你以为的“定位不到”和真实的“定位不到”是两回事
很多新手脚本里最常见的一段代码长这样:
driver.find_element(By.XPATH, "//button[contains(text(),'确认下单')]").click()我最初也是这么写的。问题来了:这行代码能跑通的前提是——执行到这行的时候,页面上这个“确认下单”按钮已经渲染完成了。但真实环境里,页面加载是分秒级变化的,尤其后台管理系统,点完“创建订单”之后要等接口返回、等前端重新渲染列表、等按钮从禁用态变成可点态。你脚本切过去就立刻找元素,十次里有八次会扑空,剩下的两次是因为电脑性能好、网速快,侥幸赶上。
selenium其实提供了两种“等待”机制:隐式等待和显式等待。
隐式等待是给webdriver对象设置一个全局轮询时间:
driver.implicitly_wait(10)它的含义是:每次用find_element找元素时,如果没找到,最多在指定时间内反复尝试查找。这玩意儿有用,但很粗糙。为什么这么说?因为它只对find_element这一层生效,管不到元素“找到了但不可点击”“找到了但被遮挡”“找到了但还没渲染完文本”这一堆后续状态。我后来吃过一个大亏:隐式等待设了10秒,元素明明能找到,但点下去毫无反应,因为按钮还在loading态,等真正可点的时候脚本已经去执行下一步了,于是下一步又崩。
于是后来我全面转向了显式等待,也就是WebDriverWait配合expected_conditions:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC wait = WebDriverWait(driver, 15) add_btn = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[contains(text(),'确认下单')]"))) add_btn.click()EC.element_to_be_clickable是个好用的条件,它内部会先确认元素存在,再确认元素可见,再确认元素可交互(没有被遮挡、不是disabled状态)。从“找到元素”进化到“元素确实能操作”,这一步是质变。
2.2 定位器本身的脆弱性:为什么你的xpath今天能跑明天不能跑
除了等待问题,定位表达式本身也是崩溃高发区。UI自动化最烦的就是前端改版,改一个class名、调一层DOM结构,你的xpath就废了。这属于“慢性崩溃”,不是脚本跑一半当场炸,而是某天回归时突然炸。
我踩过的典型坑有三个:
一是绝对路径。比如/html/body/div[2]/div[3]/form/div[1]/input这种,从html根部一路指下来。只要页面结构轻微调整,哪怕只是加了个div包裹,整条路径就全废了。后来我全部改成相对路径定位,优先用id、name、># 精确匹配,文案改了必崩 //a[text()='订单管理'] # 模糊匹配,加了角标、空格也能找到 //a[contains(text(),'订单管理')]
三是动态id和动态class。有些框架生成的前端代码,id每次刷新都不一样,比如goods-item-12345、goods-item-12346,你要是写死//div[@id='goods-item-12345'],第二次跑必崩。正确的做法是用动态前缀匹配:
//div[starts-with(@id, 'goods-item-')]2.3 定位失败后脚本崩溃的完整排查链路
说回标题里“脚本中途崩溃”的场景。我当时排查这个问题的顺序是这样的:
第一步,先复现。手动打开页面,按脚本的步骤走一遍,看能不能看到要定位的元素。这个步骤其实能排除掉80%的纯写错问题——很多新手报错后第一反应是改定位表达式,其实应该先确认页面上到底有没有这个元素。我见过一个案例,脚本在定位“确认收货”按钮时崩溃,排查了半天,最后发现那个订单状态根本不需要确认收货,按钮压根不出现。这是业务逻辑问题,不是定位问题。
第二步,加等待。确认元素存在之后,如果直接跑还是偶发崩溃,那就是时序问题。我把所有直接find_element的地方逐步替换成WebDriverWait,每次跑十分钟脚本观察稳定性。
第三步,加降级。如果一个元素用了xpath定位不到,脚本不要立刻崩,而是尝试备用定位器。比如xpath失败后用class_name再试一次,实在找不到才抛异常。这一步是学别人项目里的Page Object模式学来的,一个页面元素对应多个定位策略,稳定性高很多。
第四步,截图留证。定位失败那一刻,页面是什么状态?操作到了哪一步?截图是最直观的证据。我后来所有find_element失败的地方,都会在except里执行一次driver.save_screenshot(),把崩溃现场留下来。否则你连问题都没法复现,排查全靠猜。
3. 接口请求在自动化里“裸奔”代价有多大
3.1 一次超时塌方,整个用例全部陪葬
接口自动化要处理的问题和UI自动化不同——它没有“页面渲染”这层遮挡,直接面对的就是网络抖动、服务不可用、返回超时、响应格式异常这些原始风险。
我当时做接口自动化测试的时候,用的最多的库是requests。最早我也干过“裸奔”这件事,写出来的代码简单粗暴:
resp = requests.post("https://api.example.com/order/update", json=payload) assert resp.json()["code"] == 0这段代码在本地、在网络好的时候能跑。早晚高峰或者连了公司代理之后,同一个请求可能就要10秒才返回,再夸张点直接超时。一旦超时,requests会抛requests.exceptions.ConnectTimeout或者ReadTimeout,代码没有try,于是整个用例戛然而止,后面排队的所有用例全都不跑了。
更麻烦的是,接口自动化很多是有先后依赖的——订单接口跑完了才能跑支付接口,支付完才能跑退款接口。中间任何一步崩了,整条链路就断了。这时候你面临的已经不是一个用例失败的问题,而是“测试任务崩溃”的问题,失败原因反而被掩盖了。
3.2 requests的异常体系:你至少得认识这五个
requests库的异常都定义在requests.exceptions里,平时最常打照面的有这五个:
| 异常类型 | 触发场景 | 典型报错关键词 |
|---|---|---|
ConnectionError | TCP连接建立失败,比如域名解析不了、端口不通 | Failed to establish a new connection |
ConnectTimeout | 连接阶段超时,服务器根本没有响应 | Connection to ... timed out |
ReadTimeout | 连接成功但服务器迟迟不返回数据 | Read timed out |
HTTPError | 响应的状态码是4xx或5xx,且你调用了resp.raise_for_status() | 404 Client Error |
JSONDecodeError | 响应不是合法JSON,但你用resp.json()去解析 | Expecting value: line 1 column 1 |
把异常分清楚有什么意义?意义在于不同异常的处理策略应该不同。连接超时可以重试,HTTPError要看具体状态码,4xx重试没用、5xx可以重试,JSON解析失败则多半是接口返回了错误页或者网关拦截页。
3.3 给requests装上“防弹衣”:超时、重试、回退
先说超时。很多新手不设置timeout参数,这等于让脚本无限期等下去。我之前看到过一个脚本,一个接口卡了半小时,整个测试任务就卡在那半小时,后面啥也没跑。后来我统一给所有请求加了超时和重试逻辑。
基础版本是这样:
import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() retry_strategy = Retry( total=3, # 总共重试3次 connect=3, # 连接阶段失败重试3次 read=2, # 读取阶段失败重试2次 status=2, # 遇到5xx状态码重试2次 backoff_factor=1, # 每次重试等待时间成倍增长:1s, 2s, 4s status_forcelist=[500, 502, 503, 504], ) adapter = HTTPAdapter(max_retries=retry_strategy) session.mount("http://", adapter) session.mount("https://", adapter) resp = session.post("https://api.example.com/order/update", json=payload, timeout=(5, 15)) # (连接超时, 读取超时)这段代码里backoff_factor值得展开说说。它的工作方式是:第n次重试前的等待时间 =backoff_factor * (2 ** (n-1))。所以第一次重试等1秒,第二次等2秒,第三次等4秒。这样设计是为了给服务端留出恢复时间,而不是疯狂地每0.1秒打一次。很多新手写重试逻辑时用的是死循环+time.sleep(1),效果远不如这个。
然后是响应解析部分。我后来封装了一个统一函数:
def post_json(url, payload, session=None): s = session or requests.Session() try: resp = s.post(url, json=payload, timeout=(5, 15)) resp.raise_for_status() data = resp.json() return data except requests.exceptions.Timeout as e: # 超时类异常:记录并返回一个特殊结果,而不是让脚本崩 return {"code": -1, "msg": f"请求超时: {e}"} except requests.exceptions.HTTPError as e: status = e.response.status_code if e.response is not None else "unknown" return {"code": -2, "msg": f"HTTP {status} 错误: {e}"} except requests.exceptions.JSONDecodeError as e: return {"code": -3, "msg": f"响应JSON解析失败: {e}"} except requests.exceptions.RequestException as e: return {"code": -4, "msg": f"请求异常: {e}"}这样改完之后,接口请求即使失败,也只会返回一个带错误码的dict,不会抛异常中断脚本。后面的断言逻辑只需要判断code,而不用再担心进程崩溃。接口没通、脚本继续跑、测试结果里标记失败,这才是自动化测试应该有的行为——用一套可观测的结果替代“执行到一半死了”。
4. 让脚本“摔倒了能爬起来”:异常兜底与整体设计
4.1 分而治之:什么异常该让它崩,什么异常该兜住
不是所有异常都要兜住。我见过一些反面案例,为了不让脚本崩,把整个main函数包在一个大try...except Exception里,异常吞掉,照常继续跑。结果是测试报告全绿,实际上业务根本没走通。如果断言失败、核心业务校验不过,这种情况必须报错;只有那些环境性、临时性、偶发性的异常才值得兜住重试。
我后来给自己定了一个简单规则:
- 元素定位失败:先重试,重试3次仍然失败,截图+记录日志+标记失败,不再继续执行后续依赖步骤。
- 接口超时/网络错误:重试机制自动处理,重试后仍然失败,标记该步骤失败,但脚本继续跑其他独立的用例。
- 业务断言失败(比如接口返回的
code不是0):不重试,直接标记失败,因为这不是暂时的网络问题,是功能真出问题了。 - 代码语法错误、依赖缺失:让它崩,这种问题修复成本低,藏着掖着反而拖累排查。
4.2 给每个步骤都留“逃生通道”:try-except的最小粒度
新手常犯的另一个错误是try-except范围太大。我见过有人把整个用例的执行体包在try里,except后只打一行日志:
try: # 一堆操作 add_goods() check_stock() submit_order() pay_order() except Exception as e: print(f"执行失败: {e}")这种做法的问题在于:你会知道“失败了”,但不知道具体哪个步骤失败了。排查的时候要从头开始人工按流程走一遍,效率极低,尤其是在长流程的用例场景里。
正确做法是给最小可独立步骤加异常处理,比如定位一个元素、执行一次点击、发送一次请求、解析一次响应,这些都单独包一层。我的习惯是封装几个带重试和日志的“基础动作函数”,页面操作和接口调用全部走这些函数,而不是直接写裸的driver.find_element和requests.post。
拿UI操作来说,我封装了一个safe_click函数:
def safe_click(driver, locator, description="", retry=3): for attempt in range(1, retry + 1): try: elem = WebDriverWait(driver, 10).until( EC.element_to_be_clickable(locator) ) elem.click() return True except Exception as e: if attempt >= retry: driver.save_screenshot(f"fail_{description}_{int(time.time())}.png") raise e time.sleep(2)这里的description参数在日志里非常关键。不然报错时看到一堆莫名其妙的xpath,你还得猜这个元素是哪个页面上的。有了描述,日志像这样:
[2025-01-15 14:22:31] 步骤“点击确认下单按钮”失败: 第3次重试后仍然无法定位元素一眼就能定位到业务环节,而不是去逆向折腾DOM结构。
4.3 日志和报告:把“崩溃现场”变成可追溯的证据
处理异常只是第一步,把异常留下证据才是自动化测试能持续改进的根本。早期我只用print打日志,脚本一崩,控制台滚动刷新,关键信息早就冲没影了。后来我全面切到logging模块,并且给日志加了文件名和行号信息:
import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d %(message)s", handlers=[ logging.FileHandler("test_run.log", encoding="utf-8"), logging.StreamHandler() ] )这样做的好处是:不仅控制台能看到实时进度,文件里也留了一份完整记录。跑完一轮回归,翻日志的时候能清楚地看到哪一步先失败、哪些步骤是被连带跳过的。配合我前面说的失败截图,基本能做到“不需要复现,看着日志就能定位”。
另外,我在跑自动化测试时用的是pytest框架,它自带的assert重写和失败报告就很能打。再加上pytest-html或allure插件,失败用例会生成独立的报告页面,异常信息、日志、截图都能汇总到一块。这样一来,“脚本中途崩溃”变成了一种标准化的失败结果,而不是一次执行事故。
4.4 断点续跑:让长任务不因一步失败而全军覆没
还有一个非常实际的问题:一套测试用例有几十上百条,如果第5条失败后脚本停止,后面的95条全白等了。这个问题的解法通常分为两种。
第一种是把用例设计成相互独立的单元,pytest里天然支持——一个用例失败不影响其他用例执行。为了实现这种独立性,我在写用例时尽量避免用例之间的数据强依赖,比如创建订单的用例不依赖之前用例创建的某个具体订单id,而是自己调用接口生成测试数据。
第二种是给长流程用例增加“失败后跳转”的逻辑。比如一个购物流程用例,如果“加入购物车”这一步失败了,后面“结算”“支付”的步骤肯定没法执行。这时候与其一个接一个报错刷屏,不如让脚本直接跳过后续步骤,把整个用例标记为失败。这就是我在前面safe_click里raise e之后,上层用例捕获异常再决定流程走向的用法。
我封装了一个非常简单的流程控制类来做这件事:
class FlowAbort(Exception): """主动中断当前用例流程""" pass def step(name): def decorator(func): def wrapper(*args, **kwargs): try: logging.info(f"执行步骤: {name}") return func(*args, **kwargs) except FlowAbort: raise except Exception as e: logging.error(f"步骤失败: {name}, 错误: {e}") raise FlowAbort(f"步骤{name}失败,中断当前流程") from e return wrapper return decorator这样每个业务步骤被装饰后,一旦失败就会主动抛出FlowAbort,把它上面的流程切断,但不会影响测试框架继续执行其他独立用例。整套跑下来,一份报告里既有失败用例,也有被中断跳过的步骤,信息比原来“跑一半崩掉”要完整得多。
5. 自学Python自动化测试最该提前养成的三个习惯
习惯这个东西,靠看文章很难养成,但我还是想说,因为这比我上面写的任何一行代码都管用。
第一个习惯:先想好这个步骤可能怎么失败,再写让它成功的代码。
我见过不少自学的人(包括我自己早期),写代码的顺序是“先写正常流程,跑通了再补异常处理”。这个顺序是反的。正确的方式应该是:动手之前先问自己,这个步骤依赖什么前置条件?如果前置条件不满足会抛什么异常?异常发生后我要记录什么信息?想清楚这三件事再写代码,写出来的代码天然就是稳的。我后来开发效率提升了,不是因为技术变强了,而是因为这个“预判失败”的习惯帮我少踩了一大半坑。
第二个习惯:日志和截图永远比控制台报错更能救你。
很多人一看到Traceback就紧张,就想去“修”代码。但定位问题最快的方式不是读代码,而是看现场。UI自动化看截图,接口自动化看请求日志和响应报文。所以我写自动化测试的第一原则是:所有可能失败的地方都留下证据。哪怕慢一点、代码冗余一点,也要保证出问题之后能立刻看到现场。有一次我一个用例在凌晨跑挂了,第二天早上到公司,花了三分钟看截图和日志就定位到是页面弹窗遮挡了按钮,比读代码快得多。
第三个习惯:测试用例要“允许失败但知道为什么失败”,而不是“不允许失败”。
这是我后来做框架设计时最深的体会。优秀的自动化测试框架不是“永远不会出错”,而是“出错了能给出足够的信息让你赶紧修复”。脚本中途崩溃不可怕,可怕的是崩溃之后你只能靠猜。把异常处理、重试机制、日志记录、失败截图、流程控制这些基础设施搭好了,脚本随便造——它永远会给你一份说得清来龙去脉的结果。
我最终那套自动化测试脚本,从“跑一半崩溃、后半程全废”优化到“任何用例失败都不影响其他用例、报告里能看清楚每一步的现场”,前前后后大概迭代了三轮。每轮迭代的核心都不是加功能,而是补异常、补日志、补兜底。如果你也正在自学的路上卡在这一步,不妨先放下具体的定位技巧、接口细节,把自己的脚本当成一个“需要学会摔倒后自己爬起来”的系统,从异常处理入手去完善,进步会比想象中快。