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

📅 2026/7/2 22:38:27 👁️ 阅读次数 📝 编程学习
Pytest+Selenium实战:攻克验证码登录的UI自动化测试框架搭建

1. 项目概述:从零构建一个健壮的UI自动化测试框架

最近在团队里做了一次关于UI自动化测试的分享,主题是如何用Pytest框架,去处理一个看似简单、实则暗藏玄机的经典场景:带验证码的登录功能测试。很多刚接触自动化的同学,一看到验证码就头疼,觉得这是自动化测试的“禁区”。其实不然,只要思路清晰、策略得当,这个“禁区”完全可以被我们系统化地攻克。今天,我就把这个实战项目的完整思路、代码实现和踩过的坑,毫无保留地分享出来。无论你是刚入门的新手,还是想优化现有框架的老手,这篇文章都能给你提供一套可直接复用的解决方案。我们将围绕“账号、密码、验证码”这个核心业务流程,构建一个结构清晰、易于维护、具备高可扩展性的Pytest UI自动化测试框架。

2. 核心需求与挑战分析

2.1 业务场景拆解

我们要测试的是一个典型的Web登录页面,它包含三个核心输入项:用户名、密码、验证码,以及一个登录按钮。从业务角度看,测试用例需要覆盖:

  1. 正向流程:输入正确的用户名、密码和验证码,登录成功。
  2. 反向流程(异常校验)
    • 用户名错误(空、格式不对、不存在)。
    • 密码错误(空、错误)。
    • 验证码错误(空、错误)。
    • 组合错误。

这听起来很简单,但难点就在于“验证码”。验证码的设计初衷就是为了防止机器自动化操作,这直接与我们自动化测试的目标相悖。

2.2 主要技术挑战

  1. 验证码识别:这是最大的拦路虎。图像验证码、滑块验证码、点选验证码等,都需要不同的破解策略。
  2. 测试数据管理:需要管理有效的测试账号、密码,以及模拟各种无效数据。
  3. 用例结构与可维护性:如何设计测试用例,使得新增用例(如测试“忘记密码”功能)时,改动最小,且不与登录逻辑耦合。
  4. 测试稳定性:UI自动化受网络、页面加载速度、元素定位稳定性影响大,需要完善的等待机制和异常处理。
  5. 测试报告与日志:需要清晰的结果展示,方便快速定位失败原因。

2.3 框架选型思路

为什么选择Pytest + Selenium + Page Object Model (POM)这个组合?

  • Pytest:远超unittest的测试框架。它的夹具(fixture)机制可以优雅地管理浏览器驱动、测试数据;参数化(@pytest.mark.parametrize)能轻松实现数据驱动测试;丰富的插件生态(如pytest-html, allure-pytest)能生成美观的报告。
  • Selenium:Web 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_elementclickinput_text等通用方法,并内置了显式等待、日志记录和截图功能。这避免了在每个页面对象中重复编写等待逻辑。
  • handle_verify_code.py: 验证码处理策略的调度中心。根据配置调用不同的处理方法。
  • login_data.yaml: 使用YAML文件管理测试数据,结构清晰,易于阅读和修改。例如,可以定义多组“用户名、密码、预期结果”的组合。

实操心得:在项目初期就搭建好这个结构,虽然多花半小时,但后期新增页面或功能时,你会感谢自己。千万不要把所有代码都堆在一个文件里。

4. 核心模块实现详解

4.1 验证码处理策略实战

验证码是UI自动化测试中的经典难题。我们的原则是:在测试环境中,优先采用非识别式的绕过屏蔽方案,这比研究复杂的识别算法更稳定、更经济。

策略一:万能验证码(推荐)这是最优雅的解决方案。与开发沟通,为测试环境提供一个固定的、通用的验证码(例如“8888”或“test”)。这样,你的测试脚本里验证码输入框永远填这个固定值即可。这完全消除了验证码带来的不确定性。

策略二:后端接口获取如果开发无法提供万能验证码,可以请求他们提供一个内部接口(当然,需要做好权限控制)。在测试脚本执行到登录页时,先通过这个接口(例如GET /api/get_verify_code?username=test)获取当前会话有效的验证码,然后再填写到输入框中。

策略三:Cookie或Session跳过对于某些系统,在验证码校验后,会在Session或Cookie中设置一个标志位。可以让开发在测试环境下,提供一个接口让你预先设置这个标志位,从而跳过前端的验证码校验环节。

策略四:光学字符识别(OCR) - 最后的选择如果以上都行不通,再考虑识别。这里不推荐用于复杂验证码,但对于简单的数字验证码,可以尝试。

  1. 定位并截图:使用Selenium定位验证码图片元素,并截取该元素的图片。
  2. 图像预处理:对截图进行灰度化、二值化、降噪等处理,提高识别率。
  3. 调用OCR库:使用pytesseract(Google Tesseract的Python封装)或付费的OCR API进行识别。
  4. 识别结果处理:将识别出的文本填入输入框。

踩坑记录:我曾在一个项目中使用过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, strategy='fixed', fixed_code='8888', api_url=None): """ 初始化处理器 :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, driver=None, code_element=None): """根据策略获取验证码""" 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, cookies=cookie_dict, timeout=5) 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, config=custom_config).strip() # 清理识别结果中的空格和换行 code = ''.join(code.split()) return code except Exception as e: logger.error(f"OCR识别验证码失败: {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, timeout=None): """查找单个元素,支持显式等待""" 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(), name=f"Timeout_{locator}", attachment_type=allure.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(strategy='fixed', fixed_code='8888') 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, code=None): """ 输入验证码 :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_code=None): """完整的登录操作流程""" 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(level=logging.INFO) logger = logging.getLogger(__name__) @pytest.fixture(scope="session") def config(): """读取全局配置(这里简化处理)""" return { 'browser': 'chrome', 'headless': False, # 是否无头模式,调试时可设为False 'base_url': 'http://your-test-site.com/login', 'implicit_wait': 10 } @pytest.fixture(scope="function") # 每个测试函数执行一次 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-size=1920,1080') # 禁止显示“Chrome正受到自动测试软件控制”的信息栏(可选) options.add_experimental_option("excludeSwitches", ["enable-automation"]) options.add_experimental_option('useAutomationExtension', False) driver = webdriver.Chrome(options=options) 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(scope="function") 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', encoding='utf-8') as f: data = yaml.safe_load(f) return data['login_cases'] class TestLogin: """登录功能测试集""" @pytest.mark.parametrize("case", load_login_data(), ids=lambda c: c['name']) def test_login(self, driver, login_page, case): """ 数据驱动的登录测试 :param driver: fixture提供的浏览器驱动 :param login_page: fixture提供的登录页面对象 :param case: 参数化注入的每一组测试数据 """ # 1. 执行登录操作 login_page.login( username=case['username'], password=case['password'], verify_code=case.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 --html=reports/report.html --self-contained-html # 生成更强大的Allure报告 pytest test_cases/ -v --alluredir=reports/allure_raw # 生成后,需要安装allure命令行工具来查看 # allure serve reports/allure_raw

pytest.ini配置文件:

[pytest] # 指定测试文件路径 testpaths = test_cases # 指定python文件匹配模式 python_files = test_*.py # 指定测试类匹配模式 python_classes = Test* # 指定测试方法匹配模式 python_functions = test_* # 添加命令行默认参数 addopts = -v --strict-markers --tb=short # 定义标记,防止拼写错误 markers = smoke: 冒烟测试用例 regression: 回归测试用例 login: 登录模块测试

6. 常见问题排查与实战技巧

6.1 元素定位失败问题

这是UI自动化中最常见的问题。

  • 原因1:动态ID或Class。页面元素属性每次刷新都变化。
    • 解决:与前端开发约定,为关键测试元素添加固定的>