1. 从“会写脚本”到“会写函数”:为什么自动化测试绕不开这一层
1.1 函数是自动化用例的“积木”
很多刚入行的朋友写自动化测试,习惯是“一个用例一个脚本”,把所有步骤从上到下堆在一起。比如想测登录,就打开浏览器、输入用户名、输入密码、点登录、断言页面跳转,看起来很直接,但第二个用例要复用这些步骤时,就只能复制粘贴。等到第三个、第四个用例加进来,脚本里到处是重复代码,改一个元素定位就要全局搜索替换,维护成本翻着倍往上涨。
函数在这里的作用,本质上就是把重复动作打包成可复用的积木。我经常跟新人打比方:写自动化用例就像做菜,函数是提前调好的预制调料包。你做番茄炒蛋和蛋炒饭,盐、糖、生抽的用量几乎一样,与其每次现拿三个瓶子各倒一遍,不如提前按配比装成一小包,炒菜的时候拆开倒进去就行。函数也是这个道理,把“输入用户名”“点击按钮”“等待元素出现”这类动作封装好,用例本身只需要关心业务顺序。
从软件测试的角度看,自动化脚本不是写给自己看的,它要长期运行、多人维护、随时排查问题。如果每个用例都是独立的“一大坨”,那它既是回归测试的定时炸弹,也是团队协作的噩梦。而封装函数的收益非常直接:第一,复用逻辑,减少重复代码;第二,统一修改入口,定位变了只改一处;第三,用例读起来像业务描述,别人能看懂你在测什么。
1.2 自动化测试函数的分类地图
自动化测试里的常用函数,按用途可以分五类:
| 分类 | 核心作用 | 典型函数/方法 |
|---|---|---|
| 定位操作类 | 找元素、做交互 | find_element、click、send_keys、select |
| 等待类 | 同步页面状态 | WebDriverWait、time.sleep、locator.wait_for |
| 数据准备类 | 生成、读取、清理数据 | faker、json.loads、openpyxl、random |
| 断言类 | 验证结果 | assert、allure.assert、pytest.raises |
| 工程辅助类 | 日志、报告、截图、重试 | logging、allure.attach、pytest.fixture |
这篇文章是系列第5篇,重点讲自动化测试常用函数的第二弹:我会先从Web自动化的高频操作函数和等待函数讲起,然后重点拆解接口自动化里requests和pytest配合时的辅助函数,再说数据驱动相关的读取处理和参数化组装,最后用一整章讲我实际排查过程中的高频报错和面试官最爱追问的封装思路。不管是零基础想入门,还是已经写了一阵子脚本想进阶,都能在里面找到能直接抄作业的内容。
2. Web自动化里那些高频函数,这样用才对
2.1 元素定位与交互:不只是find_element
Selenium里最基础的是find_element和find_elements,但实际项目里很少有人直接裸调这两个方法。原因很简单:裸调时元素没出现就直接抛异常,没有任何等待和重试;而且定位失败的报错信息很干瘪,你不知道是哪个页面、哪个元素出了问题。
我习惯在项目里封装一个基础操作层,核心思路是“点击前必须可见可点,输入前必须清空再输入,每次操作都记录日志”。你可以直接参考这段思路:
from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC import allure class BasePage: def __init__(self, driver, timeout=10): self.driver = driver self.timeout = timeout def find(self, by, locator, timeout=None): timeout = timeout or self.timeout try: el = WebDriverWait(self.driver, timeout).until( EC.presence_of_element_located((by, locator)) ) except Exception as e: # 出现异常时截图并附加到测试报告,方便排查 allure.attach(self.driver.get_screenshot_as_png(), name="find_fail_shot", attachment_type=allure.attachment_type.PNG) raise TimeoutError(f"元素定位超时: {by}={locator}, 等待 {timeout}s") from e return el def click(self, by, locator, timeout=None): el = self.find(by, locator, timeout) el.click()这里有两个容易被忽略的细节。
第一个细节是等待条件的选择。expected_conditions里的presence_of_element_located只关心元素在DOM里存在,哪怕它被遮挡、不可见、不可点击,照样返回。所以对于点击操作,更稳妥的等待条件是element_to_be_clickable,它额外校验了可见性和可用性。不少新手明明等了很久还是报错,就是因为等了“存在”,但按钮始终是disabled状态。
第二个细节是超时时间要不要暴露成参数。我见过有些人把等待时间写死在方法内部,等到某个页面加载特别慢时,只能临时改源码。正确做法是给每个交互方法都留一个timeout参数,默认用类里面的全局值,特殊场景再单独传。这样既保持统一性,又保留灵活性。
再顺带说一下Playwright。它的定位语法比Selenium更简洁,比如page.locator("text=登录")、page.get_by_role("button", name="登录"),而且locator对象自带wait_for和自动等待机制。用Playwright写自动化时,很多Selenium里需要手写WebDriverWait的场景都不需要了,因为它在执行click、fill之前会自动等待元素可操作。但要注意,Playwright的自动等待不等于完全不需要等待函数,比如某个接口返回特定数据后才出现的元素,它等不了业务条件,还是得用expect(locator).to_be_visible()这类显式断言等待。
2.2 等待函数:隐式、显式、强制等待的一次说清
Web自动化的等待问题,永远是面试和实战的高频区。我把三种等待按照“力度从弱到强”排个序:隐式等待、显式等待、强制等待。
隐式等待是给driver设置一个全局轮询时间,session级别生效。比如driver.implicitly_wait(10),意味着每次find_element时,如果元素没立刻出现,会在10秒内不停轮询,直到出现或超时。它的问题有两个:一是只在find_element时生效,对元素不可见、不可点击的情况无感;二是如果和显式等待混用,等待时间会叠加,比如隐式10秒加显式10秒,最坏情况下要等20秒才抛异常,极大拖慢用例执行。
显式等待是最推荐的方式,它针对某个具体条件做轮询,精度高、定位准。我常用的场景有:
from selenium.webdriver.common.by import By 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.element_to_be_clickable((By.ID, "submit-btn"))) # 等待某个元素文本变成指定内容,适合校验操作结果 wait.until(EC.text_to_be_present_in_element((By.CSS_SELECTOR, ".toast"), "保存成功"))poll_frequency这个参数很多人不设,默认0.5秒轮询一次。实测中,如果页面响应很快,可以把poll_frequency调低到0.1秒,用例能快一点;反之页面很卡就调到1秒,减少无谓的轮询请求。
强制等待也就是time.sleep,我把它归为“最后的手段”。它的问题不是不能用,而是用起来太“无脑”,不管页面实际好了没有,统一睡满时间。页面快了浪费时间,页面慢了照样失败。所以它只适合调试阶段、模拟人工思考时间、或者处理确实无法被常规等待覆盖的外挂行为。我在代码审查时看到time.sleep(5)这种写法,第一反应都是问:这里为什么不用显式等待?如果确实要保留,我会建议加个注释,说明等待的业务原因是哪个。
3. 接口自动化中的常用函数:从requests到pytest的默契配合
3.1 请求函数与响应处理
Web自动化解决的是UI层面,接口自动化则是绕过界面直接验证服务端逻辑。接口自动化里最常用的第三方库就是requests,它足够轻量,生态也稳。不过裸用requests也有问题:每个用例都要拼URL、带headers、处理cookie、判断状态码,代码重复率极高。所以接口自动化项目里,我第一件事就是封装一个统一请求入口。
import requests from requests import Session class ApiClient: def __init__(self, base_url): self.base_url = base_url self.session = Session() def request(self, method, path, **kwargs): url = self.base_url + path # 统一设置超时与证书校验,避免用例里反复写 kwargs.setdefault("timeout", 10) kwargs.setdefault("verify", False) resp = self.session.request(method, url, **kwargs) # 记录日志,方便排查失败用例 print(f"[API] {method.upper()} {url} -> {resp.status_code} {resp.elapsed.total_seconds():.2f}s") return resp这里的Session很重要。requests的Session对象会自动管理cookie,保持跨请求的登录状态,比每次单独用requests.get/post更接近真实浏览器的会话行为。另一个容易被忽略的是resp.elapsed,它返回请求总耗时的timedelta对象,可以换算成毫秒用来做性能断言。
响应处理我建议再补一层。大多数接口返回JSON,直接resp.json()是可以,但当你需要同时断言状态码、响应时间和响应体时,代码会越来越碎。我会封装一个统一的断言入口:
class ApiAssert: @staticmethod def assert_response(resp, expect_status=200, expect_key=None, expect_value=None): assert resp.status_code == expect_status, \ f"状态码不符,期望{expect_status},实际{resp.status_code},响应体:{resp.text}" if expect_key: data = resp.json() assert expect_key in data, f"响应中缺少字段: {expect_key}" if expect_value is not None: assert data[expect_key] == expect_value, \ f"{expect_key}值不符,期望{expect_value},实际{data[expect_key]}"封装完以后,测试用例的阅读成本会明显下降。比如“创建用户后校验返回的id大于0”,写出来的用例几乎是自然语言:
def test_create_user(api_client): resp = api_client.request("post", "/api/user", json={"name": "张三"}) ApiAssert.assert_response(resp, expect_status=200) user_id = resp.json()["id"] assert user_id > 03.2 让测试数据活起来:随机数据与格式化函数
接口测试数据如果全靠手写,有几个痛点:姓名重复导致唯一性校验失败、手机号不符合规则、时间戳永远是同一时刻。这时候用程式化生成数据是最好的选择。
faker这个库在测试圈已经算标配了。它支持中文环境,能生成姓名、手机号、身份证、地址等常用测试数据,用法非常直接:
from faker import Faker fake = Faker(locale="zh_CN") # 生成随机姓名与手机号 name = fake.name() phone = fake.phone_number() id_card = fake.ssn()注意一点:faker生成的手机号并不保证符合运营商号码段规则,有些系统会在后端校验手机号合法性,这时就需要自己实现一个“假手机号生成函数”,按“1[3-9]开头+9位随机数”的规则去构造,并且要避开已存在于测试库中的号码。
再就是时间处理。日期时间相关函数在任何自动化项目里都躲不掉,我的通用做法是统一用datetime和time模块封装几个工具函数:
from datetime import datetime, timedelta def get_now_str(fmt="%Y-%m-%d %H:%M:%S"): return datetime.now().strftime(fmt) def get_future_date(days=30): return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d")为什么单独封装?因为直接裸写datetime.now().strftime(...)本身不难,但分散在几十个用例里,一旦要统一改时间格式(比如从年月日变成时间戳),逐个修改非常痛苦。集中封装后,改一个函数里的fmt,所有用例都生效。
JSON序列化也是重灾区。很多接口返回的数据里带有中文,requests的resp.json()本身没问题,但如果你要把响应体写入日志或文件,用json.dumps(data, ensure_ascii=False)才能保留中文。不写ensure_ascii=False的话,中文会被转成\uXXXX,日志内容基本没法直接看。这个小细节,几乎每个新人都中招过。
4. 数据驱动与测试固件:pytest里不可忽略的函数式组装
4.1 fixture、conftest与参数化的组合拳
pytest是当前接口自动化领域事实上的标准框架,它的fixture机制能用函数组装出非常优雅的依赖注入能力。fixture的本质,就是一个带yield的普通函数:yield之前的代码是setup,yield之后的代码是teardown,yield本身的返回值就是注入给测试用例的参数。
比如登录token这个东西,多个接口都要用。与其在每个用例里都走一遍登录接口,不如定义一个login_token的fixture:
import pytest from api_client import ApiClient @pytest.fixture(scope="session") def api_client(): client = ApiClient("https://api.example.com") return client @pytest.fixture(scope="session") def token(api_client): resp = api_client.request("post", "/api/login", json={"username": "admin", "password": "123456"}) data = resp.json() assert data["code"] == 0, f"登录失败: {data}" return data["token"]scope="session"保证整个测试会话只登录一次,极大减少重复请求。如果登录状态在session期间会过期,那就改用scope="module"或者scope="class",让每个测试模块/类自己刷新登录态,这是工程化里一个很真实的权衡。
参数化的作用则是把同一个测试逻辑喂给多组数据。最朴素的用法:
import pytest @pytest.mark.parametrize("a,b,expected", [ (1, 2, 3), (10, 20, 30), (-1, 1, 0), ]) def test_add(a, b, expected): assert a + b == expected真正做接口自动化时,参数化数据通常不直接写在代码里,而是从外部文件或数据库读取。上面那个@parametrize的列表,完全可以由一个读取数据文件的函数动态生成,这样测试用例和测试数据就彻底解耦了,新增一个用例不需要改代码。
4.2 从文件读数据的三板斧:Excel、JSON、YAML
日常接口测试中,测试数据最常见的三种载体是Excel、JSON、YAML。Excel适合给业务同事维护,JSON适合配置信息,YAML适合管理用例结构。三个库分别对应openpyxl、json、PyYAML的yaml.safe_load。
Excel读取我踩过不少坑,这里直接给你一个可用的封装:
from openpyxl import load_workbook def read_excel(file_path, sheet_name=None): wb = load_workbook(file_path, data_only=True) ws = wb[sheet_name] if sheet_name else wb.active rows = ws.iter_rows(values_only=True) headers = next(rows) data = [] for row in rows: # 跳过空行 if all(cell in (None, "") for cell in row): continue data.append(dict(zip(headers, row))) return datadata_only=True这个参数是重点。Excel里很多单元格是公式,如果没开data_only,openpyxl读到的就是公式字符串而不是计算结果,断言时必然对不上。另外一个坑是:如果文件是用WPS或Excel编辑后没保存过,自带的缓存结果可能是空的,你会拿到一堆None值。遇到这种情况,记得先检查文件本身是否有计算结果。
JSON和YAML读取更简单,但要注意yaml.safe_load和yaml.load的区别:yaml.load不加Loader参数在PyYAML 5.x之后会直接抛警告并最终报错,所有解析YAML的地方都应该无脑用safe_load,它只解析标准数据结构,防止任意代码执行。这个知识点在软件测试面试题里也经常被问到。
文件名处理建议用pathlib库,比如Path(file).parent.parent / "data" / "user_cases.yaml",这样不管在本地还是CI环境执行,路径都能正确解析,避免使用相对路径时“当前目录不对所以找不到文件”的经典问题。
5. 排查实录:常用函数用不好,bug藏在细节里
5.1 高频报错与应对速查表
自动化测试跑起来之后,真正的学习才刚刚开始。我整理了一张高频报错速查表,都是团队项目里真实出现过的:
| 异常类型 | 出现的典型场景 | 排查方向 |
|---|---|---|
| NoSuchElementException | 元素定位失败 | 先确认是否在iframe内、是否新窗口,再确认定位表达式是否被异步数据影响 |
| StaleElementReferenceException | 元素先找到后,DOM刷新导致引用失效 | 避免在循环里持有同一个元素引用,每次操作重新查找;是列表页的高发问题 |
| TimeoutException | 显式等待超时 | 检查等待条件是否写错、元素是否在另一个frame、接口是否真的返回了 |
| ConnectionError | 接口请求连接失败 | 检查网络、代理配置、服务是否启动,利用Session重试机制实现二次请求 |
| UnicodeEncodeError | 日志或文件写入中文乱码 | 统一指定encoding="utf-8",JSON字符串用ensure_ascii=False |
有一个很多人忽略的要点:NoSuchElementException出现时,第一件事不是刷新页面或改代码,而是打开观察页面当前状态。很多自动化脚本之所以失败,是因为上一个用例把页面状态污染了,比如弹窗没有关闭、列表页翻到了第3页。这时候如果盲目修改定位表达式,只会越改越崩。
5.2 我实际踩过的三个典型的坑
第一个坑是显式等待“形同虚设”。我之前封装点击方法时,等待条件用了presence_of_element_located,觉得元素只要存在就够了。结果遇到一个场景:某个按钮确实在DOM里,但被一个全屏遮罩挡住,等它可点等了半天还是超时。排查到最后发现问题是等待条件不对,应该用element_to_be_clickable,换成之后问题立刻消失。这个经历让我养成了一个习惯:点击类操作统一用可点击条件,输入类操作用可见条件,存在类条件只用于校验页面是否出现了某个节点。
第二个坑是openpyxl读到一堆None。当时测试数据管理从手工造数切到Excel读取,结果跑出来的用例全是断言失败。排查后发现是同事直接把公式写在了Excel里,而data_only默认是False,读到的是公式本身,自然和期望值对不上。从那次以后,我的Excel读取封装里强制设置data_only=True,并且在上线前校验读取结果里不能出现空值。
第三个坑是pytest参数化传错了对象。有一次我写参数化数据源,把“随机生成手机号”的函数对象直接放进用例列表,而不是函数调用的返回值。pytest确实能跑,读到的不是手机号而是一个function内存地址。这类问题日志里极其难看出来,因为它不报错,只是数据永远是同一串地址。排查方法是在参数化数据周边打日志,把每个用例的入参实际值打印出来看一眼。
6. 面试常见考点:怎么回答“你封装过哪些常用函数”
6.1 从面试官视角看函数封装
软件测试面试题里面有一个出现频率极高的追问:你们项目里的公共函数是怎么封装的?这个问题表面在问函数,实际在考察你的抽象能力、异常处理意识和工程化思维。面试官想听的不是“我会用requests.get”这种废话,而是你能不能说清楚:哪些逻辑被抽出来,为什么抽,抽完之后解决了什么问题。
我建议的回答结构是“点线面”。先说点:我项目里常用的一等公民函数包括元素点击封装、等待封装、接口请求封装、数据生成封装、断言封装、日志封装。再说线:点击和输入会先走等待,等待失败会截图并记录日志,接口请求统一带超时和Session,断言统一走断言类。最后说面:这些封装组合起来形成了一套公共工具层,业务用例只关心Given-When-Then,不直接碰底层驱动或requests细节。
这里有个加分项是异常处理细节。如果面试官问“你最喜欢自己封装的哪个函数”,你可以说“安全执行函数”。它的思路是:接收一个函数和它的参数,执行时捕获异常、记录上下文、返回失败标识,让主流程不会被一个元素的偶发异常直接打断:
import functools import traceback def safe_call(func, *args, retry=2, **kwargs): for attempt in range(retry + 1): try: return func(*args, **kwargs) except Exception: if attempt == retry: traceback.print_exc() return None return None这个函数体现的工程思想很值钱:自动化测试真正稳定运行靠的不是“每条用例都一次过”,而是“失败时不至于整个测试进程崩溃,同时留下足够信息定位问题”。
6.2 把常用函数组织成自己的测试工具库
函数封装到最后,不是零散的一堆方法,而应该沉淀成一套工具库。我通常按这样的目录组织:
common/ ├── base_page.py # Web自动化页面基类 ├── api_client.py # 接口请求封装 ├── data_factory.py # 测试数据生成 ├── file_reader.py # Excel/JSON/YAML读取 ├── assertion.py # 公共断言 └── log_util.py # 日志与报告这套设计的关键判断标准是:一个新入职的测试工程师拿到代码,不需要问太多问题就能在一个用例文件里读懂“在测什么”。如果某个封装让代码看起来更像天书,或者为了一个只在两台机器上出现的问题写了好几层装饰器,那就是过度设计了。
命名规范也是面试和实际审查的注意点。动作+对象的命名方式最稳,比如click_button、fill_input、get_cell_value,一看名字就知道在干嘛。反面教材就是写一堆do_stuff、handle_data,三个月后自己也看不懂。维护工具库时,我有一条红线:尽量不用超过三层的函数调用链。太深了,出问题时顺着调用栈追下去,血压真的会升高。
说到底,自动化的常用函数不是背出来的知识点,而是一边踩坑一边提炼出来的“手艺”。我每次把一段重复代码抽成函数时,都会顺手补上日志和失败现场截图,这个习惯帮我在无数个深夜定位问题时少走了很多弯路,也真心建议正在看这篇文章的你,从下一个用例开始就这么干。