基于Selenium与unittest的UI自动化测试框架搭建实战

📅 2026/7/25 13:07:04 👤 编程新知 🏷️ 技术资讯
基于Selenium与unittest的UI自动化测试框架搭建实战 1. 项目概述从零搭建一个可落地的UI自动化测试框架最近在团队里做技术复盘发现很多同事在写UI自动化测试脚本时还是停留在“脚本小子”的阶段——写一个test_xxx.py文件里面塞满了find_element和click然后直接python test_xxx.py运行。脚本一多管理起来就是灾难用例失败了得瞪大眼睛看终端输出想看看历史执行情况更是无从下手。这其实浪费了自动化测试最大的价值持续、稳定、可追溯的质量反馈。所以我花了些时间基于Python3和Selenium整合了经典的HTMLTestRunner搭建了一个结构清晰、报告直观、易于维护的自动化测试框架。这个框架不是什么高深的新技术堆砌而是用最成熟的“老伙计”们解决UI自动化中最实际的工程化问题。它特别适合中小型项目团队或者想从零开始规范自己自动化测试实践的测试开发工程师。你不用再东拼西凑跟着这个框架走一遍就能得到一个开箱即用、五脏俱全的自动化测试解决方案。2. 框架整体设计与核心思路拆解2.1 为什么是“Selenium unittest HTMLTestRunner”这个组合市面上测试框架很多Pytest功能强大Robot Framework关键字驱动。但对于UI自动化入门和中小项目我依然首选unittestHTMLTestRunner。原因很实在简单、直接、可控性强。Selenium是浏览器自动化的标准这个没得选。而unittest作为Python标准库的一部分无需额外安装提供了完整的测试用例TestCase、测试套件TestSuite、测试运行器TestRunner的概念结构非常清晰。它的setUp用例前置、tearDown用例后置方法天然适合处理浏览器启动关闭、登录态初始化这类固定流程。Pytest虽然更灵活但它的fixture机制对新手来说有一定理解成本而unittest的模式几乎一看就懂。HTMLTestRunner则是这个组合里的“点睛之笔”。unittest默认的文本报告实在太简陋了失败信息需要滚动查找更别提历史对比。HTMLTestRunner生成的HTML报告直观展示了用例通过率、执行时间、失败错误详情和日志甚至能高亮显示差异极大地提升了排查效率。它就像一个老式的机械表结构简单但走时精准可靠维护起来也方便。这个组合的技术栈非常浅团队新人能快速上手老手也能基于这个稳定的底座进行扩展比如集成邮件发送、钉钉通知等避免了在框架选择和学习上过度折腾。2.2 框架核心目录结构设计一个混乱的目录是项目腐化的开始。好的结构能强制约定代码的存放位置让所有人都遵循同一套规则。这是我为这个框架设计的基础目录结构project_root/ ├── common/ # 公共组件层 │ ├── __init__.py │ ├── base_page.py # 页面基类封装通用方法 │ ├── logger.py # 日志记录模块 │ └── config.py # 配置文件读取如URL、账号、超时时间 ├── page_objects/ # 页面对象层 │ ├── __init__.py │ ├── login_page.py # 登录页面 │ └── home_page.py # 主页页面 ├── test_cases/ # 测试用例层 │ ├── __init__.py │ ├── test_login.py │ └── test_search.py ├── test_data/ # 测试数据层 │ └── data.json 或 data.yaml ├── test_reports/ # 测试报告输出目录 │ └── (报告文件将自动生成于此) ├── test_suites/ # 测试套件组织目录可选 │ └── smoke_suite.py ├── drivers/ # 浏览器驱动存放目录 │ ├── chromedriver(.exe) │ └── msedgedriver(.exe) ├── utils/ # 工具函数层 │ ├── __init__.py │ └── send_report.py # 发送报告的工具 └── run_tests.py # 总执行入口文件设计思路解析分层明确common放框架基础能力page_objects遵循Page Object模式隔离页面元素和操作test_cases只关心业务逻辑和断言test_data实现数据驱动。各层职责单一修改页面元素不会影响测试用例。入口清晰所有执行由run_tests.py发起新人无需关心内部如何组装。资源隔离drivers目录统一管理驱动避免因驱动路径问题导致脚本失败。test_reports目录集中存放所有历史报告方便查阅。注意drivers目录下的驱动版本必须与本地安装的浏览器版本匹配。最好将驱动路径添加到系统环境变量PATH中或者在代码中指定绝对路径这是新手最容易踩的坑之一。3. 核心模块实现与关键技术点3.1 基石封装稳健的浏览器操作基类BasePage直接在每个用例里写driver.find_element_by_id(“kw”).send_keys(“selenium”)是灾难的开始。一旦页面元素id从kw变成keyword你需要修改所有用到它的用例。页面对象Page Object模式的核心思想就是将页面元素定位和操作封装成类的方法。我们的base_page.py是这个模式的基石它继承自Selenium的WebDriver封装了最常用的操作并加入了等待、日志等增强功能。# common/base_page.py import time from selenium import webdriver from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.common.exceptions import TimeoutException, NoSuchElementException from common.logger import Logger class BasePage: 所有页面对象的基类 def __init__(self, driver: webdriver.Remote): self.driver driver self.logger Logger().get_logger() self.timeout 10 # 默认显式等待超时时间 def find_element(self, locator): 查找单个元素加入显式等待和日志 self.logger.info(f正在查找元素: {locator}) try: element WebDriverWait(self.driver, self.timeout).until( EC.presence_of_element_located(locator) ) return element except TimeoutException: self.logger.error(f查找元素超时: {locator}) # 通常这里会截屏便于排查 self.driver.save_screenshot(ferror_find_{locator[1]}_{int(time.time())}.png) raise def click(self, locator): 点击元素 element self.find_element(locator) self.logger.info(f点击元素: {locator}) element.click() def input_text(self, locator, text): 输入文本 element self.find_element(locator) self.logger.info(f向元素 {locator} 输入文本: {text}) element.clear() element.send_keys(text) def get_text(self, locator): 获取元素文本 element self.find_element(locator) text element.text self.logger.info(f获取元素 {locator} 文本: {text}) return text # 可以继续封装更多通用方法如滚动、切换窗口、处理alert等关键点解析显式等待WebDriverWait配合expected_conditions是解决页面加载慢、元素未及时出现等异步问题的标准做法。比time.sleep()智能得多也更快。集中日志每个操作都通过logger记录当用例失败时查看HTML报告中的日志就能清晰还原操作步骤而不是盲目猜测。异常处理与截屏在等待超时等异常时自动截屏并将图片路径记录在日志或报告中这是线上调试的“救命稻草”。locator元组locator通常是一个元组如(By.ID, “kw”)或(By.XPATH, “//button[type‘submit’]”)这样便于统一管理。3.2 应用实现页面对象Page Objects有了稳固的基类页面对象类的编写就变得非常简洁和业务化。以登录页面为例# page_objects/login_page.py from selenium.webdriver.common.by import By from common.base_page import BasePage class LoginPage(BasePage): 登录页面 # 页面元素定位器集中管理一目了然 username_input (By.ID, ‘username’) password_input (By.ID, ‘password’) login_button (By.XPATH, ‘//button[type“submit”]’) error_msg (By.CLASS_NAME, ‘error-message’) def __init__(self, driver): super().__init__(driver) # 可以在这里添加页面特有的初始化逻辑 def open(self, url): 打开登录页面 self.driver.get(url) self.logger.info(f“打开登录页面: {url}”) return self def enter_username(self, username): 输入用户名 self.input_text(self.username_input, username) return self # 支持链式调用 def enter_password(self, password): 输入密码 self.input_text(self.password_input, password) return self def click_login(self): 点击登录按钮 self.click(self.login_button) # 点击后通常页面会跳转可以返回下一个页面的对象 from page_objects.home_page import HomePage return HomePage(self.driver) def get_error_message(self): 获取错误提示信息 return self.get_text(self.error_msg) def login(self, username, password): 完整的登录流程 self.enter_username(username) self.enter_password(password) return self.click_login()设计优势可读性极高在测试用例中你可以写login_page.login(“admin”, “123456”)业务意图非常清晰。维护点单一如果登录按钮的XPath变了你只需要修改这个类中的login_button定位器所有用到这个按钮的测试用例都自动生效。利于复用像enter_username这样的原子操作可以被多个不同的业务流如登录、密码修改复用。3.3 灵魂集成HTMLTestRunner生成可视化报告unittest默认的TextTestRunner输出是黑白的、冗长的文本。我们需要用HTMLTestRunner来替代它。你需要先下载这个模块它是一个单独的.py文件可以放在项目根目录或utils目录下。HTMLTestRunner的使用核心在于自定义一个测试运行器并配置报告的各类参数。# 在 run_tests.py 或专门的 runner 模块中 import unittest import time import os from HTMLTestRunner import HTMLTestRunner # 1. 发现测试用例 # 指定用例目录和模式 test_dir ‘./test_cases’ discover unittest.defaultTestLoader.discover(test_dir, pattern‘test_*.py’) # 2. 定义报告生成路径和文件名 report_dir ‘./test_reports’ if not os.path.exists(report_dir): os.makedirs(report_dir) now time.strftime(“%Y-%m-%d_%H-%M-%S”) report_filename os.path.join(report_dir, f‘UI_Test_Report_{now}.html’) # 3. 运行测试并生成报告 with open(report_filename, ‘wb’) as f: # 注意是‘wb’二进制写模式 runner HTMLTestRunner( streamf, title‘UI自动化测试报告’, description‘测试环境Chrome 浏览器’, verbosity2 # 控制日志详细程度 ) runner.run(discover) print(f‘测试报告已生成: {report_filename}’)HTMLTestRunner核心参数说明stream: 报告输出的文件句柄。title: 报告标题会显示在HTML的title和顶部。description: 报告描述通常写测试环境、执行机等信息。verbosity: 日志详细级别2为默认会打印详细的用例执行信息到报告中。生成的报告是一个独立的HTML文件用浏览器打开你会看到清晰的通过/失败统计、每个用例的执行时间、以及失败用例的详细错误堆栈。更重要的是它会把测试方法中的print语句或我们通过logger记录的日志也捕获并显示在报告中这对调试有巨大帮助。3.4 脉络编写清晰的测试用例TestCase测试用例类继承unittest.TestCase并在setUpClass/tearDownClass中处理全局的浏览器启停在setUp/tearDown中处理每个用例前后的操作如回到首页、清除Cookies。# test_cases/test_login.py import unittest from selenium import webdriver from page_objects.login_page import LoginPage from common.config import Config from common.logger import Logger class TestLogin(unittest.TestCase): 登录功能测试用例 classmethod def setUpClass(cls): 所有用例执行前只执行一次 cls.logger Logger().get_logger() cls.config Config() # 初始化浏览器这里以Chrome为例 cls.driver webdriver.Chrome() cls.driver.maximize_window() cls.driver.implicitly_wait(5) # 设置隐式等待作为显式等待的补充 cls.base_url cls.config.get(‘web’, ‘base_url’) classmethod def tearDownClass(cls): 所有用例执行后只执行一次 cls.driver.quit() cls.logger.info(“测试结束浏览器已关闭。”) def setUp(self): 每个用例执行前执行 self.logger.info(f“开始执行测试用例: {self._testMethodName}”) # 每个用例开始前可以访问首页确保状态干净 self.driver.get(self.base_url) def tearDown(self): 每个用例执行后执行 # 如果用例失败自动截屏 if hasattr(self, ‘_outcome’): # Python 3.4 result self._outcome.result if result.errors or result.failures: screenshot_name f“screenshot_{self._testMethodName}_{int(time.time())}.png” self.driver.save_screenshot(os.path.join(‘./test_reports/’, screenshot_name)) self.logger.error(f“用例失败已截屏: {screenshot_name}”) self.logger.info(f“测试用例 {self._testMethodName} 执行完毕。”) def test_login_success(self): 测试正常登录 login_page LoginPage(self.driver).open(f“{self.base_url}/login”) home_page login_page.login(self.config.get(‘user’, ‘correct_username’), self.config.get(‘user’, ‘correct_password’)) # 断言登录成功后页面是否跳转到了首页并且包含特定元素或文本 welcome_text home_page.get_welcome_text() self.assertIn(‘欢迎’, welcome_text, “登录成功后未显示欢迎语”) self.logger.info(“正常登录测试通过。”) def test_login_with_wrong_password(self): 测试密码错误登录 login_page LoginPage(self.driver).open(f“{self.base_url}/login”) # 这里不点击登录因为login方法会跳转我们直接调用原子方法 login_page.enter_username(self.config.get(‘user’, ‘correct_username’)) login_page.enter_password(“wrong_pwd”) login_page.click_login() # 断言应该停留在登录页并显示错误信息 error_msg login_page.get_error_message() self.assertEqual(error_msg, ‘密码错误’, “错误提示信息不正确”) self.logger.info(“密码错误登录测试通过。”)用例设计要点用例独立性每个测试方法test_*之间不应该有依赖setUp确保每个用例从一个干净的页面状态开始。断言明确使用self.assertXXX系列方法并且第二个参数msg要写明断言失败时的提示这会在HTML报告中显示帮助快速定位问题。数据与代码分离账号密码等测试数据从config.py或外部文件JSON/YAML读取避免硬编码。日志贯穿始终关键步骤都有日志形成完整的操作流水线。4. 进阶配置与工程化实践4.1 多浏览器支持与驱动管理真实环境中可能需要跨浏览器测试。我们可以通过配置文件或命令行参数来指定浏览器类型。首先在common/config.py中配置或在run_tests.py中接收参数# common/config.py (示例) import configparser import os class Config: def __init__(self, config_file‘config.ini’): self.conf configparser.ConfigParser() self.conf.read(config_file, encoding‘utf-8’) def get(self, section, option): return self.conf.get(section, option) # config.ini 文件内容示例 # [browser] # name chrome # headless false # [web] # base_url http://www.your-test-site.com然后在setUpClass中根据配置动态初始化浏览器# 修改后的 setUpClass 部分 classmethod def setUpClass(cls): cls.config Config() browser_name cls.config.get(‘browser’, ‘name’) is_headless cls.config.getboolean(‘browser’, ‘headless’) if browser_name.lower() ‘chrome’: from selenium.webdriver.chrome.options import Options options Options() if is_headless: options.add_argument(‘--headless’) # 无头模式不打开GUI options.add_argument(‘--no-sandbox’) options.add_argument(‘--disable-dev-shm-usage’) cls.driver webdriver.Chrome(optionsoptions) elif browser_name.lower() ‘firefox’: from selenium.webdriver.firefox.options import Options options Options() if is_headless: options.headless True cls.driver webdriver.Firefox(optionsoptions) elif browser_name.lower() ‘edge’: from selenium.webdriver.edge.options import Options options Options() if is_headless: options.add_argument(‘--headless’) cls.driver webdriver.Edge(optionsoptions) else: raise ValueError(f“不支持的浏览器: {browser_name}”) # ... 其他初始化注意无头模式Headless非常适合在CI/CD流水线或服务器上运行因为没有GUI开销速度更快。但在调试复杂交互或查看页面渲染问题时还是需要关闭无头模式。4.2 测试数据驱动当同一个测试逻辑需要多组数据验证时如登录功能需要测正确密码、错误密码、空密码等硬编码多个测试方法很冗余。可以使用ddt装饰器或unittest的subTest但更清晰的方式是结合parameterized库或自定义数据加载。这里展示一个使用parameterized库的简单例子# 首先安装: pip install parameterized import unittest from parameterized import parameterized from test_data.login_data import get_login_data # 假设从外部文件加载数据 class TestLoginDDT(unittest.TestCase): parameterized.expand(get_login_data()) def test_login(self, username, password, expected_result, case_name): 数据驱动登录测试 self.logger.info(f“执行用例: {case_name}”) login_page LoginPage(self.driver).open(login_url) # ... 执行登录操作 if expected_result ‘success’: # 断言成功 pass else: # 断言失败并检查错误信息 passtest_data/login_data.py可以是一个返回列表的函数数据来源可以是JSON、YAML、Excel或数据库。4.3 测试套件组织与灵活执行当有成百上千个用例时你不可能每次都跑全量。需要按模块、按优先级冒烟测试、回归测试来组织测试套件。可以在test_suites/目录下创建不同的套件文件# test_suites/smoke_suite.py import unittest from test_cases.test_login import TestLogin from test_cases.test_search import TestSearch def smoke_suite(): suite unittest.TestSuite() # 添加冒烟测试用例 suite.addTest(TestLogin(‘test_login_success’)) # 只加这一个成功用例 suite.addTest(TestSearch(‘test_search_basic’)) return suite # test_suites/full_regression_suite.py import unittest from test_cases import test_login, test_search, test_order # 导入整个模块 def full_suite(): loader unittest.TestLoader() suite unittest.TestSuite() # 加载某个测试类下的所有用例 suite.addTests(loader.loadTestsFromTestCase(test_login.TestLogin)) suite.addTests(loader.loadTestsFromTestCase(test_search.TestSearch)) # 或者加载某个模块下的所有用例 suite.addTests(loader.loadTestsFromModule(test_order)) return suite然后在run_tests.py中你可以选择运行哪个套件# run_tests.py import unittest from test_suites import smoke_suite, full_regression_suite if __name__ ‘__main__’: # 选择要运行的套件 suite smoke_suite.smoke_suite() # 运行冒烟测试 # suite full_regression_suite.full_suite() # 运行全量回归 # ... 使用HTMLTestRunner运行这个suite with open(report_filename, ‘wb’) as f: runner HTMLTestRunner(streamf, ...) runner.run(suite)更工程化的做法是通过命令行参数来指定运行模式例如python run_tests.py --suite smoke。5. 实战踩坑与效能提升技巧5.1 元素定位的稳定性与维护策略UI自动化最大的挑战就是元素定位不稳定页面一变脚本就挂。以下是我总结的几条铁律优先级IDNameCSS SelectorXPath。ID通常是唯一且最稳定的。尽量避免使用包含索引如div[3]或绝对路径的XPath。使用相对XPath或CSS利用元素的属性、文本或层级关系来定位。例如//button[text()‘登录’]或input[name‘username’]。封装等待除了在BasePage中使用显式等待对于某些动态加载特别复杂的元素如富文本编辑器可以封装一个自定义等待方法结合多种条件轮询。使用Page Factory模式进阶虽然我们用了传统的定位器属性但Selenium的PageFactory模式配合FindBy注解在Java中很流行Python社区也有类似库如selenium-page-factory可以进一步简化页面对象类的编写。5.2 测试报告增强与归档原生的HTMLTestRunner报告已经不错但我们可以让它更好自动附加截图我们在tearDown中实现了失败截屏。可以修改HTMLTestRunner的源码它是一个单文件修改方便在生成报告时将失败用例对应的截图路径以img标签的形式插入到报告的错误详情中实现“图文并茂”的报错。历史报告趋势每次生成的报告都以时间戳命名。可以写一个简单的脚本在每次运行后将本次报告的关键数据通过率、总用时、失败用例列表追加到一个历史趋势文件如JSON或CSV中甚至生成一个简单的趋势图。报告自动发送集成邮件smtplib或钉钉/企业微信机器人在测试完成后特别是失败时自动将报告链接或摘要发送给相关人。这部分逻辑可以放在run_tests.py的最后或者用一个单独的utils/send_report.py模块实现。5.3 在CI/CD流水线中集成自动化测试只有集成到持续集成流程中才能最大化其价值。以Jenkins为例环境准备在Jenkins节点上安装好Python、项目依赖通过requirements.txt、以及对应版本的浏览器和驱动对于无头模式可能还需要安装一些虚拟显示库如xvfb。任务配置源码管理拉取你的测试代码仓库。构建触发器可以定时构建或由代码提交Git hook触发。构建步骤执行一个Shell命令例如cd /path/to/your/project pip install -r requirements.txt # 安装依赖 python run_tests.py --browser chrome --headless --suite regression后置操作收集报告使用Jenkins的插件如HTML Publisher plugin来收集并发布生成的HTML报告这样在Jenkins界面上就能直接点击查看。结果判定可以根据测试退出码或解析报告中的通过率来决定本次构建是成功还是失败不稳定。5.4 常见问题排查清单速查表问题现象可能原因排查步骤与解决方案WebDriverException: Message: ‘chromedriver’ executable needs to be in PATH1. 未下载chromedriver。2. chromedriver未放在PATH或指定路径。3. chromedriver版本与Chrome浏览器版本不匹配。1. 从官方镜像站下载对应版本的驱动。2. 将驱动放在项目drivers/目录并在代码中指定路径webdriver.Chrome(executable_path‘./drivers/chromedriver’)。3. 检查Chrome版本chrome://version/下载匹配的驱动。NoSuchElementException或TimeoutException1. 元素定位器写错了。2. 页面尚未加载完成元素不可见/不可交互。3. 元素在iframe或shadow DOM内。4. 页面是动态渲染的如SPA元素尚未出现。1. 用浏览器开发者工具重新检查定位器。2. 增加显式等待时间或改用等待元素可点击(EC.element_to_be_clickable)。3. 使用driver.switch_to.frame()切换到iframe对于shadow DOM需用execute_script穿透。4. 等待某个特定标志性元素出现作为页面加载完成的判断。脚本在本地运行成功在CI服务器失败1. CI服务器是无头环境缺少GUI或某些依赖。2. 服务器网络或资源限制页面加载超慢。3. 服务器时区、语言环境与本地不同。1. 确保使用无头模式并添加必要的Chrome选项如--no-sandbox,--disable-dev-shm-usage。2. 全局增加隐式等待和显式等待的超时时间。3. 在启动浏览器时通过options设置一致的语言偏好。生成的HTML报告是空的或乱码1. 打开报告文件的方式不对HTMLTestRunner需要二进制写入。2. 测试套件discover没有找到任何用例。1. 检查open(file, ‘wb’)是否是二进制写入模式。2. 检查discover的起始目录和文件匹配模式(pattern)是否正确。在run_tests.py中打印一下discover到的用例数量。用例之间相互影响1. 浏览器状态未清理干净如cookies, local storage。2. 用例依赖了上一个用例产生的数据。1. 在setUp或tearDown中使用driver.delete_all_cookies()并访问一个中性页面如首页。2. 严格遵守用例独立性原则使用不同的测试账号或每次清理测试数据。搭建这个框架的过程本身就是一个很好的学习项目。它不要求你一开始就面面俱到可以从最核心的BasePage、一个PageObject和一个测试用例开始让脚本先跑起来。然后逐步加入日志、报告、数据驱动、配置管理。每增加一个特性你对UI自动化工程化的理解就会深一层。最终你会得到一个完全贴合自己项目需求、维护起来得心应手的自动化测试框架这才是它最大的价值所在。