Python UI自动化测试实战:pytest与Selenium黄金组合搭建企业级框架
1. 项目概述:为什么选择 pytest + selenium 这套组合拳?
如果你正在为网页应用的功能回归测试而头疼,每次上线前都要手动点一遍按钮、填一遍表单,那 UI 自动化测试就是你必须要掌握的一项技能。而pytest加selenium,可以说是 Python 生态里做这件事的“黄金搭档”。我用了这套组合好几年,从简单的登录测试到复杂的电商下单流程,它都能稳稳地扛下来。
简单来说,selenium负责“动手”,它就像一个虚拟的、不知疲倦的用户,能按照你的指令去打开浏览器、点击元素、输入文字。而pytest负责“动脑”和“管理”,它提供了强大的测试发现、执行、断言和报告机制,让一堆零散的自动化操作变成结构清晰、可维护的测试用例集。市面上也有其他框架,比如 unittest,但 pytest 的简洁语法(比如直接用assert)、丰富的插件生态(如生成漂亮的 HTML 报告)以及强大的 fixture 机制,让它成为更现代、更高效的选择。这套组合特别适合测试、开发以及 DevOps 工程师,用于构建可靠的前端功能回归防线,尤其是在敏捷开发、持续集成(CI)的流程中,能极大解放人力。
2. 环境搭建与核心工具选型
开始写脚本之前,得先把“战场”布置好。这里面的坑,新手最容易踩。
2.1 Python 环境与包管理
首先确保你有一个干净的 Python 环境(建议 3.7 及以上版本)。我强烈推荐使用virtualenv或venv创建虚拟环境,这是避免包冲突的黄金法则。在项目根目录下执行:
python -m venv venv # Windows 激活 venv\Scripts\activate # Linux/Mac 激活 source venv/bin/activate激活后,命令行提示符前会出现(venv)标识。接着,用 pip 安装核心包:
pip install pytest selenium这里有个实操心得:网络问题可能导致 pip 安装缓慢或失败。可以配置国内镜像源,例如使用阿里云镜像:pip install -i https://mirrors.aliyun.com/pypi/simple/ pytest selenium。安装后,用pytest --version和python -c “import selenium; print(selenium.__version__)”验证安装是否成功。
2.2 浏览器驱动的选择与配置
Selenium 本身不能直接控制浏览器,它需要通过一个名为“WebDriver”的桥梁。你需要下载与你本地浏览器版本匹配的驱动。
- 确定浏览器版本:打开你的 Chrome 或 Firefox,在设置里查看精确版本号。
- 下载对应驱动:
- Chrome:访问 ChromeDriver 官网 或国内镜像站,下载对应版本。
- Firefox:访问 GeckoDriver 发布页 。
- 配置驱动路径:有三种常用方法,我推荐第三种,最省事。
- 方法一(不推荐):将下载的驱动文件(如
chromedriver.exe)放在系统 PATH 环境变量包含的目录里(如 Windows 的C:\Windows)。这容易造成版本管理混乱。 - 方法二(灵活):在代码中指定驱动路径。
from selenium import webdriver driver = webdriver.Chrome(executable_path=‘/你的路径/chromedriver’) - 方法三(推荐,使用
webdriver-manager):安装这个包,它可以自动下载和管理匹配的浏览器驱动,彻底告别手动下载和版本匹配的烦恼。
使用时:pip install webdriver-managerfrom selenium import webdriver from webdriver_manager.chrome import ChromeDriverManager from selenium.webdriver.chrome.service import Service service = Service(ChromeDriverManager().install()) driver = webdriver.Chrome(service=service)
- 方法一(不推荐):将下载的驱动文件(如
注意:如果你在公司内网或代理环境下,
webdriver-manager可能下载失败。这时需要回退到方法二,并确保团队内部共享统一版本的驱动文件。
2.3 IDE 与项目结构规划
用什么写代码都行,但 PyCharm 或 VS Code 对 Python 和 pytest 的支持更好。更重要的是规划一个清晰的项目结构,这对后续维护至关重要。一个典型的结构如下:
your_ui_auto_project/ ├── conftest.py # pytest 共享 fixture 配置,如驱动初始化 ├── requirements.txt # 项目依赖包列表 ├── test_cases/ # 存放测试用例文件 │ ├── __init__.py │ ├── test_login.py │ └── test_search.py ├── page_objects/ # 页面对象模型(PO)目录 │ ├── __init__.py │ ├── base_page.py │ ├── login_page.py │ └── home_page.py ├── test_data/ # 测试数据文件(如 JSON, YAML, Excel) │ └── users.json ├── reports/ # 测试报告输出目录(由插件生成) └── utils/ # 工具函数,如截图、日志、数据读取 ├── __init__.py └── logger.py先建立这个骨架,后面的代码往里填,思路会清晰很多。
3. 从零编写第一个自动化脚本与用例
让我们从一个最简单的例子开始:打开百度,搜索一个关键词,并验证搜索结果标题。
3.1 最简单的线性脚本
创建一个文件test_first_script.py,先不用任何框架,就用最原始的 Selenium 脚本:
from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys import time # 1. 启动浏览器(这里使用自动管理驱动的方式) from webdriver_manager.chrome import ChromeDriverManager from selenium.webdriver.chrome.service import Service service = Service(ChromeDriverManager().install()) driver = webdriver.Chrome(service=service) try: # 2. 打开网页 driver.get(“https://www.baidu.com”) time.sleep(2) # 等待页面加载,这是初级做法,后面会改进 # 3. 定位元素并操作 search_box = driver.find_element(By.ID, “kw”) # 百度搜索框的ID是‘kw’ search_box.send_keys(“pytest selenium”) search_box.send_keys(Keys.RETURN) # 模拟回车键 time.sleep(3) # 等待搜索结果加载 # 4. 进行断言验证 assert “pytest_selenium” in driver.title.lower() # 粗略验证标题 print(“测试通过!”) finally: # 5. 关闭浏览器 driver.quit()运行这个脚本:python test_first_script.py。你会看到浏览器自动打开、搜索、然后关闭。这就是自动化的魔力。但这段代码问题很多:硬编码的等待time.sleep、断言过于简单、没有错误报告、浏览器窗口一闪而过不利于调试。
3.2 用 pytest 改造为正式测试用例
现在,我们用 pytest 的规则重写它。pytest 会自动发现以test_开头或_test结尾的文件和函数。
创建test_cases/test_baidu_search.py:
import pytest from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC class TestBaiduSearch: """百度搜索测试类""" # 每个测试方法开始前执行 def setup_method(self): from webdriver_manager.chrome import ChromeDriverManager from selenium.webdriver.chrome.service import Service service = Service(ChromeDriverManager().install()) self.driver = webdriver.Chrome(service=service) self.driver.implicitly_wait(10) # 设置隐式等待,全局生效 self.wait = WebDriverWait(self.driver, 10) # 显式等待对象 # 每个测试方法结束后执行 def teardown_method(self): self.driver.quit() def test_search_pytest_selenium(self): """测试搜索 pytest selenium 关键字""" driver = self.driver wait = self.wait driver.get(“https://www.baidu.com”) # 使用显式等待,更智能地等待元素出现 search_input = wait.until( EC.presence_of_element_located((By.ID, “kw”)) ) search_input.send_keys(“pytest selenium” + Keys.RETURN) # 等待搜索结果区域出现,并断言其中包含特定文本 first_result = wait.until( EC.presence_of_element_located((By.CSS_SELECTOR, “#content_left .result”)) ) assert “pytest” in first_result.text.lower() assert “selenium” in first_result.text.lower() # 你可以添加更多测试方法 def test_search_weather(self): """测试搜索天气""" self.driver.get(“https://www.baidu.com”) # ... 类似的操作和断言现在,在项目根目录下运行pytest test_cases/test_baidu_search.py -v。-v参数表示详细输出,你会看到每个测试方法的执行结果(PASSED 或 FAILED)。pytest 会自动执行setup_method和teardown_method,管理浏览器的生命周期。这里的核心改进是用了等待机制,替代了不稳定的time.sleep。
4. 构建可维护的自动化测试框架
当用例越来越多,直接在每个测试方法里写find_element和send_keys会变得难以维护。这时需要引入设计模式。
4.1 页面对象模型(PO)实战
PO 模式的核心思想是将页面封装成类,页面的元素定位和操作作为类的方法,测试用例只调用这些方法,不关心具体定位细节。这样,前端页面改了,你只需要改对应的 Page 类,测试用例基本不用动。
首先,在page_objects目录下创建base_page.py,这是一个所有页面类的基类,封装公共操作:
from selenium.webdriver.support.wait import WebDriverWait from selenium.webdriver.support import expected_conditions as EC class BasePage: def __init__(self, driver): self.driver = driver self.wait = WebDriverWait(driver, 10) def find_element(self, locator): """查找单个元素(显式等待)""" return self.wait.until(EC.presence_of_element_located(locator)) def find_elements(self, locator): """查找多个元素""" return self.wait.until(EC.presence_of_all_elements_located(locator)) def click(self, locator): """点击元素""" element = self.find_element(locator) element.click() def input_text(self, locator, text): """输入文本""" element = self.find_element(locator) element.clear() element.send_keys(text) def get_text(self, locator): """获取元素文本""" return self.find_element(locator).text然后,创建具体的页面类,例如login_page.py:
from selenium.webdriver.common.by import By from .base_page import BasePage class LoginPage(BasePage): # 定位器:将元素定位方式集中管理 USERNAME_INPUT = (By.ID, “username”) PASSWORD_INPUT = (By.ID, “password”) LOGIN_BUTTON = (By.ID, “loginBtn”) ERROR_MSG = (By.CLASS_NAME, “error-message”) def __init__(self, driver): super().__init__(driver) self.driver = driver def open(self, url): self.driver.get(url) return self def login(self, username, password): """登录操作""" self.input_text(self.USERNAME_INPUT, username) self.input_text(self.PASSWORD_INPUT, password) self.click(self.LOGIN_BUTTON) return self # 链式调用,可返回结果页对象 def get_error_message(self): """获取错误提示信息""" return self.get_text(self.ERROR_MSG)最后,测试用例变得非常简洁清晰。创建test_cases/test_login.py:
import pytest from page_objects.login_page import LoginPage class TestLogin: def test_login_success(self, browser): # 使用了 fixture ‘browser‘ login_page = LoginPage(browser) login_page.open(“https://your-app.com/login”) # 假设登录成功会跳转到首页,首页标题包含‘Dashboard’ login_page.login(“valid_user”, “valid_pass”) assert “Dashboard” in browser.title def test_login_failed_with_wrong_password(self, browser): login_page = LoginPage(browser) login_page.open(“https://your-app.com/login”) login_page.login(“valid_user”, “wrong_pass”) error_text = login_page.get_error_message() assert “密码错误” in error_text你看,测试用例里已经没有find_element和By.ID了,全是业务语义的操作。这就是 PO 模式带来的可读性和可维护性提升。
4.2 使用 pytest fixture 管理测试生命周期
上面的例子中,TestLogin类需要一个browser参数。这个browser就是一个fixture,它负责创建和销毁 WebDriver 实例。我们把fixture定义在conftest.py文件中,这个文件里的fixture可以被整个项目共享。
在项目根目录创建conftest.py:
import pytest from selenium import webdriver from webdriver_manager.chrome import ChromeDriverManager from selenium.webdriver.chrome.service import Service @pytest.fixture(scope=“class”) def browser(): """提供 WebDriver 实例的 fixture,作用域为类级别(一个测试类共用同一个浏览器实例)""" service = Service(ChromeDriverManager().install()) driver = webdriver.Chrome(service=service) driver.implicitly_wait(5) driver.maximize_window() # 默认最大化窗口 yield driver # 测试执行时使用 driver driver.quit() # 测试结束后退出 @pytest.fixture def login_page(browser): """直接提供一个登录页面的 fixture""" from page_objects.login_page import LoginPage return LoginPage(browser)现在,测试用例可以直接使用这些fixture。scope=“class”表示这个browser在一个测试类中只初始化一次,所有方法共用,可以加快执行速度(如果用例间没有状态依赖)。你也可以用scope=“function”(默认值)让每个测试方法都重启浏览器。
4.3 测试数据分离与管理
不要把测试数据硬编码在用例里。将数据分离出来,便于管理和参数化测试。pytest 的@pytest.mark.parametrize装饰器是神器。
首先,可以简单地在用例中参数化:
import pytest class TestLoginWithData: @pytest.mark.parametrize(“username, password, expected”, [ (“admin”, “admin123”, True), # 成功用例 (“admin”, “wrong”, False), # 失败用例 (“”, “admin123”, False), # 空用户名 (“admin”, “”, False), # 空密码 ]) def test_login_params(self, browser, username, password, expected): login_page = LoginPage(browser) login_page.open(“...”) login_page.login(username, password) if expected: assert “Dashboard” in browser.title else: assert login_page.get_error_message() != “”对于更复杂的数据,可以放在外部文件里,如 JSON 或 YAML。创建test_data/login_data.json:
[ { “case_name”: “正确账号密码登录成功”, “username”: “test_user”, “password”: “123456”, “expected”: “success” }, { “case_name”: “错误密码登录失败”, “username”: “test_user”, “password”: “wrong”, “expected”: “fail” } ]然后在conftest.py或工具函数中读取数据:
import json import pytest def load_login_data(): with open(‘test_data/login_data.json’, ‘r’, encoding=‘utf-8’) as f: return json.load(f) @pytest.fixture(params=load_login_data()) def login_data(request): return request.param # 在用例中使用 def test_login_with_json_data(browser, login_data): # login_data 就是 JSON 数组中的每一个字典对象 pass5. 高级技巧与最佳实践
掌握了基础框架后,这些技巧能让你的自动化脚本更健壮、更专业。
5.1 智能等待与元素定位策略
永远不要用time.sleep,这是 UI 自动化测试的第一条军规。要用 Selenium 提供的等待机制。
- 隐式等待 (Implicit Wait):
driver.implicitly_wait(10)设置一个全局等待时间,在查找任何元素时,如果元素没有立即出现,会轮询等待最多10秒。它是一次性设置,对整个 driver 生命周期有效。但它不适用于元素的状态(如可点击、可见)。 - 显式等待 (Explicit Wait):针对特定元素和条件进行等待,更灵活、更精确。这是推荐的主要方式。
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By wait = WebDriverWait(driver, 10, poll_frequency=0.5, ignored_exceptions=[NoSuchElementException]) # 等待元素可见并可点击 button = wait.until(EC.element_to_be_clickable((By.ID, “submitBtn”))) button.click() # 等待元素包含特定文本 element = wait.until(EC.text_to_be_present_in_element((By.TAG_NAME, “h1”), “Welcome”))
元素定位优先级建议:ID>Name>CSS Selector>XPath。ID 和 Name 通常最稳定。CSS Selector 性能好,语法简洁。XPath 功能强大但性能稍差,且容易因页面结构微小变动而失效,谨慎使用。对于动态ID(包含变化部分),可以使用CSS Selector的模糊匹配,如input[id*=‘username’]。
5.2 测试报告与日志记录
跑完测试,你需要知道结果。pytest 有很多插件可以生成漂亮的报告。
- pytest-html:生成 HTML 报告。
pip install pytest-html pytest --html=reports/report.html --self-contained-html - allure-pytest:生成非常美观、交互性强的 Allure 报告。
pip install allure-pytest pytest --alluredir=./allure-results # 生成报告 allure serve ./allure-results
同时,加入日志记录,方便调试。在conftest.py或一个工具模块中配置:
import logging import sys def get_logger(name): logger = logging.getLogger(name) logger.setLevel(logging.INFO) if not logger.handlers: ch = logging.StreamHandler(sys.stdout) formatter = logging.Formatter(‘%(asctime)s - %(name)s - %(levelname)s - %(message)s’) ch.setFormatter(formatter) logger.addHandler(ch) return logger # 在 fixture 或 Page 类中使用 logger = get_logger(__name__) logger.info(“正在打开登录页面...”)5.3 失败截图与重试机制
测试失败时,一张截图抵得上千行日志。我们可以通过修改conftest.py中的 fixture 或使用 hook 函数来实现自动截图。
import pytest from datetime import datetime @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): """获取测试用例执行结果的钩子函数""" outcome = yield rep = outcome.get_result() if rep.when == “call” and rep.failed: # 只有测试执行阶段失败才截图 driver = item.funcargs.get(“browser”, None) if driver is not None: timestamp = datetime.now().strftime(“%Y%m%d_%H%M%S”) screenshot_name = f”screenshot_failure_{item.name}_{timestamp}.png” driver.save_screenshot(f”reports/{screenshot_name}”) print(f”\n测试失败,截图已保存至:reports/{screenshot_name}”)对于不稳定的测试(如网络波动),可以使用pytest-rerunfailures插件进行重试。
pip install pytest-rerunfailures pytest --reruns 3 --reruns-delay 2 # 失败后重试3次,每次间隔2秒6. 常见问题排查与避坑指南
在实际操作中,你肯定会遇到各种奇怪的问题。这里记录了一些高频坑点和解决思路。
6.1 元素定位不到(NoSuchElementException)
这是最常见的问题。
- 可能原因1:等待时间不足。页面还没加载完就去定位元素。
- 解决:使用显式等待
WebDriverWait配合EC.presence_of_element_located或EC.visibility_of_element_located。
- 解决:使用显式等待
- 可能原因2:元素在 iframe 或 shadow DOM 内。
- 解决:先切换到对应的 iframe:
driver.switch_to.frame(“frame_name_or_id”),操作完再切回来:driver.switch_to.default_content()。Shadow DOM 需要使用driver.execute_script执行 JavaScript 来定位。
- 解决:先切换到对应的 iframe:
- 可能原因3:元素属性是动态生成的。每次刷新页面 ID 或 Class 会变。
- 解决:使用相对定位,如通过父元素的稳定属性结合 XPath 轴(如
following-sibling::,parent::)或 CSS Selector 的其他属性(如^=开头,$=结尾,*=包含)。
- 解决:使用相对定位,如通过父元素的稳定属性结合 XPath 轴(如
- 可能原因4:页面有多个相同属性的元素。
find_element只返回第一个。- 解决:使用
find_elements获取列表,然后按索引或遍历查找;或者使用更精确的定位器。
- 解决:使用
6.2 脚本在 CI/CD 环境(如 Jenkins)中运行失败
本地跑得好好的,一上 Jenkins 就挂。
- 可能原因1:无头模式或缺少显示服务器。Linux 服务器通常没有图形界面。
- 解决:使用无头模式运行 Chrome。
from selenium.webdriver.chrome.options import Options chrome_options = Options() chrome_options.add_argument(“--headless”) # 无头模式 chrome_options.add_argument(“--no-sandbox”) # 在 Docker 或某些 CI 环境中需要 chrome_options.add_argument(“--disable-dev-shm-usage”) # 解决共享内存问题 driver = webdriver.Chrome(options=chrome_options)
- 解决:使用无头模式运行 Chrome。
- 可能原因2:环境路径问题。Jenkins 节点上可能没有 Chrome 浏览器或驱动。
- 解决:确保 CI 环境中安装了 Chrome(或使用
webdriver-manager自动处理驱动),或者使用 Docker 镜像来提供一致的环境。
- 解决:确保 CI 环境中安装了 Chrome(或使用
- 可能原因3:权限或防火墙。脚本无法访问目标测试环境。
- 解决:检查 Jenkins 节点的网络配置,确保能连通测试服务器。
6.3 自动化测试不稳定(Flaky Tests)
有时成功有时失败,最让人抓狂。
- 对策1:强化等待。检查所有关键操作前后是否都有合适的显式等待,特别是对于 Ajax 加载的内容。
- 对策2:避免依赖固定等待时间。彻底抛弃
time.sleep。 - 对策3:优化定位器。使用更稳定、唯一的元素定位方式,避免使用绝对 XPath。
- 对策4:引入重试机制。如上文所述,使用
pytest-rerunfailures对不稳定用例进行重试。 - 对策5:隔离测试环境与数据。确保测试用例是独立的,不依赖前一个用例产生的数据。每次测试前可以清理或重置测试数据。
6.4 如何选择 UI 自动化还是接口自动化?
这也是一个常见困惑。我的经验是:
- UI 自动化:适合验证端到端的用户业务流程和前端交互。例如,完整的用户注册-登录-下单流程。它更贴近真实用户,但运行慢、稳定性相对差、维护成本高。
- 接口自动化:适合验证业务逻辑和数据一致性。它运行极快、稳定性高、维护成本低。例如,验证提交订单接口是否成功扣减库存、生成订单号。
最佳实践是结合使用:用接口自动化覆盖大部分业务逻辑和核心数据流测试,构建快速反馈的测试层;用 UI 自动化覆盖关键的用户场景和核心业务流程,作为最终的用户验收层。不要试图用 UI 自动化覆盖所有测试点,那会是一场维护噩梦。
我个人在搭建自动化体系时,通常会遵循“金字塔模型”:底层是大量的单元测试和接口测试(快速稳定),顶层是少量的、关键的 UI 自动化测试(覆盖核心场景)。pytest + selenium 正是打造这顶层关键测试的利器。记住,UI 自动化的目标不是发现大量 bug,而是保障核心流程的畅通无阻,为持续交付提供信心。
