首页 / 资讯中心 / 文章详情

基于Pytest-BDD的UI与API自动化测试统一框架实践

基于Pytest-BDD的UI与API自动化测试统一框架实践 ★ FEATURED ARTICLE
做UI和API自动化测试很多团队一开始是分开搭两套框架的UI用Selenium那套API用Requests写脚本各搞各的。搭完之后就发现问题了业务理解没法统一测试逻辑大量重复维护成本变成两边都在扛。后来我改用统一架构用Python加Pytest-BDD把UI和API自动化整合到同一套框架里让测试用例的编写方式和业务描述语言完全对齐整体维护负担一下就降下来了。这篇文章就把我这套框架的架构设计、目录规划、核心实现和一些踩坑经验完整梳理出来给正在做测试架构升级或者准备从零搭一套可复用自动化框架的同学做个参考。1. 框架选型与整体设计思路1.1 为什么不用Behave或Robot Framework而是选Pytest-BDD市面上Python的BDD框架核心就是Behave和Pytest-BDD这两家Robot Framework其实也算BDD思想的一种实现但它的语法是自成一派的关键字驱动。我在选型时对比过这三者的差别主要从三个方面判断第一团队技术栈的统一程度第二用例维护的灵活度第三生态和CI集成的便利性。Behave有自己独立的测试运行器和报告体系虽然Behavior-Driven的味道很正宗但从工程角度看它始终是和Pytest生态割裂的。要么你单独维护Behave的配置和钩子要么想办法做适配层把两套东西粘在一起长期来看没必要。Pytest-BDD是基于Pytest的一套BDD插件它的核心优势在于所有测试能力包括fixture、参数化、标签过滤、断言、报告插件全部是Pytest原生的。这就意味着整个团队不需要学习两套测试框架的用法只要你熟悉Pytest写BDD用例只是换一种描述方式。Robot Framework则是又一套独立的语法体系和IDE团队学习成本高跟纯代码测试的灵活度差距也大。从我的角度看Pytest-BDD不是最纯粹的BDD实现但它是最适合工程落地的BDD方案。1.2 分层架构业务层、操作层、技术层各管各的框架好不好用关键看分层是否清晰。我常用的分层思路是三层结构第一层是Feature文件用来写业务场景每个Given/When/Then步骤都是自然语言描述的业务动作。第二层是Step Definitions负责把自然语言映射到具体的函数调用这一层只做参数解析和业务流程编排不直接接触底层自动化API。第三层是Page Object和API Client这一类操作封装层UI自动化中对页面元素和交互的封装API自动化中对请求发送和响应解析的封装都在这一层完成。严格遵循这套分层后你会得到一个非常明显的好处写Feature文件的同学可以完全不懂代码只要根据业务描述文档来写场景步骤写Step Definitions的同学只需要关心业务动作的参数和流程不需要关心元素定位和接口路径最底层的维护者只管把页面和请求封装好。三层各管各的出了问题也好排查层级之间通过Well-defined的接口交互不会出现UI定位逻辑跑到步骤定义里的情况。以登录场景为例Feature里那是用户使用正确的用户名密码登录系统Step里做的是调用登录操作并检查结果Page层里才是定位用户名输入框、密码输入框并执行Selenium的send_keys操作。如果某天前端把用户名输入框的id改了你只需要去Page层改那一个定位符Feature和Step完全不用动。1.3 一套代码同时驱动UI和API的机制做UI和API统一框架最核心的挑战不是单层的实现而是怎么让两条测试线在架构上真正统一而不是表面拼在一起。我的做法是引入了一个支持层Support Layer的概念这一层把UI自动化和API自动化共用的公共能力抽出来数据准备、断言工具、报告封装、配置读取、日志记录、数据库操作等等。UI测试和API测试各自的核心逻辑是独立的但它们共用这一层的基础能力。数据准备这个点对UI和API的协同特别重要。比如测试一个下单流程API层可以直接调用创建订单接口准备好订单数据然后UI层接着在这个数据基础上去验证订单页面的展示。这样不是两条平行线而是一条业务流。具体实现上Pytest的fixture机制在这里起了大作用。我可以定义不同范围的fixture比如依赖API的会话级fixture、依赖UI的模块级fixture再通过fixture组合来完成场景编排。Pytest-BDD允许你在Step函数中直接注入fixture这就让UI步骤和API步骤能共享同一个测试上下文。2. 项目目录结构与核心配置2.1 一份可以直接抄的目录规划目录结构是框架设计的骨架刚开始落地时如果拍脑袋随便建文件夹后面各种文件只会越堆越乱。下面这套结构是经过两个完整项目验证过的你可以直接照着搭test_project/ ├── config/ │ ├── __init__.py │ ├── settings.py │ ├── config.yaml │ └── environments/ │ ├── dev.yaml │ ├── staging.yaml │ └── prod.yaml ├── features/ │ ├── ui/ │ │ ├── login.feature │ │ ├── order.feature │ │ └── payment.feature │ └── api/ │ ├── user_api.feature │ ├── order_api.feature │ └── payment_api.feature ├── steps/ │ ├── __init__.py │ ├── conftest.py │ ├── ui_steps/ │ │ ├── login_steps.py │ │ ├── order_steps.py │ │ └── common_ui_steps.py │ └── api_steps/ │ ├── user_steps.py │ ├── order_steps.py │ └── common_api_steps.py ├── support/ │ ├── __init__.py │ ├── page_objects/ │ │ ├── base_page.py │ │ ├── login_page.py │ │ └── order_page.py │ ├── api_clients/ │ │ ├── base_client.py │ │ ├── user_client.py │ │ └── order_client.py │ ├── utils/ │ │ ├── data_builder.py │ │ ├── assertion_utils.py │ │ ├── report_utils.py │ │ └── db_utils.py │ └── drivers/ │ ├── webdriver_manager.py │ └── browser_factory.py ├── data/ │ ├── test_users.json │ ├── test_orders.json │ └── sql/ │ ├── cleanup.sql │ └── seed.sql ├── reports/ ├── logs/ ├── pytest.ini ├── requirements.txt └── run_tests.py有几个细节要特别注意。features目录按UI和API先分一层再按业务模块分一层这个习惯可以帮助你快速定位某个业务场景的用例。steps目录对应features的结构同样分层保证每个feature文件都能找到对应的step实现文件不会出现一个step散落在多个文件里难定位的情况。config目录放环境配置不同环境的域名、账号、测试数据地址都在这里通过环境变量切换。2.2 pytest.ini关键配置解析Pytest-BDD虽然是个插件但它的运行参数还是通过Pytest配置来管理。我项目里的pytest.ini核心配置如下[pytest] markers ui: UI related tests api: API related tests smoke: smoke test cases regression: regression test cases login: login module order: order module payment: payment module addopts --strict-markers -v -s --alluredirreports/allure -p no:cacheprovider log_cli true log_cli_level INFO log_cli_format %(asctime)s [%(levelname)s] %(message)s testpaths features这里--strict-markers配置很关键它强制所有marker必须提前声明避免拼错标签名导致用例被静默跳过。关于conftest.py的放置有一个常见的误区需要提醒很多人习惯把所有fixture都塞在根目录的conftest.py里。我建议按层级分散放。根目录的conftest.py只放全局fixture比如浏览器会话初始化和结束、全局日志配置、公共数据清理等steps/conftest.py放steps目录特有的fixture比如UI测试的页面对象实例化、API测试的客户端会话管理各子目录下的conftest.py再放各自模块特有的fixture。这样fixture的作用范围和生命周期一目了然。2.3 多环境配置切换机制多环境配置是自动化测试绕不开的问题。开发环境、测试环境、预发布环境域名不一样测试数据不一样有的环境还有开关限制。我用的是环境变量加YAML的组合方案。默认跑本地dev环境通过设置ENV环境变量切换export TEST_ENVstaging pytest --tagssmokesettings.py里用动态加载的逻辑来处理环境切换import os import yaml from functools import lru_cache lru_cache def get_config(): env os.getenv(TEST_ENV, dev) config_path os.path.join( os.path.dirname(__file__), environments, f{env}.yaml ) with open(config_path, r, encodingutf-8) as f: config yaml.safe_load(f) return config config get_config()dev.yaml的内容很直白base_url: https://dev-api.example.com web_url: https://dev-web.example.com db: host: localhost port: 3306 user: test password: test123 timeout: web_wait: 20 api_timeout: 10为什么要用lru_cache因为整个测试过程中环境配置只需要加载一次每次读取都去解析YAML纯粹是浪费IO。缓存之后后续所有模块直接拿内存里的字典用就行了。这个细节看起来小但在用例多的时候能省下不少时间。3. UI自动化核心实现3.1 页面对象模式与BDD步骤层的衔接UI自动化我选用Selenium这是最成熟稳定的一套。页面对象模式用来封装页面元素和交互BDD步骤层只负责业务编排。这里有一个重点问题BDD步骤和页面对象怎么衔接才不会乱我的经验是每个Step函数只做三件事解析参数、调用页面对象方法、断言结果。拿登录场景的Step来说from pytest_bdd import given, when, then, parsers when(parsers.parse(用户在登录页面输入用户名{username}和密码{password})) def user_login(login_page, username, password): login_page.enter_credentials(username, password) login_page.submit()这里login_page是通过fixture注入的不在Step内部直接实例化页面对象。用fixture管理页面对象的好处是你可以通过fixture的scope控制页面对象的生命周期并且在fixture内部织入隐式等待和日志记录。而Page层的实现base_page.py提供了一些公共能力from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.common.exceptions import TimeoutException class BasePage: def __init__(self, driver): self.driver driver self.wait WebDriverWait(driver, 20) def find_element(self, locator): 封装元素查找并加日志 try: element self.wait.until( EC.visibility_of_element_located(locator) ) return element except TimeoutException: self.logger.error(f元素定位超时: {locator}) self.take_screenshot(element_timeout) raise def click(self, locator): element self.find_element(locator) element.click() def enter_text(self, locator, text): element self.find_element(locator) element.clear() element.send_keys(text) def take_screenshot(self, name): timestamp datetime.now().strftime(%Y%m%d_%H%M%S) path freports/screenshots/{name}_{timestamp}.png self.driver.save_screenshot(path) return path登录页面继承BasePageclass LoginPage(BasePage): username_input (By.ID, username) password_input (By.ID, password) login_button (By.CSS_SELECTOR, button[typesubmit]) error_message (By.CSS_SELECTOR, .error-tip) def enter_credentials(self, username, password): self.enter_text(self.username_input, username) self.enter_text(self.password_input, password) def submit(self): self.click(self.login_button) def get_error_message(self): return self.find_element(self.error_message).text为什么要把元素定位符放在页面类顶部的类属性里因为这样一眼就能看到这个页面涉及的全部元素维护时方便集中修改。一旦某个元素的定位方式变了你只需要改这个类属性不用满文件去找。3.2 浏览器驱动管理与多浏览器支持浏览器驱动的管理我强烈建议直接用WebDriver Manager库不要再手动下载chromedriver、geckodriver了。手动管理驱动版本是件极其痛苦的事情浏览器一升级驱动就失效你还要去查对应版本号。from selenium import webdriver from webdriver_manager.chrome import ChromeDriverManager from webdriver_manager.firefox import GeckoDriverManager from selenium.webdriver.chrome.service import Service class BrowserFactory: staticmethod def get_driver(browserchrome, headlessFalse): if browser chrome: options webdriver.ChromeOptions() if headless: options.add_argument(--headlessnew) options.add_argument(--no-sandbox) options.add_argument(--disable-dev-shm-usage) options.add_argument(--window-size1920,1080) service Service(ChromeDriverManager().install()) driver webdriver.Chrome(serviceservice, optionsoptions) elif browser firefox: options webdriver.FirefoxOptions() if headless: options.add_argument(--headless) service Service(GeckoDriverManager().install()) driver webdriver.Firefox(serviceservice, optionsoptions) else: raise ValueError(f不支持的浏览器类型: {browser}) driver.implicitly_wait(5) driver.maximize_window() return driverheadless模式在你需要把UI自动化任务放进CI流水线的时候特别重要因为绝大多数CI的runner环境是没有图形界面的。--no-sandbox和--disable-dev-shm-usage这两个参数是给容器环境准备的否则Chrome在Docker容器里经常启动失败。3.3 UI自动化中的等待策略与稳定性优化UI自动化最大的痛点是什么不稳定。最常见的坑就是页面元素加载慢代码执行到点击按钮的时候按钮还没出现。很多初学者用time.sleep固定等待这条路在我看来就是饮鸩止渴。固定等待3秒机器性能好时1秒就加载完了纯浪费时间性能差时不只3秒还是失败。我采用了显式等待为主、隐式等待兜底的策略。初始化driver时设置一个5秒的隐式等待解决80%的元素查找问题。对于关键操作比如点击提交按钮后出现的加载动画和结果区域用显式等待配合Expected Conditions精准控制。加载动画是UI自动化里很常见的隐藏炸弹。页面逻辑是一个加载中遮罩层数据分析完了之后遮罩层会消失。Selenium判断元素可见性的时候遮罩层挡住了目标按钮明明按钮已经在DOM里了就是点不了。这种情况下我会实现一个等待加载完成的自定义条件def wait_for_overlay_to_disappear(driver, overlay_locator): wait WebDriverWait(driver, 20) wait.until(EC.invisibility_of_element_located(overlay_locator))还有更隐蔽的坑一个按钮被点击前是可用的点击后变成loading状态但在loading结束后又需要再次点击。处理这类场景我的技巧是等待按钮状态循环变化直到稳定。还有不要忽略页面滚动。某些前端的懒加载页面里元素虽然已经渲染到DOM中但不在可视区域内WebDriver直接click会报元素不可交互。我在BasePage里封装了一个scroll_to_element方法遇到这类情况先滚动再操作。3.4 失败截图与Allure报告接入UI自动化测试失败时没截图基本等于白跑因为你不知道当时页面上到底发生了什么。我的BasePage里已经写了take_screenshot方法但需要确保它在断言失败时也会自动执行。用Pytest的pytest_runtest_makereport钩子来实现import pytest pytest.hookimpl(tryfirstTrue) def pytest_runtest_makereport(item, call): if call.when call: if call.excinfo is not None: driver item.funcargs.get(driver) if driver is not None: screenshot_path driver.save_screenshot(freports/screenshots/{item.name}_{int(time.time())}.png) allure.attach.file( screenshot_path, name失败截图, attachment_typeallure.attachment_type.PNG )注意这里拿driver是通过item.funcargs来获取当前测试用例的fixture实例这是Pytest内部机制的一种用法能保证截图的是同一个浏览器会话。Allure报告是UI自动化报告的首选原因很简单支持步骤层级展示、失败截图和日志自动附加、历史趋势分析。配合Pytest-BDD每个Given/When/Then步骤会自动变成一个报告里的步骤节点业务人员看报告时也能看出整个用例的执行流程。4. API自动化核心实现4.1 Requests会话封装与统一鉴权处理API自动化我用Requests库做底座核心是需要封装一个BaseClient统一处理请求发送、超时设置、鉴权信息注入、响应日志记录。import requests import logging class BaseClient: def __init__(self, base_url, tokenNone): self.base_url base_url self.session requests.Session() self.token token self.logger logging.getLogger(__name__) def _update_headers(self, headers): merged {Content-Type: application/json} if self.token: merged[Authorization] fBearer {self.token} if headers: merged.update(headers) return merged def request(self, method, path, **kwargs): url f{self.base_url}{path} headers self._update_headers(kwargs.pop(headers, None)) self.logger.info(f发送请求: {method} {url} param{kwargs.get(params, )}) response self.session.request(method, url, headersheaders, **kwargs) self.logger.info(f收到响应: {response.status_code} body{response.text[:500]}) return self._handle_response(response) def _handle_response(self, response): if response.status_code 400: raise ApiException(fAPI请求失败: {response.status_code} {response.text}) return response这里日志记录很关键尤其是请求和响应日志接口联调排查问题的时候全靠它。日志里我会把响应文本截断为前500字符避免超长响应把日志文件撑爆。关于token管理我用一个全局的fixture在创建session时注入pytest.fixture(scopesession) def api_client(): login_resp requests.post( f{config[base_url]}/api/login, json{username: config[api_user], password: config[api_password]} ) token login_resp.json()[data][token] return BaseClient(config[base_url], tokentoken)这样一个session级别的fixture只会在整个测试过程开始时登录一次后面的所有API测试都复用同一个token。如果你的token有效期短可以考虑在每次request前检查token过期时间并自动刷新。这套逻辑稍微复杂点但大项目的API测试里很有必要。4.2 响应断言与数据校验的深度实践API测试断言的核心困局在于结构校验、字段值校验和业务逻辑校验混在一起。我的做法是分三步走。第一步用状态码判断请求是否成功BaseClient里已经处理了非2xx的异常。第二步做结构校验用JsonSchema来校验响应结构的字段类型和是否缺失。第三步才是字段值级别的断言也就是业务逻辑校验。JsonSchema校验可以极大提高API用例的维护效率比如创建一个用户后再查询返回的数据结构变了你期望哪些字段存在哪些字段是整数类型JsonSchema都能自动检查到。def validate_schema(instance, schema): import jsonschema try: jsonschema.validate(instanceinstance, schemaschema) except jsonschema.ValidationError as e: raise AssertionError(f响应数据结构不符合Schema定义: {e.message} in path {list(e.path)})但需要提醒一点JsonSchema对字段类型的要求比较严格如果后端偶尔返回null你需要在schema里把这些字段定义为nullable类型不然全是误报。我通常用如下方式定义{ type: object, properties: { id: {type: integer}, name: {type: [string, null]} }, required: [id, name] }字段值层的断言强烈建议封装一个公共断言工具类统一错误消息格式class AssertionUtils: staticmethod def assert_equal(actual, expected, field_name): if actual ! expected: raise AssertionError( f字段[{field_name}]断言失败: f期望{expected}, 实际{actual}, 类型期望{type(expected)}, 类型实际{type(actual)} ) staticmethod def assert_in_payload(payload, key_path, expected): 按点分路径取值后断言支持嵌套字典 value payload for part in key_path.split(.): value value[part] if value ! expected: raise AssertionError(f路径[{key_path}]断言失败: 期望{expected}, 实际{value})这个assert_in_payload支持data.user.id这种点分路径取值写用例时不用一层层剥开字典再去对比对嵌套结构很友好。4.3 接口关联与动态数据传递接口关联是API自动化里绕不开的课题。场景创建订单后拿到order_id才能去执行支付和查询操作。这就要求步骤之间共享数据。Pytest-BDD里我利用一个专门的Context对象来做测试数据传递class TestContext: def __init__(self): self.store {} def set(self, key, value): self.store[key] value def get(self, key): return self.store.get(key) pytest.fixture def context(): return TestContext()然后在步骤函数里通过参数注入contextwhen(parsers.parse(创建一笔金额为{amount}元的订单)) def create_order(context, api_client, amount): resp api_client.request(POST, /api/orders, json{amount: int(amount)}) order_id resp.json()[data][order_id] context.set(order_id, order_id) then(parsers.parse(该订单可以成功支付)) def pay_order(context, api_client): order_id context.get(order_id) resp api_client.request(POST, f/api/orders/{order_id}/pay) assert resp.json()[data][status] paid这个Context对象的作用是跨Step传递参数。注意context的fixture作用域默认是function级别意味着每个场景都是全新的上下文不会出现脏数据。如果要在一个场景内多个步骤间共享状态这不就刚刚好。如果你需要在不同场景间共享数据比如多个场景都要用一个公共的测试用户ID那就可以把fixture作用域调到session级别。但这时候要格外留意数据的独立性场景A改了用户状态场景B又拿这个状态去判断很容易出现意想不到的依赖。4.4 API参数化与数据驱动BDD配合参数化是API测试的利器。Pytest-BDD支持两种参数化方式一种是Feature里的场景大纲Scenario Outline一种是Pytest原生的pytest.mark.parametrize。场景大纲的写法非常直观Scenario Outline: 验证不同金额的订单创建结果 When 创建一笔金额为amount元的订单 Then 订单创建成功且金额为amount元 Examples: | amount | | 0 | | 1 | | 999.99 | | -1 |这个特性让业务人员可以非常直观地添加测试数据组合不用碰任何代码。它在Pytest-BDD里的名称叫Scenario Outline落库时每个Examples行会生成一个独立的测试用例。但要注意边界值测试的一个坑金额为0和金额为-1这种后端可能有不同的业务规则0可能直接成功-1可能是参数错误所以这里的断言逻辑需要区分场景。用场景大纲时If分支下不同的期望结果就不好写了。我在遇到这类情况时会把断言细化成两种方式要么在步骤定义里根据参数动态构造期望值要么把正反向用例拆成两个不同的场景来写。5. 公共组件与工程化实践5.1 测试数据准备与清理策略测试数据管理是最容易被忽视又最影响稳定性的环节。一个自动化用例跑着跑着突然失败排查下来发现是测试数据被别的用例改了这种情况我见得太多了。我这套框架的数据管理分三层第一层是静态测试数据文件存放在data目录下的JSON文件里主要存放登录账号、基础配置等不常变更的数据。第二层是动态数据工厂通过Python代码实时生成测试数据。第三层是SQL清理脚本在每个测试用例前后恢复测试环境。数据工厂的核心是每次生成的数据都带随机后缀避免重复执行用例时撞数据class DataBuilder: staticmethod def generate_user(): suffix short_uuid() return { username: ftest_{suffix}, email: ftest_{suffix}example.com, password: Test123456 }清理策略我用Pytest的自动fixture每个用例执行完后自动删除测试过程中创建的数据。或者用pytest_fixture_setup/takedown的钩子统一回收。还有一个关键点需要强调如果你测试的是同一个环境并发执行时数据清理和创建之间极容易出现交叉。我的建议是在并行执行场景下给每个Worker分配独立的数据前缀比如worker-1只创建test_w1_开头的用户worker-2只创建test_w2_开头的清理时只清理自己前缀的数据。这样完全避免数据打架。5.2 Pytest-BDD进阶使用标签实现模块化测试项目规模一大用例数量一多如何灵活挑选要跑的用例就变成了重要问题。Pytest-BDD同样支持Pytest原生的标签机制在Feature文件或Scenario上方打marker。标记管理有个不算复杂但很核心的点BDD场景的标记应该在Feature级别还是在Scenario级别我的建议是业务流程归属类标签比如order、login打在每个Scenario上质量等级类标签比如smoke、regression也打在每个Scenario上模块级公共标签比如ui、api可以打在Feature上。原因在于一个Feature文件里可能有十几个Scenario当你只想跑smoke用例时如果标记只打在Feature上你没办法精确过滤出这个文件里的smoke场景。而ui这种能力标签打在Feature上就够了因为一个Feature文件的场景基本都是同一类线别的。执行筛选命令很简单pytest --tagsapi and regression pytest --tagsui and not smoke pytest --tagsorder or payment5.3 日志规范与全链路追踪自动化测试排错日志太重要了。我在框架里统一使用Python内置logging按模块创建子logger并且启用了TraceID机制。所谓TraceID就是在每个测试用例开头生成一个唯一ID然后通过Context对象传递给所有测试步骤。日志输出时把这个TraceID带上这样一组日志记录从发起到断言就可以根据TraceID串起来哪怕多个用例同时跑也不会混淆。pytest.fixture def trace_id(): return short_uuid() pytest.fixture def context(trace_id): ctx TestContext() ctx.set(trace_id, trace_id) return ctx日志格式里加上路径和行号2025-01-05 10:23:45,123 [INFO] [login_steps.py:42] TraceIDabc123 用户登录成功CI上跑完测试发现API调用偶发超时你靠日志里记录的用户、订单号、请求耗时能精准复现效率能差出十倍。5.4 CI/CD集成与定时任务这套框架跑进CI只需要一个命令python run_tests.py --env staging --tags smoke --workers 4run_tests.py里封装了pytest.main()的调用并且直接把命令行参数传递给pytest#!/usr/bin/env python import sys import pytest if __name__ __main__: args sys.argv[1:] pytest.main(args [ --coloryes, --disable-warnings ])在CI的Pipeline里测试阶段本质上就是跑这个脚本再配合Allure的jar包生成在线报告。定时任务方面通过CI的Schedule功能设置每天晚上跑一次全量回归每次代码合并前跑一次smoke集。值得注意的是跑UI自动化任务的CI机器必须配备独立显示环境或者加上headless参数否则Selenium启动会失败。我踩过的坑有一次UI任务在CI容器里跑了几十条用例全都报no such element折腾半天发现是headless模式下Chrome窗口大小默认只有800x600页面触发了响应式布局元素被折叠了。解决办法就是我在BrowserFactory里已经写过的--window-size1920,1080固定窗口尺寸能让页面布局稳定自动化脚本也就稳定了。6. 常见问题与排查速查表6.1 高频问题记录UI自动化里元素定位失败是最常见的问题。通常的原因有以下几类元素在iframe里、元素在Shadow DOM里、异步加载导致元素还没生成、页面存在多个相似元素。iframe的问题通过switch_to.frame来切入Shadow DOM则需要用特定的JavaScript执行器来穿透。最让我印象深刻的是一次iframe嵌套的情况页面点击一个按钮后弹出新区域这个区域竟然是在三层嵌套iframe里。用Selenium的原生API一层层切进去极其繁琐后来我在BasePage里封装了一个递归查找iframe的工具函数专门处理这类复杂情况。API自动化里最常见的坑是服务端返回HTTP 200但业务逻辑出错。这种情况状态码校验根本发现不了问题。一定要配合返回体里的业务状态码判断{ code: 500, message: 用户不存在, data: null }我在BaseClient里增加了一个可配置的参数让调用方可以选择是否开启业务状态码校验。默认配置下如果返回体是JSON并且包含code字段就自动校验code是否等于业务上定义的成功码。6.2 常见问题速查表问题现象可能原因排查方向解决方案元素定位报错NoSuchElement元素未加载完成等待策略是否过短改用显式等待报错ElementClickIntercepted遮罩层或弹窗遮挡检查页面上是否有浮动元素等待遮挡元素消失后再点击用例在Jenkins容器运行失败窗口尺寸触发响应式布局检查运行环境显示尺寸固定窗口大小为1920x1080API响应偶发超时网络或服务端延迟波动查看超时设置和重试机制增加指定次数重试数据重复导致断言失败上轮用例未清理干净查看数据库残留测试数据增加清理策略场景间状态污染fixture部分设了session级别检查fixture的scope配置推荐局部状态使用function级别重新运行与首次运行结果不同测试数据动态生成导致查看日志中数据生成时间排查数据规格不一致问题6.3 排查方法论怎么高效定位自动化用例失败自动化用例失败的排查效率很大程度取决于日志系统的完善程度。我自己的排查流程是标准四步走。第一步看日志。打开CI的执行日志先搜TraceID把这一条用例的所有请求和响应记录按时间排序拉出来快速判断是哪一步出错。第二步看截图和录屏。如果是UI用例失败直接看失败截图里页面状态很多问题一眼就能看出来。第三步看Allure报告里的断言详情。它会展示断言期望值和实际值帮你判断是数据问题还是逻辑问题。第四步复现。如果前三步还没定位到那就用初始的测试数据手动跑一遍场景。这四步走下来绝大多数问题在第三步就能解决。剩下那种日志一切正常、断言也不报错就是不通过的情况几乎都是测试假设和生产行为对不上这类问题最考验对业务的理解。7. 执行策略与工作流设计7.1 测试分层与选择策略自动化测试不是跑得越多越好跑得多、跑得慢的结果就是别人不愿意用你的框架。我在项目里把测试分成了三个层级。第一层是smoke级就是核心主流程的用例比如登录、创建关键业务单、查看主页面数量控制在20条以内在每次代码提交后触发CI跑要求在5分钟内跑完。第二层是regression级覆盖面广所有主要业务场景都纳入每晚定时任务跑一次要求30分钟内完成。第三层是专项测试比如兼容性测试、大数据量测试在版本发布前手动触发。这种分层设计的核心思想是让不同速度、不同覆盖面的测试各司其职而不是做一个大锅炖。smoke用例少而精跑得越快反馈越及时regression用例多而全慢点无所谓但要覆盖到位。7.2 并行执行实践用例多了以后串行执行的时间里大量页面发出请求但等在等待I/O资源利用率毫无意义。改用并行执行收益立竿见影。Pytest的并行执行主力是pytest-xdist。使用很简单pytest --workers4 --distloadscope这里--distloadscope是一个非常关键的参数。它按模块分组分发用例同一个模块的用例分配到同一个Worker上执行。如果你不加这个参数pytest-xdist默认是load平衡模式把用例一个一个分发给不同的Worker。对普通单元测试来说没问题但对UI测试来说每个Worker是一个独立的浏览器进程如果两个用例都用同一个静态测试账号登录负载均衡模式下很容易发生账号互踢。给你描述一下这个坑有多痛苦。跑完一遍CI报了几个登录后跳转异常你以为是系统的Bug。复看日志才发现是并行执行时两个Worker同时用同一个账号登录后者把前者的会话挤掉了前者再执行操作就直接给踢到登录页。用loadscope按模块隔离后每个模块一个Worker同一模块内的用例共享同一个浏览器进程账号就不会互相干扰了。7.3 失败重试机制重试机制是个双刃剑。对偶发的网络抖动、前端动画导致的短暂问题重试确实能提高用例稳定性。但如果用例有真正的业务逻辑Bug重试只是把失败时间推迟并且掩盖了问题的真实性。推荐的做法是以Step为单位重试而不是整个用例重试。定义一个装饰器在关键的步骤上做有限次重试def retry_step(max_retries3): def decorator(fn): functools.wraps(fn) def wrapper(*args, **kwargs): for attempt in range(max_retries): try: return fn(*args, **kwargs) except Exception as e: if attempt max_retries - 1: raise time.sleep(2 * (attempt 1)) logger.warning(f步骤执行失败第{attempt 1}次重试: {e}) return wrapper return decorator为什么不像很多人习惯的那样对整个用例重试因为整个用例重试会把前置步骤全部重新跑一遍慢且不说还有可能在数据清头时把之前已经产生的数据搞乱。步骤级重试只针对那个偶尔闪断的环节对整体执行效率的影响小得多。但这里有一个原则必须守住重试只适用于操作步骤不要对断言步骤轮询重试。断言重试是个极其隐蔽的陷阱它会让测试用例由一个真实验证退化成网络轮询在理想状态下测试应该通过但这时某处逻辑实际有问题因为最终一次轮询可能总是撞对于是变得不可靠。我见过CI上一条用例重试8次后成功大家欢呼后又开始互相推诿的画面但其实那正是说明测试已经没发挥到应有的作用。8. 从零到落地按这个顺序推进避免返工8.1 五阶段推进路线框架落地不一定要大而全步步为营按阶段推进对手头项目更合适。第一阶段搭骨架目录结构、配置管理、pytest.ini、日志、报告。目标只有一个跑通一个最小可用的demo用例。第二阶段做API层先把后端核心接口用BDD封装起来这个阶段不依赖任何UI元素跑起来最快也最容易产生价值。第三阶段做UI层在API层可以完整跑通核心业务流后开始实现Page Object和UI步骤。第四阶段做工程化CI集成、并行执行、失败重试、Allure报告整理。第五阶段做规模化把历史测试脚本迁移到新框架、逐步扩充业务覆盖。为什么我坚持建议先做API再做UI因为API层的稳定性远高于UI层不会有各种元素定位和加载等待的问题。API层先把接口的业务逻辑和断言经验沉淀下来你做UI时很多数据准备和流程校验直接用API层的能力还能顺便给UI测试省掉大量造数环节。8.2 团队协作与代码规范如果团队里多人一起维护这套框架代码规范比任何文档都重要。我从实践中整理出几条规则。目录结构规则方面勿在steps目录下新建.py文件新增的模块或工具必须放进support目录下对应的子目录。文件名规则方面Feature文件名和Steps文件名要一一对应例如login.feature对应login_steps.py不要出现一对多的关联方式。Step定义命名规则方面Given、When、Then的动词和名词组合要清晰无歧义例如用户在首页点击登录按钮这种形式要能看懂在做什么且不含时序概念。断言收敛规则方面断言必须使用AssertionUtils或严格使用pytest断言禁止胡乱写。每次提交代码前必须本地跑一遍静态检查加相关模块用例。对UI元素的封装粒度我也希望团队收敛成一个原则一个页面所有的元素定位都要写在Page类里不要散步在Step定义中。团队的习惯培养很重要。要让核心成员先熟练这套框架的执行和排查流程他们再推广给其他人用而非一上来就全员铺开。9. 个人经验与踩坑复盘9.1 一个曾经让团队尴尬的回归事故我分享一个发生过的事故某次版本发布前UI回归测试全绿API回归测试也全绿但产品上线后核心功能依旧出了问题。问题是这么发生的前端做了页面交互重构旧的页面元素虽然仍然存在但用户的操作路径已经完全变了。UI自动化用例还在用旧的Page Object去点击元素前端为了兼容旧版本没有删除旧元素所以用例照样能通过。而真正的用户根本不会走那个已经被隐藏的交互入口。这个事故给我上了非常重要的一课自动化用例的价值不只是证明测试通过了更要证明它测试的是当前真实的用户行为流。从那以后每次前端重构时必须同步更新UI用例并主动废弃已经被前端重构掉的交互路径而不是等着用例自己失败。这个教训同样适用于API测试如果后端接口已经改版旧接口虽然还能调用但新业务不再使用了你的API用例也得跟着把所有旧接口调用全部替换掉。9.2 UI与API共享同一套断言逻辑的价值做这套框架后我最大的收获之一是UI层和API层复用了几乎所有的断言工具和数据构造工具。传统UI测试和API测试是两个团队各做一套UI断言靠Selenium里的文本抓取和数据核对API断言是单独的schema验证。但这套框架里UI执行到某个页面后我可以同时调API去验证后端数据的正确性而不是只验证前端显示。比如页面显示订单金额是100元UI断言成功了但API里这个订单的金额字段实际是101元这种情况下页面对用户展示出的内容与实际数据不符U族有所警觉但如果我甚至没有这一步可能就根本发现不了这个前端显示的问题。我在UI的步骤定义里经常会这样做then(页面订单详情展示金额) def verify_order_amount_display(page_context, api_client, context): displayed_amount order_page.get_displayed_amount() api_amount api_client.get_order_amount(context.get(order_id)) AssertionUtils.assert_equal(displayed_amount, api_amount, 订单金额展示与接口一致)这个交叉验证的做法价值很大它把UI和API真正统一成一条业务链也让自动化框架的完整体验上升了一个层级。但提醒一句这种验证方式本质上是在同时验证前后端一致性你就不能再把它简单归类为纯UI测试。设计用例时要结合业务场景别把每条用例都写成这种交叉验证毕竟这种用例的执行时间以及排错的复杂度都远超单层用例。9.3 框架的后续演进方向虽然这套框架已经稳定跑了很长时间但我知道它仍然有持续的演进空间。比如UI自动化目前依托Selenium虽然稳定但执行速度相对慢对大量页面的并发支撑捉襟见肘。未来引入Playwright做新一代浏览器自动化底座是值得考虑的方向。Playwright的wait逻辑和自动等待机制比Selenium先进而且自带浏览器下载管理和录制调试功能。再比如AI辅助定位元素已经在影响着自动化测试的玩法。传统的元素定位是你在页面写死一个selector页面一改就失效。AI辅助的视觉定位能让用例识别元素的能力更抗页面变化。但我要说的是技术底座替换和AI辅助是锦上添花的事情框架本身的架构分层和BDD语义化才是支撑长期维护性的核心。只要你把这一层架构设计扎实了底层从Selenium换成任何别的工具都只是覆盖替换的事。10. 最后想说的话从2019年第一次接触Pytest-BDD到如今我对这套框架的感悟经历了从简单到复杂又回归简单的一个循环。最初我以为Pytest-BDD不过是把测试用例写成Given-When-Then格式后来才意识到它的价值在于把测试的技术实现语义化和业务语言对齐。最初我以为UI自动化和API自动化应该分家各做各的后来才在一次次实际项目中体会到它们本就是一条业务链的两端。最初我以为框架的工程量主要在脚本编写上后来踩遍了各种环境和复杂度问题后才知道真正的工程量是工程化落地。如果你正准备搭建自己的自动化测试架构我的建议很具体先不要追求一次性搞定所有功能把最小可用版本跑通让一个业务场景从API到UI完整走一遍再横向扩展模块。这一步做完你会发现自己对整个系统的理解也会更上一层自动化测试框架的落地就不再是一件遥不可及的事情。这套方案我已经在多个实际项目中反复用过了每一步的可行性都被验证过。你照着这个架构去搭建能少走很多我当年绕进去的弯路。
阅读完成 · 觉得有帮助?
咨询建站