写软件测试这块有一点年纪的朋友应该都有体会:我们最初做自动化测试,基本都是从“背函数”开始的——find_element点什么、send_keys输什么、click点什么,背熟一套就感觉已经会自动化了。等真正进了项目、跑了两轮回归之后才发现,函数谁都会调,拉开差距的是你怎么组合它们、怎么处理等待和异常、怎么让用例在业务变动时少改几行。这篇作为“软件测试知识点总结”系列里自动化测试常用函数的第二篇,重点跟上篇做个区分:上篇讲的是必备基础函数,这篇讲的是能直接提升脚本质量和运行稳定性的进阶函数,覆盖 Web UI 自动化和接口自动化两条主流路线。适合已经能独立写简单自动化脚本、正在往框架设计和工程化方向走的同学,也适合准备自动化测试面试、想在深挖底层原理时有点谈资的人。
我先把结论放在前面:如果你只是想跑通一条用例,那常用函数确实只有那么二三十个;但如果你想让这套用例能长期跑、能在团队里推广、能让别人接手时不骂人,那么真正值得反复研究的函数,其实集中在这几个点上——显式等待条件和断言机制、参数化与数据绑定、fixture 的处理、请求封装与重试策略。接下来我按实际使用频率和重要性逐层拆。
1. 自动化测试函数体系整体拆解:别急着背函数,先搞清楚函数在测试里承担的角色
1.1 自动化测试的“函数”到底分几层
很多初学者拿到一个自动化测试项目,第一反应是打开源码找测试用例文件,看到一堆函数定义,然后逐个去背。这种做法效率低,而且容易陷入“背了忘、忘了背”的循环。实际在工程层面,自动化测试里的函数基本可以按层级分成四块,理解了这四块你就抓住了主线。
第一块是“驱动层函数”。这一层负责跟浏览器、App 或接口客户端打交道,典型代表是 Selenium 里的webdriver.Chrome()、driver.get()、driver.find_element(),Appium 里的driver.implicitly_wait()、driver.find_element_by_id(),接口测试里 requests 库的requests.get()、requests.post()。这一层的特点是:它们是自动化测试最底层的“手和脚”,几乎不能替代,但也是最不值得花大量精力去背的部分——因为它们的用法你用到哪查到哪就行,真正重要的是理解它们的执行逻辑和参数含义。
第二块是“业务封装函数”。这一层是把重复的底层操作提炼成有业务语义的自定义函数,比如login(user, password)、add_to_cart(item_id)、create_order(order_data)。这一层是测试代码质量的试金石:封装得好的项目,用例层读起来几乎像在描述业务操作;封装得差的项目,用例层全是driver.find_element(By.XPATH, "//div[contains(@class,'...')]").click()这种细节噪音。
第三块是“断言与校验函数”。这是自动化测试的灵魂。很多同学用例跑得欢,但断言只会写assert 1 == 1这种假断言,或者干脆不写断言。这一块最典型的是 pytest 的assert表达式 +pytest.approx()做浮点数比对,以及自定义的断言集合模块。
第四块是“框架支撑函数”。比如 pytest 的@pytest.fixture、@pytest.mark.parametrize、pytest_runtest_makereport钩子函数,allure.step()、allure.attach()这类报告增强函数。它们是让测试用例能组织起来、能产生有效报告、能灵活扩展的“底层架构”。
1.2 为什么同样是点击操作,有人写成 5 行有人封装成 1 行
我见过很多刚入行的同学写脚本,每个用例里面都要来一遍“查找元素 + 等待 + 点击”,一个用例 80 行,其中 70 行都是找元素的细枝末节。真正的问题不是他们不会写,而是没有建立“函数即抽象”的思维。
举个很简单的例子,同样是点击某个按钮,我刚接触自动化的时候写的是:
driver.find_element(By.ID, "login-btn").click()后来项目复杂了,这个按钮经常因为页面渲染慢而点不上,于是改成了:
WebDriverWait(driver, 10).until( EC.element_to_be_clickable((By.ID, "login-btn")) ).click()再后来很多页面都有这个点击需求,这段代码我复制了十几份。直到有一次前端把按钮的 id 改成了login-submit,我花了整个下午去改那十几个地方。从那天起我再也不这么写了,直接做了个click_element(locator, timeout=10)函数,把显式等待和点击动作收进去,业务层只传定位符。这样改一个逻辑只动一个地方,这正是函数封装的核心意义:消除重复、统一变化点、提升可维护性。理解这一点再看任何函数,你的眼光都会不一样——你关心的不是这个函数本身怎么写,而是它放到哪一层、要解决哪种变化。
2. 进阶函数逐个拆:Web UI 自动化里的这几个函数决定了脚本寿命
2.1 元素等待函数:隐式等待和强制等待为什么会被显式等待取代
先说结论:请把代码里的time.sleep()当成万不得已的手段,把implicitly_wait当成基础配置,把WebDriverWait+expected_conditions当成主力等待方式。
time.sleep(3)这种强制等待的罪过在于:它不管页面 0.5 秒就加载完了,还是 10 秒还没出来,一律傻等 3 秒。页面快了它浪费时间,页面慢了它照样报错。我见过一个项目,100 条用例平均每条里有五六个 sleep,跑一轮全量要一个半小时。后来把 sleep 换成智能等待,时间压缩到 40 分钟,还更稳定了。
implicitly_wait(10)是 Selenium 提供的隐式等待,它设置了一个全局的超时时间,每次find_element找不到元素时轮询等待,直到超时。它的问题也很明显:这个等待只作用于元素查找,判断不了元素是否可见、是否可点击;而且它是全局的,一旦某个页面元素加载特别慢,或者某个元素压根不存在,所有查找都要干等满 10 秒。
真正靠谱的是显式等待:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC wait = WebDriverWait(driver, 10, poll_frequency=0.5) # 等待元素可见 wait.until(EC.visibility_of_element_located((By.ID, "success-tip"))) # 等待元素可点击 wait.until(EC.element_to_be_clickable((By.XPATH, "//button[contains(text(),'提交')]"))) # 等待元素消失(比如 loading 遮罩层) wait.until(EC.invisibility_of_element_located((By.CLASS_NAME, "loading-mask")))注意这里By.ID这种定位方式一定要套在括号里传进去,expected_conditions里绝大多数条件接收的是 locator 元组而不是裸的定位字符串,这是新手最容易踩的坑。
2.2 expected_conditions 里最常用的几个条件和函数封装技巧
expected_conditions本身是一个模块,里面提供了大量“等待条件”,它们本质上都是一些定义了__call__方法的类。你用的时候既可以直接用WebDriverWait(...).until(EC.xxx),也可以把EC.xxx理解为一个“带状态的条件函数”。
我实际项目里最常用的条件有这么几个:
| 条件 | 适用场景 | 返回值 |
|---|---|---|
visibility_of_element_located | 等待元素出现在页面且可见 | 元素对象 |
element_to_be_clickable | 等待元素可见且可点击 | 元素对象 |
presence_of_element_located | 等待元素出现在 DOM 中(不要求可见) | 元素对象 |
invisibility_of_element_located | 等待元素隐藏或消失 | 布尔值 |
text_to_be_present_in_element | 等待元素文本变为期望值 | 布尔值 |
url_contains | 等待页面 URL 变化(比如跳转后) | 布尔值 |
这里要特别提醒:visibility_of_element_located和presence_of_element_located很多人混着用,但两个判断逻辑差异很大。“presence”只要求元素在 DOM 树里存在,就算它宽高为 0、完全不可见也会返回;“visibility”则要求元素不仅存在,还被用户看得到。有的场景里你用presence去取元素然后执行click(),大概率会碰上“元素不可交互”的异常,因为元素可能还在渲染。
基于这些条件,我自己的做法是封装一小组“等什么就函数名叫什么”的方法,比如等元素可见并返回元素、等元素可点击并完成点击、等文本出现并返回文本内容,核心是让上层用例读起来干净。这个习惯在我带团队、做 code review 的时候明显降低了很多沟通成本。
2.3 断言函数不是 assert 那么简单:pytest 断言和软断言的取舍
UI 自动化里最常见的毛病,是断言要么太多、要么太少。太多是指每个操作后面都跟一大段校验,把用例写成了状态检查器;太少是指只在最后面草草断言一句“页面跳转成功”。我的标准是:“每个用户可见的核心变化都值得一个断言,但不要断言无关的中间过程。”
pytest 的assert本身就很好用,失败时能自动展开表达式细节。比如assert login_success_text == "欢迎回来"失败时,pytest 会把左右两个值的具体内容打印出来。这一点比unittest的assertEqual直观太多。
不过纯assert在“一个步骤后要验多个点”的场景下有个问题:第一条断言挂了,后面的断言直接跳过,你得反复跑才能把所有问题点收集齐。解决方式是用软断言,比如pytest-assume插件:
from pytest import assume def test_login_flow(): assume(check_element_exist("user_name")) assume(check_button_clickable("submit_btn")) assume(get_text("login_result") == "登录成功")这样即使第一个assume失败,后面的也会继续执行,一次跑完能拿到全部失败点。代价是软断言失败时不会立刻中断,后面代码如果依赖前面的结果,需要你自己控制好逻辑。我的建议是:核心连续流程用普通assert,多个独立展示点的校验用assume,按场景选。
另外一个很容易忽略的断言点是 UI 自动化里的“元素不存在”怎么断言。很多人会写成assert not driver.find_element(...),这非常危险——元素找不到时find_element会抛异常,于是你的断言永远不会走到“不存在”这条分支。正确写法是用find_elements判断列表长度:
assert len(driver.find_elements(By.CLASS_NAME, "error-message")) == 0这个思路在做表单校验类用例时几乎是必备技能。
3. 接口自动化里的常用函数:参数化和数据绑定是灵魂
3.1 requests 核心函数和 session 管理
接口自动化测试虽然不属于 UI 范畴,但自动化测试的系统知识里它一定是重头戏。最常用的请求库是requests,它的核心函数就三类:get/post/put/delete发请求,Session管理会话,Response对象上的一系列属性方法。
很多人用requests.get()一条一条地发请求,这种写法在单接口调试时没问题,但做业务流程串联时会有个坑:cookie 和鉴权状态不共享。比如你先登录拿到 token,再下一个请求要带着这个 token,如果用裸函数你得手动把 token 提取出来塞到下一个请求的 header 里,代码又杂又容易遗漏。而用Session就不一样了:
import requests session = requests.Session() def login_and_get_session(host, username, password): login_url = f"{host}/api/login" payload = {"username": username, "password": password} response = session.post(login_url, json=payload) # 服务端返回 token 时,自己存到 session 的 headers 里 token = response.json().get("data", {}).get("token") if token: session.headers.update({"Authorization": f"Bearer {token}"}) return session后面所有接口都通过这个session对象调用,鉴权信息自动带上。这就是“函数复用”在接口层的具体体现——你可以把登录逻辑、token 刷新逻辑、请求头拼装逻辑全部封装成函数,用例层只关心业务参数。
Response对象上的常用函数也得心里有数:response.json()解析 JSON 响应体,response.status_code看状态码,response.text拿原始文本,response.headers看响应头。碰到返回大报文、需要校验某个字段是否在多层嵌套结构里存在时,提前封装一个get_json_value_by_path(data, "data.order.items.0.price")这种按路径取值的函数,能在写用例时省下大量重复的["data"]["order"]["items"]切片操作。
3.2 pytest 参数化函数:一条用例跑出多个数据集
接口测试里最核心的测试设计技巧就是数据驱动,而数据驱动在 pytest 里的落地方式就是@pytest.mark.parametrize。这个装饰器函数的威力在于:它能把一组测试数据循环传给同一个测试函数,生成多条独立测试用例,每一条失败都能单独定位。
它的基础用法是这样:
import pytest @pytest.mark.parametrize("case", [ {"name": "正常登录", "username": "admin", "password": "123456", "expect_code": 200}, {"name": "密码错误", "username": "admin", "password": "wrong", "expect_code": 401}, {"name": "用户不存在", "username": "nobody", "password": "123456", "expect_code": 404}, ]) def test_login_api(case): response = do_login(case["username"], case["password"]) assert response.status_code == case["expect_code"]这里有三个细节值得展开:
第一,参数化数据和用例逻辑要分离。上面这种直接把列表写在装饰器里适合数据量小、跟用例强相关的情况;数据量大了之后,更推荐把数据放到独立的 JSON/YAML 文件里,用工厂函数读取并返回,这样业务人员也能维护数据。
第二,参数化用例的失败信息要能一眼定位是哪组数据挂了。pytest 默认会在用例名后追加参数值作为 ID,但碰到 dict 类型参数时,显示的内容很丑。解决办法是用ids参数手动指定每条用例的名称:
@pytest.mark.parametrize("case", case_data, ids=lambda case: case["name"])这样测试报告里显示的是“正常登录”“密码错误”这些用例名,谁挂了一目了然。
第三,参数化同样适用于 UI 自动化,但要注意把定位符也作为参数传递时,最好给定位符包一层元组,避免 pytest 的字符串格式化出问题。这是我在@pytest.mark.parametrize里传 locator 时踩过的一个小坑,后面常见问题里再细说。
3.3 allure 相关函数:报告是测试的“另一面”
测试报告这块在“函数”语境下容易被忽略,但实际你写自动化测试这个动作本质上是给项目建立信心体系,如果结果无法被有效展示和解读,信心的建立就打了折扣。allure在 pytest 里有一组装饰器和函数,值得熟练:@allure.story()、@allure.feature()做用例分层,@allure.step()做步骤展示,allure.attach()做现场数据截图。
我在 UI 自动化里几乎是强制要求在异常时截图并附加到报告里:
import allure def shot_on_failure(driver, name="failure_screen"): try: screenshot = driver.get_screenshot_as_png() allure.attach(screenshot, name=name, attachment_type=allure.attachment_type.PNG) except Exception: pass这个函数的价值在于:用例失败后,报告里直接能看到体面的截图,再也不用在回忆里猜当时页面长什么样子。接口自动化里也可以用allure.attach(response.text, "response_body", allure.attachment_type.TEXT),方便追踪失败请求的返回内容。
4. 实操过程:把常用函数组合成一套可维护的测试框架
4.1 fixture 函数:前置后置处理的现代写法
前面提到的都是具体功能的函数,现在聊一个“框架级”的函数体系——@pytest.fixture。很多从unittest时代过来的人习惯在类里写setUp和tearDown,但 pytest 的 fixture 函数灵活得多,它的核心价值是依赖注入和按需使用。
看一个最简单的登录 fixture:
import pytest from api_client import TestClient @pytest.fixture(scope="class") def client(): c = TestClient() c.login("admin", "123456") yield c c.close()这个clientfixture 一个类里只执行一次,测试类的方法只要声明参数client,pytest 就会自动把函数返回值注入进来。你不用在每个测试方法里去调用“构造登录”——依赖是声明式获得的。yield前是前置逻辑,yield后是后置清理逻辑,一个函数搞定 setup 和 teardown。
fixture 的scope参数决定了生命周期:function每个用例跑一次,class每个类跑一次,module每个模块跑一次,session整个测试会话跑一次。合理设置能大幅提升执行效率。
我在实际项目里长期踩过的一个坑是把一个可变对象作为 fixture 返回值,比如一个 dict 或者 list。如果某个用例修改了这个对象又没还原,下一个用例拿到的就是“脏数据”。这种问题排查起来很隐蔽,因为代码看着完全没问题。解决办法是尽量让 fixture 返回“工厂函数”而不是直接返回对象实例,或者用yield明确之后让 pytest 做清理。
4.2 数据驱动和测试用例生成函数
把测试用例数据外部化之后,还需要一个读取数据的“用例生成函数”。这里我常用一个组合:先写一个load_test_data(file_path)通用函数统一读 JSON/YAML,再让参数化装饰器读取它。
import json import pytest def load_test_data(file_path): with open(file_path, "r", encoding="utf-8") as f: return json.load(f) login_cases = load_test_data("./data/login_cases.json") @pytest.mark.parametrize("case", login_cases, ids=lambda c: c["name"]) def test_login(case): ...这个方案的好处是测试数据和测试代码物理隔离。接口 URL 变了、字段新增了,通常只需要改数据文件,测试函数不需要跟着动。从团队协作角度,测试数据可以交给业务分析师或测试设计人员维护,开发人员只需要保证函数解析规则稳定。
这里强烈建议数据文件里有一层“标准化的字段约束”:比如每条数据必须包含name、request、expect三个字段,expect里可以包含status_code、contains_text、db_check等不同类型的验证点。这样你的通用执行函数才能统一解析。
4.3 重试机制:让用例从“偶发性失败”里站起来
自动化测试到了成熟阶段,最大的烦恼不是“测不出来”,而是“偶发性失败”。网络抖动、页面渲染慢、第三方服务超时,都可能导致一条用例这轮跑挂了、下轮又过了。这类问题如果不处理,测试报告的可信度会被严重消耗。
pytest 生态里有一个pytest-rerunfailures插件,它提供@pytest.mark.flaky(reruns=3, reruns_delay=2)这个函数装饰器:
@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_user_create(): ...这条用例失败后会自动重跑最多 3 次,每次间隔 2 秒。用法上要克制:重试只适合覆盖“偶发环境问题”,不适合掩盖“确定性业务 bug”。如果一条用例连续稳定失败,你加了重试只会延长任务执行时间,还可能让真正的回归问题淹没在整体通过的假象里。
我自己的策略是两层:接口层的关键用例不开重试,保证失败立刻暴露;UI 层对“前置页面加载耗时大”的用例开低次数的重试,并且重试之前先截图日志现场,避免排查问题的时候丢失第一手现场资料。
4.4 一个完整的 UI 自动化用例长什么样
把前面说的函数组合在一起,你得到的测试用例不再是一长串底层 API 调用,而是这样一段像业务剧本一样的东西:
import pytest from pages.login_page import LoginPage from pages.order_page import OrderPage @pytest.mark.parametrize("product_id, quantity", [("P1001", 1), ("P1002", 3)], ids=["p1001", "p1002"]) def test_add_order(product_id, quantity, login_env): order_page = OrderPage(login_env) order_page.add(product_id, quantity) assert order_page.get_amount_text() == expected_amount(product_id, quantity) assert len(order_page.get_order_items()) == quantity + 1用例层只关心“做什么”和“验证什么”,底层函数负责“怎么做”。这种分层以后,面对前端小改动,大多数情况只需要动 Page 对象或者数据文件,不用动用例本身。这也是为什么我说:常用函数的真正价值不在“会用”,而在“用在正确的位置”。
5. 常见问题与排查技巧实录
5.1 等不到元素:先分清是等待不够还是定位不准
UI 自动化报TimeoutException是家常便饭。很多新人的第一反应是调大WebDriverWait的超时时间,但实际排查我建议按这个顺序走:
第一步,打开浏览器手动复现,确认元素是不是真的存在。如果页面根本不存在你定位的元素,那等多久都没用,问题出在定位表达式。
第二步,确认元素存在但不唯一。用find_elements查一下匹配数量,如果一个表达式匹配到了多个元素而脚本恰好不在第一个上,极大概率会行为异常。
第三步,确认元素存在且唯一但仍超时,重点观察元素是不是在 iframe 里、是不是在 Shadow DOM 里、是不是需要先展开下拉面板。这三个也是最容易忽略的“等不到”原因。
排查工具上,我一般直接用浏览器开发者工具验证定位表达式,用$$()在 Console 里跑一下看匹配效果,能省非常多反复跑脚本的时间。
5.2 参数化数据报错排查:从“用例 ID 混乱”下手
一个高频翻车点是:参数化数据量一大,报错之后你根本看不出是第几条数据挂了。这时ids参数和用例命名机制就能救你一命。给每条用例起一个有意义的名字,报错信息里直接显示“test_login_cases密码错误”这种明确标识,你连日志都不用翻就知道挂了哪一条。
另一个更隐蔽的坑是:参数化传入元组形式的 locator,比如("id", "login-btn"),但 pytest 的参数列表解析和 selenium 的 locator 格式偶尔会混在一起,导致WebDriverWait.until接收到一个字符串而不是元组。我的经验是参数化传 locator 时,统一使用By.ID格式的定位对象,并在测试函数入口处加一个类型检查断言:
assert isinstance(locator, tuple) and len(locator) == 2别觉得这多余,实际排查时它能帮你快速区分是“数据问题”还是“框架问题”。
5.3 接口测试中 Session 状态串用例
接口测试用session时最容易出的问题就是用例间的状态污染。某个业务用例为了测试故意删除了一个用户,结果另一个用例正好要拿这个用户下单,两条用例一起挂。这种灾难的根源是测试用例之间隐式共享了可变状态。
我的做法是:每个测试类用一个独立session,并且对于“会产生副作用”的测试数据,在 fixture 的后置阶段做数据清理,而不是依赖用例自己处理。清理逻辑本身也要封装成函数,比如delete_user(user_id)、reset_order_data(order_no)。测试能跑得再快,也没有稳定重要。
5.4 常见问题速查表
| 问题现象 | 常见原因 | 排查与解决 |
|---|---|---|
NoSuchElementException | 定位表达式错误或元素在 iframe 内 | DevTools 验证表达式,检查 iframe 切换 |
ElementNotInteractableException | 元素存在但被遮挡或未完全渲染 | 改用element_to_be_clickable等待 |
StaleElementReferenceException | 页面刷新后旧元素对象失效 | 重新定位元素,避免变量长期持有元素 |
| 用例偶发失败 | 外部接口响应慢、前端渲染抖动 | 合理使用 flaky 重试,先截现场日志 |
| 接口响应体里取不到字段 | JSON 结构嵌套多层,或者值在 list 里 | 封装路径取值函数,先打印完整 JSON |
| 参数化用例名全是表达式 | 没有配置ids | 使用ids=lambda c: c["name"] |
| 断言失败后报告缺少现场 | 没有挂 allure 截图钩子 | 在 hook 里调用截图函数 attach 到报告 |
5.5 一个容易忽视的细节:日志函数也是测试函数的一部分
这算是我个人的一个执念。很多人觉得日志不属于“测试函数”,但我在实践里发现,80% 的用例排查效率提升来自日志。建议封装一个简单的logger工具函数,在关键步骤打印操作对象、参数、响应状态码和耗时,宁可多打几步,也不要让“查日志”变成“翻代码猜逻辑”。
import logging import time logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") def step_log(msg): logging.info(f"[{time.strftime('%H:%M:%S')}] {msg}")在每个核心动作函数里埋入这种日志,跑完一轮测试后,你按时间线就能完整还原每一步发生了什么。这对排查“为什么前面成功后面失败”的问题帮助很大。
记录一些经验感受
写了这些年自动化,最大的体会是:自动化测试的函数从来不是孤立存在的,它们组合在一起才构成测试能力。你背会了find_element、click、send_keys、assert,这只能保证你“手上有活”;只有真正理解等待机制、参数化、fixture、重试和日志之后,你才能保证测试用例在复杂项目里“活得久、跑得稳、报得清”。如果你正处在“函数都会写,但用例总不稳定”的阶段,我的建议是先别急着扩充新函数,而是把你已经用的那批函数重新审视一遍:哪些可以用显式等待替换 sleep、哪些断言可以更精准、哪些数据可以抽出来参数化。把这一步踏踏实实做完,比你盲目追求再多新函数都有用得多。