Pytest+Selenium实战:攻克验证码登录的UI自动化测试框架搭建

Pytest+Selenium实战:攻克验证码登录的UI自动化测试框架搭建 1. 项目概述从零构建一个健壮的UI自动化测试框架最近在团队里做了一次关于UI自动化测试的分享主题是如何用Pytest框架去处理一个看似简单、实则暗藏玄机的经典场景带验证码的登录功能测试。很多刚接触自动化的同学一看到验证码就头疼觉得这是自动化测试的“禁区”。其实不然只要思路清晰、策略得当这个“禁区”完全可以被我们系统化地攻克。今天我就把这个实战项目的完整思路、代码实现和踩过的坑毫无保留地分享出来。无论你是刚入门的新手还是想优化现有框架的老手这篇文章都能给你提供一套可直接复用的解决方案。我们将围绕“账号、密码、验证码”这个核心业务流程构建一个结构清晰、易于维护、具备高可扩展性的Pytest UI自动化测试框架。2. 核心需求与挑战分析2.1 业务场景拆解我们要测试的是一个典型的Web登录页面它包含三个核心输入项用户名、密码、验证码以及一个登录按钮。从业务角度看测试用例需要覆盖正向流程输入正确的用户名、密码和验证码登录成功。反向流程异常校验用户名错误空、格式不对、不存在。密码错误空、错误。验证码错误空、错误。组合错误。这听起来很简单但难点就在于“验证码”。验证码的设计初衷就是为了防止机器自动化操作这直接与我们自动化测试的目标相悖。2.2 主要技术挑战验证码识别这是最大的拦路虎。图像验证码、滑块验证码、点选验证码等都需要不同的破解策略。测试数据管理需要管理有效的测试账号、密码以及模拟各种无效数据。用例结构与可维护性如何设计测试用例使得新增用例如测试“忘记密码”功能时改动最小且不与登录逻辑耦合。测试稳定性UI自动化受网络、页面加载速度、元素定位稳定性影响大需要完善的等待机制和异常处理。测试报告与日志需要清晰的结果展示方便快速定位失败原因。2.3 框架选型思路为什么选择Pytest Selenium Page Object Model (POM)这个组合Pytest远超unittest的测试框架。它的夹具fixture机制可以优雅地管理浏览器驱动、测试数据参数化pytest.mark.parametrize能轻松实现数据驱动测试丰富的插件生态如pytest-html, allure-pytest能生成美观的报告。SeleniumWeb UI自动化的行业标准社区活跃浏览器支持好。Page Object Model (POM)将页面元素定位和操作封装成类使测试脚本业务逻辑与页面细节分离。这是提升可维护性的关键设计模式。面对验证码我们的核心策略不是“硬刚”而是根据测试环境灵活选择绕过方案。在测试环境中我们完全可以寻求开发同学的帮助这是最稳定、最高效的方式。3. 项目结构设计与框架搭建一个清晰的项目结构是后续高效开发和维护的基石。下面是我采用的目录结构它体现了关注点分离的原则。project_root/ │ ├── conftest.py # Pytest全局配置文件定义fixture ├── pytest.ini # Pytest配置文件 ├── requirements.txt # 项目依赖包列表 │ ├── common/ # 公共模块 │ ├── __init__.py │ ├── base_page.py # 页面基类封装通用方法 │ ├── logger.py # 日志记录模块 │ └── handle_verify_code.py # 验证码处理模块核心 │ ├── page_objects/ # 页面对象层 │ ├── __init__.py │ └── login_page.py # 登录页面对象 │ ├── test_cases/ # 测试用例层 │ ├── __init__.py │ ├── conftest.py # 用例层特有的fixture │ └── test_login.py # 登录功能测试用例 │ ├── test_data/ # 测试数据层 │ ├── __init__.py │ └── login_data.yaml # 使用YAML管理登录测试数据 │ ├── reports/ # 测试报告目录 │ └── (报告文件) │ └── drivers/ # 浏览器驱动目录 ├── chromedriver(.exe) └── geckodriver(.exe)关键文件解析conftest.py 定义driver夹具所有测试用例只需声明即可使用初始化好的浏览器实例。还可以定义login_page夹具直接返回初始化好的页面对象。base_page.py 所有页面对象的父类。封装了如find_element、click、input_text等通用方法并内置了显式等待、日志记录和截图功能。这避免了在每个页面对象中重复编写等待逻辑。handle_verify_code.py 验证码处理策略的调度中心。根据配置调用不同的处理方法。login_data.yaml 使用YAML文件管理测试数据结构清晰易于阅读和修改。例如可以定义多组“用户名、密码、预期结果”的组合。实操心得在项目初期就搭建好这个结构虽然多花半小时但后期新增页面或功能时你会感谢自己。千万不要把所有代码都堆在一个文件里。4. 核心模块实现详解4.1 验证码处理策略实战验证码是UI自动化测试中的经典难题。我们的原则是在测试环境中优先采用非识别式的绕过或屏蔽方案这比研究复杂的识别算法更稳定、更经济。策略一万能验证码推荐这是最优雅的解决方案。与开发沟通为测试环境提供一个固定的、通用的验证码例如“8888”或“test”。这样你的测试脚本里验证码输入框永远填这个固定值即可。这完全消除了验证码带来的不确定性。策略二后端接口获取如果开发无法提供万能验证码可以请求他们提供一个内部接口当然需要做好权限控制。在测试脚本执行到登录页时先通过这个接口例如GET /api/get_verify_code?usernametest获取当前会话有效的验证码然后再填写到输入框中。策略三Cookie或Session跳过对于某些系统在验证码校验后会在Session或Cookie中设置一个标志位。可以让开发在测试环境下提供一个接口让你预先设置这个标志位从而跳过前端的验证码校验环节。策略四光学字符识别OCR - 最后的选择如果以上都行不通再考虑识别。这里不推荐用于复杂验证码但对于简单的数字验证码可以尝试。定位并截图使用Selenium定位验证码图片元素并截取该元素的图片。图像预处理对截图进行灰度化、二值化、降噪等处理提高识别率。调用OCR库使用pytesseractGoogle Tesseract的Python封装或付费的OCR API进行识别。识别结果处理将识别出的文本填入输入框。踩坑记录我曾在一个项目中使用过OCR方案识别率只有70%左右导致测试用例极不稳定。后来和开发沟通后采用了“万能验证码”方案测试稳定性立刻提升到99.9%。所以沟通永远是第一生产力。下面是一个handle_verify_code.py模块的示例它整合了多种策略# common/handle_verify_code.py import requests from PIL import Image import pytesseract from selenium.webdriver.remote.webelement import WebElement import logging logger logging.getLogger(__name__) class VerifyCodeHandler: 验证码处理器 def __init__(self, strategyfixed, fixed_code8888, api_urlNone): 初始化处理器 :param strategy: 处理策略fixed | api | ocr :param fixed_code: 固定验证码 :param api_url: 获取验证码的API地址 self.strategy strategy self.fixed_code fixed_code self.api_url api_url def get_code(self, driverNone, code_elementNone): 根据策略获取验证码 code if self.strategy fixed: code self._get_fixed_code() elif self.strategy api and self.api_url: code self._get_code_from_api(driver) elif self.strategy ocr and driver and code_element: code self._get_code_by_ocr(driver, code_element) else: logger.warning(f未配置有效的验证码处理策略: {self.strategy}) logger.info(f获取到验证码: {code}) return code def _get_fixed_code(self): 返回固定验证码 return self.fixed_code def _get_code_from_api(self, driver): 从后端接口获取验证码示例需要根据实际接口调整 try: # 假设接口需要当前会话的cookie session_cookies driver.get_cookies() cookie_dict {c[name]: c[value] for c in session_cookies} # 调用接口 response requests.get(self.api_url, cookiescookie_dict, timeout5) if response.status_code 200: # 假设接口返回JSON: {code: 1234} return response.json().get(code, ) except Exception as e: logger.error(f从API获取验证码失败: {e}) return def _get_code_by_ocr(self, driver, code_element: WebElement): 使用OCR识别验证码成功率低慎用 try: # 1. 截取验证码元素图片 location code_element.location size code_element.size driver.save_screenshot(temp_screenshot.png) # 2. 从全屏截图中裁剪出验证码区域 full_img Image.open(temp_screenshot.png) left location[x] top location[y] right left size[width] bottom top size[height] code_img full_img.crop((left, top, right, bottom)) # 3. 图像预处理简单示例 code_img code_img.convert(L) # 灰度化 # 这里可以添加二值化、降噪等更复杂的预处理 # 4. OCR识别 custom_config r--oem 3 --psm 6 outputbase digits # 尝试只识别数字 code pytesseract.image_to_string(code_img, configcustom_config).strip() # 清理识别结果中的空格和换行 code .join(code.split()) return code except Exception as e: logger.error(fOCR识别验证码失败: {e}) return 4.2 Page Object模型精讲与登录页面实现POM模型的核心思想是将每个页面抽象成一个类页面上的元素定位器Locators和操作这个元素的方法Actions都封装在这个类里。测试用例只调用页面对象提供的方法不关心元素如何定位。base_page.py(页面基类)# common/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, NoSuchElementException import logging import allure logger logging.getLogger(__name__) class BasePage: 所有页面对象的基类 def __init__(self, driver): self.driver driver self.timeout 10 # 默认显式等待超时时间 self.wait WebDriverWait(self.driver, self.timeout) def find_element(self, locator, timeoutNone): 查找单个元素支持显式等待 wait self.wait if timeout is None else WebDriverWait(self.driver, timeout) try: element wait.until(EC.presence_of_element_located(locator)) logger.debug(f定位到元素: {locator}) return element except TimeoutException: logger.error(f查找元素超时: {locator}) # 失败时截图并附加到Allure报告 allure.attach(self.driver.get_screenshot_as_png(), namefTimeout_{locator}, attachment_typeallure.attachment_type.PNG) raise def click(self, locator): 点击元素 element self.find_element(locator) try: element.click() logger.info(f点击元素: {locator}) except Exception as e: logger.error(f点击元素失败: {locator}, 错误: {e}) raise def input_text(self, locator, text): 向输入框输入文本先清空原有内容 element self.find_element(locator) try: element.clear() element.send_keys(text) logger.info(f向元素 {locator} 输入文本: {text}) except Exception as e: logger.error(f输入文本失败: {locator}, 文本: {text}, 错误: {e}) raise def get_text(self, locator): 获取元素的文本内容 element self.find_element(locator) return element.text.strip()login_page.py(登录页面对象)# page_objects/login_page.py from selenium.webdriver.common.by import By from common.base_page import BasePage from common.handle_verify_code import VerifyCodeHandler import logging logger logging.getLogger(__name__) class LoginPage(BasePage): 登录页面对象 # 页面元素定位器 (Locators) USERNAME_INPUT (By.ID, username) # 假设用户名输入框的ID是username PASSWORD_INPUT (By.ID, password) VERIFY_CODE_INPUT (By.ID, verifyCode) VERIFY_CODE_IMG (By.ID, verifyCodeImg) # 验证码图片元素用于OCR识别 LOGIN_BUTTON (By.ID, loginBtn) ERROR_MSG_SPAN (By.CLASS_NAME, error-message) # 错误信息提示元素 def __init__(self, driver): super().__init__(driver) # 初始化验证码处理器策略从配置读取这里示例用固定验证码 self.verify_handler VerifyCodeHandler(strategyfixed, fixed_code8888) def input_username(self, username): 输入用户名 self.input_text(self.USERNAME_INPUT, username) return self # 支持链式调用 def input_password(self, password): 输入密码 self.input_text(self.PASSWORD_INPUT, password) return self def input_verify_code(self, codeNone): 输入验证码 :param code: 如果传入了code则直接使用否则通过处理器获取 if code is None: # 通过策略获取验证码 code self.verify_handler.get_code(self.driver, self.find_element(self.VERIFY_CODE_IMG)) self.input_text(self.VERIFY_CODE_INPUT, code) return self def click_login(self): 点击登录按钮 self.click(self.LOGIN_BUTTON) def get_error_message(self): 获取错误提示信息如果不存在则返回空字符串 try: # 这里设置较短超时因为错误信息可能不会立即出现 return self.get_text(self.ERROR_MSG_SPAN) except Exception: return def login(self, username, password, verify_codeNone): 完整的登录操作流程 logger.info(f执行登录操作用户名: {username}) self.input_username(username) self.input_password(password) self.input_verify_code(verify_code) self.click_login()4.3 测试数据管理YAML与参数化将测试数据与测试逻辑分离是良好实践。我们使用YAML文件来管理数据因为它结构清晰支持复杂数据类型且可读性好。test_data/login_data.yaml# 登录测试数据 login_cases: # 正向用例 - name: 正向用例_管理员登录成功 username: admin password: admin123 verify_code: 8888 # 使用固定验证码 expected: success # 期望结果登录成功通常通过页面跳转或特定元素出现判断 # 反向用例 - name: 反向用例_用户名为空 username: password: anypassword verify_code: 8888 expected: 用户名不能为空 - name: 反向用例_密码错误 username: test_user password: wrong_password verify_code: 8888 expected: 用户名或密码错误 - name: 反向用例_验证码错误 username: test_user password: test123 verify_code: 9999 # 故意输入错误的验证码 expected: 验证码错误在测试用例中我们使用Pytest的pytest.mark.parametrize装饰器来读取这些数据实现数据驱动测试。4.4 Pytest Fixture驱动与页面的生命周期管理Fixture是Pytest的精华用于提供测试所需的依赖资源并管理其生命周期如setup和teardown。conftest.py(项目根目录)# conftest.py import pytest from selenium import webdriver from selenium.webdriver.chrome.options import Options from page_objects.login_page import LoginPage import logging logging.basicConfig(levellogging.INFO) logger logging.getLogger(__name__) pytest.fixture(scopesession) def config(): 读取全局配置这里简化处理 return { browser: chrome, headless: False, # 是否无头模式调试时可设为False base_url: http://your-test-site.com/login, implicit_wait: 10 } pytest.fixture(scopefunction) # 每个测试函数执行一次 def driver(config): 初始化浏览器驱动这是最核心的fixture browser config[browser] driver None if browser chrome: options Options() if config[headless]: options.add_argument(--headless) options.add_argument(--no-sandbox) options.add_argument(--disable-dev-shm-usage) options.add_argument(--disable-gpu) options.add_argument(--window-size1920,1080) # 禁止显示“Chrome正受到自动测试软件控制”的信息栏可选 options.add_experimental_option(excludeSwitches, [enable-automation]) options.add_experimental_option(useAutomationExtension, False) driver webdriver.Chrome(optionsoptions) elif browser firefox: # 类似地配置Firefox pass else: raise ValueError(f不支持的浏览器: {browser}) # 设置隐式等待 driver.implicitly_wait(config[implicit_wait]) driver.maximize_window() driver.get(config[base_url]) logger.info(f初始化 {browser} 驱动访问: {config[base_url]}) yield driver # 将driver对象提供给测试用例使用 # 测试函数执行完毕后执行teardown logger.info(测试结束关闭浏览器) driver.quit() pytest.fixture(scopefunction) def login_page(driver): 提供一个初始化好的登录页面对象 return LoginPage(driver)5. 测试用例编写与执行5.1 编写健壮的测试用例有了前面的铺垫编写测试用例就变得非常清晰和简单。测试用例只关注测试逻辑和断言。test_cases/test_login.py# test_cases/test_login.py import pytest import yaml import os from page_objects.login_page import LoginPage # 加载测试数据 def load_login_data(): data_path os.path.join(os.path.dirname(__file__), .., test_data, login_data.yaml) with open(data_path, r, encodingutf-8) as f: data yaml.safe_load(f) return data[login_cases] class TestLogin: 登录功能测试集 pytest.mark.parametrize(case, load_login_data(), idslambda c: c[name]) def test_login(self, driver, login_page, case): 数据驱动的登录测试 :param driver: fixture提供的浏览器驱动 :param login_page: fixture提供的登录页面对象 :param case: 参数化注入的每一组测试数据 # 1. 执行登录操作 login_page.login( usernamecase[username], passwordcase[password], verify_codecase.get(verify_code) # 使用数据中的验证码如果数据中未指定则用页面对象的策略 ) # 2. 根据预期结果进行断言 expected case[expected] if expected success: # 正向用例断言登录成功例如跳转到首页首页有特定元素 # 假设登录成功后跳转到 dashboard 页面其标题包含“控制台” WebDriverWait(driver, 5).until( EC.title_contains(控制台) ) assert 控制台 in driver.title # 或者断言某个登录后才显示的元素存在 # assert login_page.is_element_present(HomePage.USER_MENU) else: # 反向用例断言页面上出现了预期的错误提示信息 # 注意错误信息可能需要短暂等待才能出现 import time time.sleep(1) # 简单等待生产环境建议使用显式等待 actual_error login_page.get_error_message() assert expected in actual_error, f断言失败期望错误信息包含 {expected}实际为 {actual_error} # 也可以单独写一些不需要参数化的复杂用例 def test_login_with_wrong_code_retry(self, login_page): 测试验证码错误后刷新验证码并重试的场景 # 1. 第一次用错误验证码登录 login_page.input_username(test_user) login_page.input_password(test123) login_page.input_verify_code(wrong_code) login_page.click_login() error1 login_page.get_error_message() assert 验证码 in error1 # 2. 假设有刷新验证码的按钮点击刷新 # login_page.click(login_page.REFRESH_CODE_BUTTON) # 等待新验证码加载... # new_code login_page.get_verify_code_by_ocr() # 重新识别 # 3. 用新验证码再次登录这里简化实际需要处理新验证码 # login_page.input_verify_code(new_code) # login_page.click_login() # ... 后续断言5.2 执行测试与生成报告在项目根目录下可以创建一个简单的run_tests.py脚本或者直接使用命令行。命令行执行推荐# 运行所有测试 pytest test_cases/ -v # 运行特定测试文件 pytest test_cases/test_login.py -v # 运行带标记的测试 pytest -m smoke -v # 假设你用 pytest.mark.smoke 标记了冒烟用例 # 生成HTML报告 pytest test_cases/ -v --htmlreports/report.html --self-contained-html # 生成更强大的Allure报告 pytest test_cases/ -v --alluredirreports/allure_raw # 生成后需要安装allure命令行工具来查看 # allure serve reports/allure_rawpytest.ini配置文件[pytest] # 指定测试文件路径 testpaths test_cases # 指定python文件匹配模式 python_files test_*.py # 指定测试类匹配模式 python_classes Test* # 指定测试方法匹配模式 python_functions test_* # 添加命令行默认参数 addopts -v --strict-markers --tbshort # 定义标记防止拼写错误 markers smoke: 冒烟测试用例 regression: 回归测试用例 login: 登录模块测试6. 常见问题排查与实战技巧6.1 元素定位失败问题这是UI自动化中最常见的问题。原因1动态ID或Class。页面元素属性每次刷新都变化。解决与前端开发约定为关键测试元素添加固定的>